Transactions, locking and concurrency

Propagation and rollback rules, the self-invocation trap, optimistic versioning, pessimistic locks and retry strategies.

Where the transaction really starts

@Service
public class TransferService {

  @Transactional(rollbackFor = Exception.class)
  public void transfer(Long from, Long to, BigDecimal amount) {
    var a = accounts.findByIdForUpdate(from).orElseThrow();
    var b = accounts.findByIdForUpdate(to).orElseThrow();
    a.debit(amount);
    b.credit(amount);
  }

  // WRONG: self-invocation bypasses the proxy, so no transaction is created
  public void outer() { this.transfer(1L, 2L, TEN); }
}
  • @Transactional is implemented by a proxy: calling this.method() skips it entirely.
  • Default rollback covers RuntimeException and Error only - checked exceptions commit unless you say rollbackFor.
  • readOnly = true lets Hibernate skip dirty checking and the driver pick a replica.
  • Keep transactions short. An HTTP call inside a transaction holds database locks for the duration of network latency.
⚠️
Propagation REQUIRES_NEW suspends the outer transaction and opens a second connection. Two connections per thread quickly exhaust a small pool, and the inner commit survives an outer rollback.

Optimistic and pessimistic locking

@Entity
public class Account {
  @Id Long id;
  BigDecimal balance;

  @Version
  Long version;   // Hibernate adds "and version = ?" to every UPDATE
}

// pessimistic, blocks other writers until commit
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
Optional<Account> findByIdForUpdate(@Param("id") Long id);
ApproachBehaviourBest for
@VersionConflicting write throws OptimisticLockExceptionRead-mostly rows, web edits
PESSIMISTIC_READSELECT ... FOR SHAREShort critical sections, low contention
PESSIMISTIC_WRITESELECT ... FOR UPDATECounters and queues where retries are worse than waiting
Advisory lockDatabase-level named lockCross-service coordination on one row
@Retryable(retryFor = OptimisticLockingFailureException.class,
           maxAttempts = 3, backoff = @Backoff(delay = 50, multiplier = 2))
@Transactional
public void adjust(Long id, BigDecimal delta) { /* re-read inside the retry */ }

The retry must re-read the row inside the transaction, otherwise it retries with the same stale version and fails again. Detect version mismatches at the boundary and translate them into a 409 response.

FAQ

Why is @Transactional ignored on a private method?
Spring proxies only intercept calls that arrive through the proxy. Private methods are not callable through it, so no advice applies. Make the method public and call it from another bean.
Should a transaction wrap an HTTP call to another service?
No. Do the remote call outside the transaction and persist the result after. Holding locks across a network round trip is how a slow third party takes down your write path.

JPA relationships, queries and pagination Caching, async processing and scheduling

Last refreshed 2026-09-18.