Transaction Isolation & Locking
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
@Transactional defines where a transaction starts and ends, and propagation defines how a boundary
relates to one already in progress — both covered in
Spring Data JPA and
Spring Data Overview. This page covers the two settings that
decide what happens inside those boundaries when several transactions touch the same data at the same time:
the isolation level the transaction runs at, and whether the application takes an explicit pessimistic
lock instead of relying on @Version optimistic locking.
The ANSI/SQL isolation model itself — what each level means, exactly which anomaly it permits, and how a
concrete engine implements it — belongs to the database, and is covered in
SQL Reference: Transaction Control. What follows is the Spring layer over
that model: the isolation attribute, the @Lock annotation, how each store’s transaction manager behaves, and
how to choose a strategy as a Spring Data developer.
Isolation levels in Spring
@Transactional carries an isolation attribute taking a value of the
org.springframework.transaction.annotation.Isolation enum. The PlatformTransactionManager applies it when it
starts the transaction — for the relational managers (DataSourceTransactionManager, JpaTransactionManager)
that means a Connection.setTransactionIsolation(…) call on the JDBC connection, reverted when the connection
returns to the pool:
@Service
public class ReportService {
private final OrderRepository orders;
public ReportService(OrderRepository orders) {
this.orders = orders;
}
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public MonthlyReport buildReport(Long customerId) {
// every read in this method sees the same consistent snapshot of the customer's orders
return MonthlyReport.of(orders.findByCustomerId(customerId));
}
}
The five enum constants and the read phenomena each one permits:
Isolation constant |
Dirty read | Non-repeatable read | Phantom read | Notes |
|---|---|---|---|---|
|
— |
— |
— |
Do not set anything — keep whatever the datasource/database default is. The default value of the attribute. |
|
permitted |
permitted |
permitted |
Reads uncommitted changes of other transactions. Rarely useful; several engines do not implement it at all. |
|
prevented |
permitted |
permitted |
Only committed data is visible, but a row re-read within the transaction can have changed. |
|
prevented |
prevented |
permitted |
A row re-read within the transaction is unchanged; newly matching rows can still appear. |
|
prevented |
prevented |
prevented |
The result is equivalent to running the concurrent transactions one after another. |
See SQL Reference: Transaction Control for what each phenomenon is, the per-level detail, and how a given engine actually enforces the level (locking, multi-version concurrency control, or serializable snapshot isolation).
Isolation.DEFAULT — the default — means Spring sets nothing and the database’s own default applies. That
default differs by engine: PostgreSQL, Oracle, and SQL Server default to READ COMMITTED, MySQL/InnoDB to
REPEATABLE READ. Code that depends on a specific level must therefore state it explicitly rather than assume a
portable baseline.
|
Not every level is supported by every engine, and not every engine implements a level the way the ANSI
definition describes. Oracle, for instance, accepts only |
How isolation level affects performance
Raising the isolation level never makes a workload faster. It buys stronger guarantees by making the engine do more work, hold more state, or hold it for longer:
-
Lock scope and duration grow with the level. A locking engine holds shared read locks to the end of the transaction at
REPEATABLE READinstead of releasing them after each statement, and may escalate to range or predicate locks atSERIALIZABLEso that rows which do not exist yet cannot be inserted by anyone else. -
MVCC bookkeeping costs more. On a snapshot-based engine the price is paid in old row versions that must be retained while any transaction can still see them, longer version chains to walk on read, and vacuum/undo work deferred until the oldest snapshot closes. A long-running
REPEATABLE READtransaction pins that garbage for everybody. -
Anomalies become explicit failures. The weaker levels let a conflicting interleaving succeed quietly with a slightly wrong answer; the stronger ones convert the same interleaving into something visible — blocking, a lock-wait timeout, a deadlock victim, or a serialization failure. That is the point, but it means the caller now needs a retry path for exceptions that simply never occurred before.
-
Throughput falls as contention rises. The cost of a stricter level is roughly proportional to how often transactions actually collide. On data no two requests touch at once,
SERIALIZABLEis nearly free; on a hot row or a hot index range, it can collapse concurrency to one transaction at a time.
Practical consequences for a Spring service:
-
Keep transactions short. Isolation cost is paid per unit of time the transaction stays open, so never span a remote HTTP call, a message round-trip, or user think time inside a
@Transactionalmethod. -
Raise the level on the narrowest method that needs it rather than globally —
isolationis a per-boundary attribute precisely so a single report or balance check can be strict while the rest of the application is not. -
Mark read paths
@Transactional(readOnly = true): it lets the JPA provider skip dirty checking and flushing, and lets some drivers and proxies route the work to a replica. -
If a stricter level is chosen, write the retry.
SERIALIZABLEon PostgreSQL, and deadlock resolution on any engine, mean a transaction that has done nothing wrong can still be aborted and must be replayed.
Pessimistic locking
Pessimistic locking takes the database lock up front, on read, and holds it until the transaction commits, so
no competing transaction can modify (or, depending on the mode, even read) the row in between. In Spring Data
JPA this is the @Lock annotation on a repository query method, with a jakarta.persistence.LockModeType:
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Optional<Account> findWithLockById(Long id);
}
@Service
public class WithdrawalService {
private final AccountRepository accounts;
public WithdrawalService(AccountRepository accounts) {
this.accounts = accounts;
}
@Transactional
public void withdraw(Long accountId, BigDecimal amount) {
Account account = accounts.findWithLockById(accountId).orElseThrow();
// the row is locked from here until this method's transaction commits --
// any concurrent withdraw() on the same account waits (up to 3 s) rather than conflicting
account.setBalance(account.getBalance().subtract(amount));
accounts.save(account);
}
}
The lock modes:
LockModeType |
Meaning |
|---|---|
|
A shared lock. Other transactions may read the row, none may update it. Use when the value must not change while it is being used to compute something, but this transaction will not write it. |
|
An exclusive lock, emitted as |
|
An exclusive lock that additionally bumps the entity’s |
Some details worth knowing:
-
The annotated method must run inside a transaction — a lock acquired by a repository call with no surrounding
@Transactionalboundary is released immediately when that call’s implicit transaction commits, which defeats the purpose. -
@QueryHintswithjakarta.persistence.lock.timeout(milliseconds) caps how long the statement waits for the lock; on expiry the provider throwsLockTimeoutException, surfaced by Spring asPessimisticLockingFailureException. A value of0means "do not wait at all" where the dialect supports it. Support is dialect-dependent — PostgreSQL and Oracle honour it, several others ignore the hint. -
Locks taken in an inconsistent order across concurrent transactions produce deadlocks; the engine kills one participant and the application sees a
CannotAcquireLockException. Always acquire multiple locks in a stable order (for example, by ascending primary key). -
The lock is held for the whole remaining transaction, so a pessimistic path must be short. It also does not survive across requests — it cannot protect an edit that spans a user filling in a form.
-
Modes can also be applied outside a repository method, via
EntityManager.lock(entity, lockMode)orfind(Class, id, lockMode), and to a@Query-annotated method by adding@Lockto it.
Optimistic locking (recap)
Optimistic locking takes no database lock. A @Version field is read with the entity and included as a
condition in the write; if a concurrent transaction already committed a change, the write matches zero rows and
fails with an exception in the OptimisticLockingFailureException family (JPA surfaces the more specific
ObjectOptimisticLockingFailureException). Nothing blocks — the conflict is detected at write time instead of
prevented at read time.
Because the conflict surfaces as an exception, the caller owns the recovery: catch it, re-read the current state,
re-apply the change, and save again — or translate it into a 409 Conflict for the client to resolve. The
mechanism and worked examples are not repeated here; see
Spring Data Overview for the cross-store mechanism, and the
"Optimistic locking with `@Version`" section of
Spring Data JPA,
Spring Data MongoDB,
Spring Data Couchbase, and
Spring Data Neo4j for each store’s concrete behavior.
Optimistic vs. pessimistic: choosing
Optimistic (@Version) |
Pessimistic (@Lock) |
|
|---|---|---|
How conflicts surface |
At write time, as |
They do not — the competing transaction waits at read time, or times out. |
Cost under low contention |
Effectively zero: one extra column and one extra |
A real lock acquired on every read, paid for even when nothing would have collided. |
Cost under high contention |
High: repeated wasted work and retry storms as writers keep losing the race. |
Bounded: writers queue and each does its work exactly once. |
Deadlock risk |
None — no locks are held. |
Real. Multiple locks in inconsistent order will deadlock; needs a stable acquisition order. |
Scalability |
Excellent — readers and writers never block each other. |
Limited — throughput on a hot row is capped by one transaction at a time. |
Caller complexity |
The caller must implement retry (or surface a conflict to the user). |
The caller writes straight-line code, but must bound lock time and handle timeouts. |
Long "user think time" edits |
The only workable option: the version travels with the form and is checked on submit. |
Unusable — a database lock cannot be held across requests. |
The default should be optimistic: it is cheaper, it cannot deadlock, and it degrades gracefully. Reach for
pessimistic locking when contention on a specific row is genuinely high, the transaction touching it is short,
and redoing the work on conflict is either expensive or unacceptable — the canonical case being a balance or
inventory decrement that must succeed on the first attempt. The two are not exclusive: an entity can carry
@Version for its ordinary paths and still be read with @Lock(LockModeType.PESSIMISTIC_WRITE) on the one hot
code path that needs it.
The same trade-off seen from the database’s side, including how each strategy maps onto engine-level locking, is covered in SQL Reference: Transaction Control under "Choosing between the two strategies" — not restated here.
Per-store support
The isolation attribute is only meaningful for a transaction manager that has somewhere to apply it. Only the
relational managers do:
| Store | isolation |
Behavior | See also |
|---|---|---|---|
Relational (JPA / JDBC) |
Supported |
|
|
MongoDB |
Ignored |
|
|
Couchbase |
Fixed at read-committed |
|
Spring Data Couchbase, Couchbase Reference: Concurrency, Locking and Durability |
Neo4j |
Rejected |
|
None of the three non-relational managers lets you actually raise the isolation level: Couchbase and Neo4j
reject a stricter value with an exception when the transaction starts, while MongoDB accepts the attribute and
then ignores it — so on MongoDB a mistaken isolation setting fails silently rather than loudly.
Summary
-
@Transactional(isolation = …)takes anIsolationenum constant;DEFAULT(the default) keeps the database’s own level, which differs by engine —READ COMMITTEDon PostgreSQL/Oracle/SQL Server,REPEATABLE READon MySQL/InnoDB. -
Raising the level trades throughput for guarantees: longer-held locks, more MVCC bookkeeping, and anomalies converted into blocking, lock timeouts, deadlocks, or serialization failures the caller must retry.
-
@Lock(LockModeType.PESSIMISTIC_WRITE)on a repository method emitsSELECT … FOR UPDATEand holds the row lock until commit; pair it with a@QueryHintsjakarta.persistence.lock.timeoutand a stable lock ordering. -
Prefer optimistic
@Versionlocking by default; use pessimistic locking for short, server-side, high-contention writes whose work must not be redone. -
Only the relational transaction managers honour
isolation:MongoTransactionManagersilently ignores it,CouchbaseCallbackTransactionManagerallows onlyREAD_COMMITTED, andNeo4jTransactionManagerthrowsInvalidIsolationLevelExceptionon any non-default value.