RizTech Academy logo
RizTech Academy
Concurrency, Transactions and AsyncLesson 1 of 535 min

Transaction propagation and isolation, in depth

The data-access module introduced @Transactional — commit on return, roll back on an unchecked exception. This lesson goes deeper into the two settings that decide how transactions behave under real conditions: propagation (what happens when a transactional method calls another) and isolation (what one transaction sees of another's uncommitted work). These are where transaction behaviour gets subtle, and where production bugs hide. This opens the concurrency module because everything in it rests on understanding transactions properly.

Propagation: transactions calling transactions

Propagation decides what happens when a @Transactional method is called from within another transaction. The default — REQUIRED — is the one to understand first:

  • REQUIRED (default) — join the caller's transaction if one exists, otherwise start a new one. So if serviceA (transactional) calls serviceB (transactional), they run in one transaction: if B fails, the whole thing rolls back, including A's work. This is usually what you want, and why it is the default.

The others, and when they matter:

  • REQUIRES_NEW — always start a new, independent transaction, suspending the caller's. The inner transaction commits or rolls back on its own, regardless of the outer. Use it when an operation must persist even if the caller rolls back — the classic case is an audit log: you want the "attempted X" record saved even when X fails and rolls back. Put the audit write in a REQUIRES_NEW method.
  • NESTED, SUPPORTS, MANDATORY, NEVER — rarer; SUPPORTS (join if present, else non-transactional), MANDATORY (must be called within a transaction, else error), NEVER (must not be).

The trap: assuming a called method rolls back independently when it is actually REQUIRED and joined the outer transaction — so its "committed" work is undone when the outer fails. And the reverse: expecting the outer to roll back the inner's work when the inner is REQUIRES_NEW (it will not — the inner already committed independently). Know that the default is "one transaction for the whole call tree" and reach for REQUIRES_NEW deliberately, for the audit-log-style case.

Isolation: what one transaction sees of another

Isolation decides how visible one transaction's uncommitted changes are to another running concurrently. It is a trade-off between correctness and concurrency, expressed as levels, each preventing more anomalies at more cost:

Level Prevents The anomaly it allows
READ_UNCOMMITTED (little) Dirty reads — seeing another transaction's uncommitted changes
READ_COMMITTED dirty reads Non-repeatable reads — a row you read changes if you read it again
REPEATABLE_READ + non-repeatable reads Phantom reads — new rows appear in a repeated range query
SERIALIZABLE everything (slowest — transactions effectively run one at a time)

The anomalies, concretely: a dirty read sees data that may be rolled back (reading a lie); a non-repeatable read means reading the same row twice in one transaction gives different values (someone committed a change between); a phantom read means a WHERE-range query run twice returns different rows (someone inserted a matching row). Higher isolation prevents more of these but reduces concurrency (more locking, more conflicts).

The default is the database's default — for PostgreSQL and most, READ_COMMITTED, which is the sensible balance for typical web apps: no dirty reads, decent concurrency. You set it only when a specific operation needs more:

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void reconcile() { ... }     // needs consistent repeated reads within the transaction

Raise isolation for the rare operation that genuinely needs stronger guarantees (a financial reconciliation reading the same data multiple times); leave the default for everything else. Do not raise isolation globally to "be safe" — SERIALIZABLE everywhere cripples concurrency and causes serialization-failure retries.

Isolation is not the whole answer to the lost update

A crucial point that sets up the next lesson: READ_COMMITTED does not prevent the lost-update problem. Two transactions each read a value, each compute a new value, each write — the second overwrites the first, and no isolation level below SERIALIZABLE stops it by itself, because each write is individually valid. Raising isolation to SERIALIZABLE would (at the cost of failed transactions to retry), but the better, targeted tools are locking (optimistic @Version or pessimistic) — the next lesson. So: isolation handles read anomalies (dirty/non-repeatable/phantom reads); the lost update on concurrent writes needs locking, not just isolation. Confusing the two — thinking READ_COMMITTED protects you from lost updates — is a real source of silent data corruption under load.

Keep transactions short, and mind the boundaries

Two practical reinforcements from the data-access lesson, sharpened by concurrency:

  • Short transactions. A transaction holds a database connection (and any locks) for its whole duration. A transactional method that calls a slow external API holds all that across the call — under load, that exhausts the connection pool and increases lock contention. Do slow, non-database work outside the transaction.
  • The proxy and self-invocation (from @Transactional's mechanics): propagation only applies across bean boundaries. A REQUIRES_NEW method called from another method of the same class does not get a new transaction — the proxy is bypassed. Call across beans.

The mental model to carry into the module: propagation controls how nested transactional calls combine (default: one transaction), isolation controls what concurrent transactions see of each other (default: READ_COMMITTED, raise rarely), and neither alone stops the lost update — that needs locking.

Check your work

Propagation. How a transactional call combines with an existing transaction. REQUIRED (default): join the caller's, or start one — one transaction for the call tree, all-or-nothing. REQUIRES_NEW: independent transaction (commits regardless of the caller) — for audit-log-style writes that must survive a caller rollback.

Isolation. What concurrent transactions see of each other's uncommitted work: READ_UNCOMMITTED (dirty reads) → READ_COMMITTED (default; no dirty reads) → REPEATABLE_READ (no non-repeatable reads) → SERIALIZABLE (all, slowest). Raise rarely and per-operation; do not globally SERIALIZABLE.

Isolation ≠ lost-update protection. READ_COMMITTED does not stop the lost update on concurrent writes; that needs locking (next lesson), not higher isolation. Confusing them causes silent corruption.

Keep it short + boundaries. Short transactions (hold connection/locks briefly; slow work outside); the proxy means propagation applies only across bean boundaries (self-invocation bypasses it).

Practice

  1. Have transactional serviceA call transactional serviceB (both REQUIRED); make B throw and confirm A's work also rolls back — one transaction.
  2. Change B to REQUIRES_NEW, make the outer fail after B committed, and confirm B's work survives — the audit-log case.
  3. Set an operation to REPEATABLE_READ and reason about which read anomaly it now prevents versus the READ_COMMITTED default.
  4. Reason (or demonstrate with two sessions) why READ_COMMITTED still allows a lost update on concurrent read-modify-write — motivating the locking lesson.
  5. Put a slow call inside a transaction and reason about connection-pool/lock impact under load; move it outside.
  6. Call a REQUIRES_NEW method via self-invocation and observe it does not get a new transaction; fix by moving across beans.

Official documentation

Next: optimistic and pessimistic locking, and the lost-update race.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship