Transactions in Spring
@Transactional's proxy, propagation types, isolation, read-only, rollback rules, and the checked exception that does not roll back.
@Transactional is one word that does a great deal, and the reason it surprises people is that the word is not where the work happens. Spring does not rewrite your method. It wraps your bean in a proxy, and the proxy is what starts and ends the transaction.
Every trap in this lesson is a consequence of that one sentence.
The proxy, and the call that never reaches it
When a bean is annotated, the object Spring hands to everyone else is not your object. It is a generated subclass (or a JDK interface proxy) that looks identical, and whose job is: begin a transaction, call the real method, commit or roll back.
another bean. A controller or another service holds a reference to what it thinks is your service. It is holding the proxy — Spring injected that, not your instance.
proxy intercepts. The proxy reads the annotation, asks the transaction manager for a connection, and begins. Your code has not run yet.
your method. Now your body runs, with a persistence context bound to this transaction. Dirty checking works because that context exists and is open.
it returns. Control passes back through the proxy, which flushes and commits — or rolls back, if what came out was an exception it rolls back for.
this.debit(id). A call from inside the same class. `this` is your real object, not the proxy, so nothing intercepts it. The annotation is still there and does nothing at all.
Self-invocation, measured
That last frame is the trap, and it is worth seeing rather than being warned about. Two calls to the same annotated method, differing only in who calls it:
@Service
public class Money {
private final Accounts accounts;
/** No annotation here. It calls the annotated method on `this`. */
public void selfInvoked(Long id) {
this.debit(id);
}
@Transactional
public void debit(Long id) {
Account a = accounts.findById(id).get();
a.balance -= 10; // dirty checking is supposed to write this
}
}### A: calling debit() from INSIDE the bean (this.debit)
### inside debit(): transaction active? false
### balance for ana: 100 (was 100)
### B: calling debit() from OUTSIDE the bean
### inside debit(): transaction active? true
### balance for bo: 90 (was 100)Same method. Same annotation. In the first case there is no transaction, so there is no persistence context to hold a managed entity, so dirty checking never happens and the debit is silently discarded. No exception. No warning. A log line would show the select and no update, and only if anyone were looking.
Three ways out, in the order you should prefer them:
- Move the method to another bean. Usually the right answer, because a method that needs its own transaction is usually a different responsibility.
- Inject the proxy into itself —
@Lazyon a self-reference, thenself.debit(id). It works and it looks strange, which is honest: it looks strange because something is. AopContext.currentProxy(). Works, requiresexposeProxy = true, and ties your code to Spring's AOP. Last resort.
Rollback rules are not what you expect
Ask most engineers what happens when a @Transactional method throws, and they will say it rolls back. That is true for unchecked exceptions and false for checked ones:
### C: debit, then throw a CHECKED exception
### caught Checked
### balance for cy: 90 (was 100)
### D: debit, then throw an UNCHECKED exception
### caught Unchecked
### balance for di: 100 (was 100)Read C again. The method debited the account, threw an exception, the caller caught it — and the money moved anyway. The transaction committed on the way out because a checked exception is, by Spring's default, a business outcome rather than a failure.
That default comes from EJB and is defensible in theory: a checked exception is a declared, expected result. In practice, teams write checked exceptions for exactly the cases where they mean stop, undo this. So say it:
@Transactional(rollbackFor = Checked.class)### E: checked again, but with rollbackFor = Checked.class
### caught Checked
### balance for ed: 100 (was 100)Propagation: what happens when a transaction is already running
Propagation answers one question: this method wants a transaction, and the caller already has one — now what?
| Propagation | With a transaction already running |
|---|---|
REQUIRED (default) | join it. One transaction, one commit, all-or-nothing together |
REQUIRES_NEW | suspend the caller's, start a second, independent one |
NESTED | a savepoint inside the caller's; can roll back alone |
SUPPORTS | join if there is one, otherwise run with none |
MANDATORY | throw if there is not one |
NEVER | throw if there is one |
The two that matter are the first two, and the distinction is worth stating as a rule: REQUIRED means your work dies with the caller's; REQUIRES_NEW means it survives.
That is what makes REQUIRES_NEW the right choice for an audit record or a failed-attempt log — the thing you most want written is the record of the operation that just failed, and under REQUIRED it rolls back along with everything else.
It also carries a real cost that is easy to miss: REQUIRES_NEW takes a second connection from the pool while the first is still held open and suspended. A request that nests three of those holds four connections. On a pool of ten, that is a deadlock waiting for traffic.
Isolation, briefly and honestly
Isolation decides what one transaction can see of another's uncommitted or in-flight work:
| Level | Prevents |
|---|---|
READ_UNCOMMITTED | nothing; you can read another transaction's uncommitted rows |
READ_COMMITTED | dirty reads |
REPEATABLE_READ | dirty reads and non-repeatable reads |
SERIALIZABLE | all of it, including phantoms |
The honest advice is: leave it alone. PostgreSQL defaults to READ_COMMITTED, MySQL's InnoDB to REPEATABLE_READ, and both are right for almost all application work. Raising it trades throughput for a guarantee you usually do not need, and raising it for one method is an invitation to a lock ordering problem that appears under load and nowhere else.
Reach for the locking below instead. It is more precise and it fails in a way you can catch.
Optimistic locking is a column, not a lock
Add one field:
@Entity
public class Account {
@Id public Long id;
public int balance;
@Version public Long version;
}Hibernate now includes the version in every update's where clause and increments it:
### F: @Version on an ordinary update
### version on load: 0, balance 100
### version afterwards: 1Nothing is locked. Nobody waits. The check happens at write time: if the row's version is no longer the one you read, your update matches zero rows and Hibernate knows somebody changed it underneath you.
### G: two writers, one stale read
### user A read version 0
### user B has written and bumped the version
### user A now commits...
### org.springframework.orm.ObjectOptimisticLockingFailureExceptionIn a real system B's transaction has committed and B's change stands; A's is rejected and A must retry with fresh data. That is the whole trade: cheap when conflicts are rare, and it turns a silent lost update into a loud exception.
Without @Version, the same sequence produces no error at all. A read the change, B read the same row, both wrote, and whoever wrote last silently erased the other. Nobody finds out until the numbers stop adding up.
Pessimistic locking is the other answer: SELECT ... FOR UPDATE, via @Lock(LockModeType.PESSIMISTIC_WRITE). It holds a database lock for the length of the transaction, so the second reader waits. Correct, and expensive, and a source of deadlocks if two transactions take locks in different orders. Use it when conflicts are the normal case — seat booking, stock decrement on a hot item — and optimistic everywhere else.