Spring Boot @Transactional Pitfalls: Deadlocks, Long Transactions & Propagation Failures

This article analyzes three critical @Transactional pitfalls in Spring Boot 3.5.0: deadlocks from inconsistent lock ordering during concurrent transfers, long transactions holding row locks during external RPC calls causing blocking and inconsistency, and nested transaction propagation causing unexpected rollbacks despite try-catch. Solutions include consistent lock ordering, transaction splitting, and using REQUIRES_NEW or programmatic transactions.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Boot @Transactional Pitfalls: Deadlocks, Long Transactions & Propagation Failures

Environment: Spring Boot 3.5.0

1. Introduction

While @Transactional declarative transactions greatly simplify data consistency management, they can hide performance and data consistency hazards under high concurrency and complex business flows. This article addresses three common pain points in real architectures: database deadlocks from concurrent transfers, long transactions holding row locks and blocking RPC calls, and nested transaction propagation failures causing unexpected rollbacks, along with optimization solutions.

2. Practical Cases

2.1 Deadlock

Two transactions can deadlock even without any exceptional operations. Consider a transfer between two accounts. One request transfers from account A to B, another from B to A, both wrapped in transactions. The following complete example demonstrates the issue:

public interface AccountRepository extends JpaRepository<Account, Long> {
  @Lock(LockModeType.PESSIMISTIC_WRITE)
  Optional<Account> findById(Long id);
}
// Service operating on two accounts for transfer
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
  Account from = accountRepository.findById(fromId).orElseThrow();
  Account to = accountRepository.findById(toId).orElseThrow();

  from.setBalance(from.getBalance().subtract(amount));
  to.setBalance(to.getBalance().add(amount));
}

Transaction 1 locks account A then attempts to lock account B. Simultaneously, transaction 2 locks account B and attempts to lock account A. Each holds a resource the other needs; neither can proceed. The database detects the deadlock and aborts one transaction so the other can continue. This is not a database failure but a self-protection measure. Individually each method call looks correct, yet concurrency exposes the problem. The test case below reproduces it:

@Resource
private AccountService accountService;

@Test
public void testTransfer() throws Exception {
  Thread t1 = new Thread(() -> {
    accountService.transfer(1L, 2L, BigDecimal.valueOf(20));
  });
  t1.start();

  Thread t2 = new Thread(() -> {
    accountService.transfer(2L, 1L, BigDecimal.valueOf(20));
  });
  t2.start();

  t1.join();
  t2.join();
}

Execution result: console outputs a deadlock error (see image below). One thread succeeds while the other fails with a deadlock exception.

Deadlock error console output
Deadlock error console output

One thread's operation succeeds (see image below).

Successful thread output
Successful thread output

Solution: Consistent lock ordering avoids many deadlocks. A practical method is to always acquire locks in a consistent order. We can always lock the account with the smaller ID first. The modified transfer method:

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
  Long firstId = Math.min(fromId, toId);
  Long secondId = Math.max(fromId, toId);

  Account first = accountRepository.findById(firstId).orElseThrow();
  Account second = accountRepository.findById(secondId).orElseThrow();

  Account from = firstId.equals(fromId) ? first : second;
  Account to = firstId.equals(fromId) ? second : first;

  from.setBalance(from.getBalance().subtract(amount));
  to.setBalance(to.getBalance().add(amount));
}

If every transfer follows the same ordering rule, two transactions involving the same pair of accounts will not acquire locks in opposite order. One transaction may still wait, but the specific circular dependency is eliminated.

2.2 Long Transaction

Suppose a service method updates a database record then calls an external API. Example:

private final PaymentClient paymentClient;

@Transactional
public void processAccount(Long accountId) {
  Account account = accountRepository.findById(accountId)
    .orElseThrow(() -> new RuntimeException("Account not found: " + accountId));
  account.markProcessing();
  paymentClient.charge(account.getId());
  account.markCompleted();
}

This code has three problems:

Row lock held longer by RPC: When the payment interface is slow, the account row lock is not released, causing other transactions to wait and potentially time out.

Intermediate state invisible: markProcessing() and markCompleted() are in the same transaction, so the PROCESSING state is not visible to other transactions until commit.

External call succeeds but database rolls back: Payment is deducted, but if the transaction later fails and rolls back, the account state remains unchanged, creating inconsistency.

Solution: Decompose the transaction into smaller transactions.

@Transactional
public Long markProcessing(Long accountId) {
  Account account = accountRepository.findById(accountId)
    .orElseThrow(() -> new RuntimeException("Account not found: " + accountId));
  if (account.getStatus() != AccountStatus.NORMAL) {
    throw new IllegalStateException("Account status not allowed for processing: " + account.getStatus());
  }
  account.markProcessing();
  return account.getId();
}

@Transactional
public void markCompleted(Long accountId) {
  Account account = accountRepository.findById(accountId)
    .orElseThrow(() -> new RuntimeException("Account not found: " + accountId));
  account.markCompleted();
}

// Original order processing operation
// Do NOT add @Transactional here
public void processAccount(Long accountId) {
  Long id = accountStateService.markProcessing(accountId);
  try {
    paymentClient.charge(id);
    accountStateService.markCompleted(accountId);
  } catch (Exception e) {
    throw e;
  }
}

2.3 Transaction Failure?

Assume we save an account then log an operation; if logging fails, the account save should not be affected.

@Transactional
public void saveAccount(Account account) {
  this.accountRepository.save(account);
  try {
    // Does try...catch here affect the save above?
    this.logService.saveLog(new OperatorLog("Save account"));
  } catch (Exception e) {
    System.err.println("Exception: %s".formatted(e.getMessage()));
  }
}

// Operation log
@Transactional
public void saveLog(OperatorLog log) {
  this.logRepository.save(log);
  // Simulate error
  System.err.println(1 / 0);
}

The code does not behave as expected. You might think the try/catch swallows the runtime exception thrown by saveLog within the same transaction, but it cannot swallow the rollback-only flag already set by the transaction interceptor. Ultimately, the outer commit throws UnexpectedRollbackException, and both Account and log are rolled back.

Solution 1: Due to default transaction propagation behavior, we can change the transaction propagation of saveLog to REQUIRES_NEW:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog(OperatorLog log)

With this change, saveLog executes in a new transaction.

Solution 2: Use programmatic transactions.

public void saveAccount(Account account) {
  this.transactionTemplate.executeWithoutResult(status -> {
    this.accountRepository.save(account);
  });
  try {
    this.logService.saveLog(new OperatorLog("Save account"));
  } catch (Exception e) {
    System.err.println("Exception: %s".formatted(e.getMessage()));
  }
}
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

concurrencydeadlockSpring Boot@Transactionallong transactionprogrammatic transactiontransaction propagationREQUIRES_NEW
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.