Transactions and Concurrency in Spring
A production guide to @Transactional, propagation and isolation levels, and optimistic vs pessimistic locking for data consistency.
Transactions and Concurrency in Spring
@Transactional looks like a single annotation, but it's the surface of a proxy-based mechanism with real, consequential decisions hiding underneath: propagation behavior, isolation level, read-only optimization, and rollback rules. Getting these wrong doesn't usually fail loudly — it fails as a subtle data-consistency bug that shows up under concurrent load, weeks after the code shipped. This guide covers @Transactional from the AOP mechanism up through the locking strategies you need for real concurrent write paths.
1. How @Transactional Actually Works
Spring's declarative transaction management is implemented with AOP proxies, not bytecode magic inside your class. When you annotate a @Service bean's method with @Transactional, Spring wraps the bean in a proxy at startup that begins a transaction before the method runs and commits/rolls back after it returns.
Self-invocation defeats the proxy. If placeOrder() calls this.chargeCustomer() internally, and chargeCustomer() is separately annotated @Transactional, that annotation is silently ignored — the call goes directly to the target object, bypassing the proxy entirely. This is one of the most common @Transactional bugs in real codebases. The fix: move the method to a separate Spring bean and inject it, or use AopContext.currentProxy() (requires exposeProxy = true) as a last resort.
@Service
public class OrderService {
@Transactional
public void placeOrder(OrderRequest request) {
Order order = createOrder(request);
this.chargeCustomer(order); // ← proxy bypassed, new @Transactional ignored!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void chargeCustomer(Order order) {
// this NEVER runs in its own transaction when called via `this.`
}
}Default rollback rules
By default, Spring rolls back on unchecked exceptions (RuntimeException, Error) and commits on checked exceptions. This trips people up coming from a "any exception rolls back" mental model.
@Transactional(rollbackFor = OrderValidationException.class) // checked exception — must opt in
public void placeOrder(OrderRequest request) throws OrderValidationException {
if (!isValid(request)) {
throw new OrderValidationException("Invalid order"); // rolled back only because of rollbackFor
}
}Production convention: prefer unchecked (RuntimeException-derived) domain exceptions for anything that should trigger a rollback, and reserve rollbackFor/noRollbackFor for the rare exception where you deliberately want the opposite of the default behavior.
2. Propagation Types
Propagation controls how a transactional method behaves when called from within an existing transaction — join it, suspend it, nest inside it, or reject it.
| Propagation | Behavior when a transaction already exists | Behavior when none exists |
|---|---|---|
REQUIRED (default) | Joins the existing transaction | Starts a new one |
REQUIRES_NEW | Suspends the existing one, starts an independent new one | Starts a new one |
NESTED | Creates a savepoint within the existing transaction | Starts a new one |
SUPPORTS | Joins if one exists | Runs non-transactionally |
NOT_SUPPORTED | Suspends the existing one, runs non-transactionally | Runs non-transactionally |
MANDATORY | Joins the existing transaction | Throws IllegalTransactionStateException |
NEVER | Throws IllegalTransactionStateException | Runs non-transactionally |
REQUIRES_NEW in practice: audit logging that survives a rollback
@Service
public class OrderService {
private final AuditLogService auditLogService;
@Transactional
public void placeOrder(OrderRequest request) {
auditLogService.logAttempt(request); // must survive even if placeOrder rolls back
Order order = validateAndCreate(request); // may throw
orderRepository.save(order);
}
}
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAttempt(OrderRequest request) {
auditRepository.save(new AuditEntry(request));
// commits independently, even if the caller's transaction later rolls back
}
}REQUIRES_NEW suspends the outer transaction's connection and acquires a second database connection from the pool for the duration of the inner call. Under connection-pool pressure, nesting REQUIRES_NEW calls inside high-throughput code paths can exhaust the pool. Use it sparingly, and only where independent commit semantics are truly required (audit logs, outbox writes, rate-limit counters).
NESTED vs REQUIRES_NEW
| Aspect | NESTED | REQUIRES_NEW |
|---|---|---|
| Connection | Same connection, uses a DB savepoint | New, separate connection |
| Outer tx on inner failure | Outer can catch and continue (rolls back to savepoint) | Outer is unaffected — independent commit/rollback |
| Outer tx if it later fails | Rolls back inner changes too (savepoint is part of the same tx) | Inner already committed — unaffected |
| DB support | Requires savepoint support (most RDBMS; not all JTA setups) | Universally supported |
3. Isolation Levels
Isolation levels control what concurrent transactions are allowed to see of each other's uncommitted or concurrently-committed changes.
| Isolation level | Dirty read | Non-repeatable read | Phantom read | Typical default |
|---|---|---|---|---|
READ_UNCOMMITTED | Possible | Possible | Possible | Rare in practice |
READ_COMMITTED | Prevented | Possible | Possible | PostgreSQL, Oracle, SQL Server default |
REPEATABLE_READ | Prevented | Prevented | Possible* | MySQL/InnoDB default |
SERIALIZABLE | Prevented | Prevented | Prevented | Highest isolation, lowest concurrency |
REPEATABLE_READ actually prevents most phantom reads too, via next-key locking — a well-known deviation from the strict SQL standard definition.
@Transactional(isolation = Isolation.READ_COMMITTED)
public BigDecimal getAccountBalance(Long accountId) {
return accountRepository.findById(accountId)
.orElseThrow()
.getBalance();
}Isolation.DEFAULT (the annotation's default) means "use whatever the underlying database's configured default is" — Spring does not impose its own isolation level unless you specify one. Most services never override this; it's a lever to pull only when you've identified a specific race condition the DB default doesn't prevent.
The three read anomalies
Higher isolation levels reduce anomalies but increase lock contention and reduce throughput. SERIALIZABLE is rarely used in high-throughput web services — reach for it only in specific, narrow code paths (e.g., financial ledger reconciliation) where correctness genuinely outweighs concurrency, and consider whether optimistic locking solves the same problem more cheaply.
4. Read-Only Transactions
@Transactional(readOnly = true)
public List<OrderSummary> listOrders(Long customerId) {
return orderRepository.findSummariesByCustomer(customerId);
}readOnly = true is a hint, not an enforced constraint at the JPA level — but Hibernate uses it to:
- Set the underlying JDBC connection's read-only flag (some drivers/DBs use this to route to a read replica).
- Skip dirty checking at flush time — Hibernate won't diff entity state against its loaded snapshot, saving CPU on large result sets.
- Optionally set
FlushMode.MANUAL, avoiding unnecessary flush cycles.
Mark every query-only service method @Transactional(readOnly = true). It's a small, free win — less Hibernate bookkeeping per request, plus a documented contract for anyone reading the code that this path performs no writes.
5. Optimistic Locking with @Version
Optimistic locking assumes conflicts are rare — it lets transactions proceed without locking rows, and detects conflicts only at commit time.
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "account_seq")
private Long id;
private BigDecimal balance;
@Version
private Long version;
}Every UPDATE Hibernate issues includes the version in the WHERE clause and increments it:
UPDATE account SET balance = ?, version = version + 1
WHERE id = ? AND version = ?If another transaction already committed a change (bumping the version), this UPDATE affects zero rows, and Hibernate throws OptimisticLockException (wrapped as ObjectOptimisticLockingFailureException by Spring).
@Service
public class AccountService {
private final AccountRepository accountRepository;
@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2)
)
@Transactional
public void withdraw(Long accountId, BigDecimal amount) {
Account account = accountRepository.findById(accountId).orElseThrow();
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(accountId);
}
account.setBalance(account.getBalance().subtract(amount));
// no explicit save() needed — dirty checking flushes the UPDATE on commit
}
}@Retryable (from spring-retry) is the standard companion to optimistic locking — a conflict is expected and recoverable, not exceptional. Retry with backoff on ObjectOptimisticLockingFailureException, but always re-read the entity fresh on each attempt (a new transaction means a fresh SELECT, which the pattern above does automatically since the whole method re-runs).
6. Pessimistic Locking
Pessimistic locking acquires a database-level row lock before reading, blocking other transactions from reading (in exclusive mode) or writing to the same row until the lock is released.
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findByIdForUpdate(@Param("id") Long id);
}Generates (on Postgres):
SELECT * FROM account WHERE id = ? FOR UPDATE;| Lock mode | SQL equivalent | Blocks other readers? | Blocks other writers? |
|---|---|---|---|
PESSIMISTIC_READ | SELECT ... FOR SHARE | No | Yes |
PESSIMISTIC_WRITE | SELECT ... FOR UPDATE | Yes (for write locks) | Yes |
PESSIMISTIC_FORCE_INCREMENT | FOR UPDATE + forces @Version bump | Yes | Yes |
@Service
public class AccountService {
private final AccountRepository accountRepository;
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// lock ordering matters — always lock in a consistent order to avoid deadlocks
Long first = Math.min(fromId, toId);
Long second = Math.max(fromId, toId);
Account a = accountRepository.findByIdForUpdate(first).orElseThrow();
Account b = accountRepository.findByIdForUpdate(second).orElseThrow();
Account from = fromId.equals(first) ? a : b;
Account to = fromId.equals(first) ? b : a;
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
}
}Lock ordering is the #1 cause of deadlocks in pessimistic-locking code. If one transaction locks account 1 then account 2, and a concurrent transaction locks account 2 then account 1, you get a classic deadlock — the database will detect it and abort one of the transactions (usually with a DeadlockLoserDataAccessException in Spring). Always acquire locks in a globally consistent order (e.g., always by ascending ID) as shown above.
7. Optimistic vs Pessimistic: Choosing
| Factor | Optimistic (@Version) | Pessimistic (SELECT FOR UPDATE) |
|---|---|---|
| Conflict frequency | Low — conflicts are rare | High — contention is expected |
| Throughput | Higher — no blocking on read | Lower — readers/writers block |
| Failure mode | Exception at commit, needs retry logic | Blocks until lock released, or times out |
| Long-held locks | None — no lock held during "think time" | Risky — a slow transaction blocks others |
| Typical use case | Web form edits, shopping carts, most CRUD | Financial transfers, inventory decrements, counters |
| Deadlock risk | None | Real — requires consistent lock ordering |
Default to optimistic locking. It scales better under normal web traffic where two users editing the same row at the same instant is rare. Reach for pessimistic locking specifically for high-contention hotspots — a single row that many concurrent requests are guaranteed to touch, like a shared inventory counter or a ledger balance — where retry storms from optimistic locking would themselves become a throughput problem.
8. Production Observations
- Transaction boundaries belong at the service layer, not the repository or controller layer — one
@Transactionalservice method per logical unit of work. - Keep transactions short. Never call an external HTTP API, send an email, or do slow I/O inside a
@Transactionalmethod — it holds a DB connection and, under pessimistic locking, row locks, for the entire duration. @Transactionalon aprivatemethod silently does nothing — Spring's proxy-based AOP can only intercept public method calls made through the proxy.- Test transactional behavior explicitly.
@Transactionalon a@SpringBootTestrolls back after each test by default — useful for isolation, but make sure you're not accidentally testing behavior that only "works" because of that automatic rollback masking a missing@Transactionalin production code.
Key takeaways
@Transactionalis implemented via AOP proxies — self-invocation (this.method()) silently bypasses it.- By default, Spring rolls back on unchecked exceptions only; use
rollbackForto roll back on checked exceptions. REQUIRED(default) joins an existing transaction;REQUIRES_NEWsuspends it and opens an independent one (uses a second DB connection);NESTEDuses a savepoint within the same connection.- Isolation levels trade correctness guarantees (dirty/non-repeatable/phantom reads) for concurrency; most services never need anything beyond the database's default (
READ_COMMITTEDon Postgres). - Mark every read-only service method
@Transactional(readOnly = true)— it's a free optimization and a documentation signal. - Optimistic locking (
@Version) scales better under normal contention; pessimistic locking (SELECT FOR UPDATE) is for genuine hotspots where retries would themselves become a bottleneck. - Always acquire pessimistic locks in a consistent, global order to avoid deadlocks.
- Keep transactions short — no external calls, no slow I/O — since they hold DB connections and, under pessimistic locks, block other transactions.
Interview Questions
- How is
@Transactionalimplemented under the hood in Spring? What is the practical consequence of it being proxy-based? - Why does calling a
@Transactionalmethod viathis.from within the same class not start a new transaction? - What is the default rollback behavior in Spring — does it roll back on checked exceptions?
- Explain the difference between
REQUIRED,REQUIRES_NEW, andNESTEDpropagation, with a concrete use case for each. - What database resource cost does
REQUIRES_NEWincur thatNESTEDavoids? - What are dirty reads, non-repeatable reads, and phantom reads? Which isolation levels prevent each?
- Why is
READ_COMMITTEDa reasonable default for most web applications, and when would you escalate toSERIALIZABLE? - What does
@Transactional(readOnly = true)actually change about Hibernate's behavior? - How does optimistic locking with
@Versiondetect a conflicting concurrent update? What SQL does Hibernate generate? - When would you choose pessimistic locking over optimistic locking?
- What causes a deadlock in a pessimistic-locking transfer operation, and how do you prevent it?
- What's the difference between
PESSIMISTIC_READandPESSIMISTIC_WRITE? - Why should you avoid making external HTTP calls inside a
@Transactionalmethod? - How would you design a retry strategy for
ObjectOptimisticLockingFailureException?