Transaction isolation & concurrency anomalies
Mid / SeniorACID, dirty writes, dirty reads, non-repeatable reads, phantoms, lost updates and write skew — and which isolation level prevents which.
Created 5 October 2026
11 concepts · detail hidden
01ACID
#c-acid- Atomicity — all of a transaction’s changes apply, or none do (rollback undoes partial work).
- Consistency — a transaction moves the database from one valid state to another (constraints, foreign keys, invariants hold). Partly the application’s job.
- Isolation — concurrent transactions don’t see each other’s intermediate states — to the degree set by the isolation level. This is the part with trade-offs.
- Durability — once committed, data survives crashes (write-ahead log flushed to disk).
02Dirty write
#c-dirty-writeA transaction overwrites a value another transaction wrote but has not yet committed.
T1: UPDATE account SET owner = 'Alice' WHERE id = 1; -- not committed T2: UPDATE account SET owner = 'Bob' WHERE id = 1; -- overwrites T1's uncommitted write T1: ROLLBACK; -- roll back to what? T2's change is entangled with T1'sDirty writes make rollback undefined and let two multi-row updates interleave into an inconsistent mix (an invoice belonging to Alice while the car belongs to Bob). Every isolation level prevents them, even
READ UNCOMMITTED— the database takes a row-level write lock held until commit, so T2’sUPDATEwaits for T1 to finish.03Dirty read
#c-dirty-readA transaction reads data another transaction has written but not committed.
T1: UPDATE account SET balance = balance - 100 WHERE id = 1; -- not committed T2: SELECT balance FROM account WHERE id = 1; -- sees the -100 T1: ROLLBACK; -- T2 acted on data that never existedAllowed only at
READ UNCOMMITTED; prevented fromREAD COMMITTEDup. (PostgreSQL doesn’t implementREAD UNCOMMITTEDat all — it silently behaves asREAD COMMITTED.)Quick distinction: dirty read = reading someone’s uncommitted data; dirty write = overwriting someone’s uncommitted data.
04Non-repeatable read (fuzzy read)
#c-non-repeatable-read-fuzzy-readReading the same row twice in one transaction gives different values, because another transaction committed an update in between.
T1: SELECT price FROM product WHERE id = 7; -- 100 T2: UPDATE product SET price = 120 WHERE id = 7; COMMIT; T1: SELECT price FROM product WHERE id = 7; -- 120Allowed at
READ COMMITTED(each statement sees the latest committed data); prevented byREPEATABLE READand above.05Phantom read
#c-phantom-readRe-running the same range/predicate query returns a different set of rows, because another transaction committed an insert or delete matching the predicate.
T1: SELECT count(*) FROM booking WHERE room = 4 AND day = '2026-10-05'; -- 0 T2: INSERT INTO booking (room, day) VALUES (4, '2026-10-05'); COMMIT; T1: SELECT count(*) FROM booking WHERE room = 4 AND day = '2026-10-05'; -- 1Difference from non-repeatable read: that one is an existing row changing; a phantom is rows appearing or disappearing. Locking the rows you read can’t prevent phantoms — the row didn’t exist to be locked — so it needs predicate/range (gap) locks or snapshots. By the SQL standard only
SERIALIZABLEprevents it; in practice PostgreSQL’s and MySQL’sREPEATABLE READalso prevent phantoms for plain reads via snapshots.06Lost update
#c-lost-updateTwo transactions each read a value, modify it in application code, and write it back; one write silently overwrites the other.
T1: SELECT stock FROM item WHERE id = 1; -- 10 T2: SELECT stock FROM item WHERE id = 1; -- 10 T1: UPDATE item SET stock = 9 WHERE id = 1; COMMIT; T2: UPDATE item SET stock = 9 WHERE id = 1; COMMIT; -- two items sold, stock dropped by 1This is the anomaly that bites typical Spring/JPA code (
load entity → change field → commit) at the defaultREAD COMMITTED. Prevention:- Atomic update in SQL —
UPDATE item SET stock = stock - 1 WHERE id = 1 AND stock > 0. - Optimistic locking — JPA
@Version; the second commit fails and can retry. - Pessimistic locking —
SELECT … FOR UPDATE(@Lock(PESSIMISTIC_WRITE)); the second reader waits. - Higher isolation — PostgreSQL
REPEATABLE READdetects it and aborts the second transaction (“could not serialize access due to concurrent update”). MySQL InnoDB’sREPEATABLE READdoes not — plainUPDATEs act on the latest committed row.
- Atomic update in SQL —
07Read skew and write skew
#c-read-skew-and-write-skewRead skew — a transaction sees parts of the database at different points in time: it reads account A, another transaction moves 100 from A to B and commits, then it reads B — the total appears 100 short. Prevented by snapshot-based
REPEATABLE READ.Write skew — two transactions read the same set of rows, each checks an invariant, then each writes different rows, and together they break the invariant.
Rule: at least one doctor must be on call. T1: SELECT count(*) FROM doctor WHERE on_call; -- 2 → ok for me to leave T2: SELECT count(*) FROM doctor WHERE on_call; -- 2 → ok for me to leave T1: UPDATE doctor SET on_call = false WHERE name = 'Alice'; COMMIT; T2: UPDATE doctor SET on_call = false WHERE name = 'Bob'; COMMIT; -- 0 on callNo row was written by both, so row locks and snapshot isolation don’t catch it. Only
SERIALIZABLE(e.g. PostgreSQL’s Serializable Snapshot Isolation) prevents it in general — or explicitly locking the rows the decision is based on (SELECT … FOR UPDATE), or materialising the conflict into a row/constraint both transactions must touch.08Isolation levels and what each one prevents
#c-isolation-levels-and-what-each-one-preventsLevel Dirty write Dirty read Non-repeatable read Phantom Lost update Write skew READ UNCOMMITTEDprevented possible possible possible possible possible READ COMMITTEDprevented prevented possible possible possible possible REPEATABLE READprevented prevented prevented possible (std) * DB-dependent * possible SERIALIZABLEprevented prevented prevented prevented prevented prevented * Implementation-dependent: PostgreSQL’s
REPEATABLE READis snapshot isolation (no phantoms for reads, lost updates detected); MySQL InnoDB’s prevents phantoms via snapshots and next-key locks but doesn’t detect lost updates.Defaults: PostgreSQL, Oracle and SQL Server →
READ COMMITTED; MySQL InnoDB →REPEATABLE READ. In Spring:@Transactional(isolation = Isolation.REPEATABLE_READ);Isolation.DEFAULTuses the database’s.Higher isolation costs throughput: more blocking (lock-based) or more aborted transactions (snapshot/SSI-based) that the application must retry — serialization failures are an expected outcome, not a bug.
09MVCC vs lock-based concurrency control
#c-mvcc-vs-lock-based-concurrency-control- Lock-based (two-phase locking) — readers take shared locks, writers exclusive locks; readers and writers block each other. Classic SQL Server (without snapshot options).
- MVCC (multi-version concurrency control) — writers create a new version of the row; readers see a consistent snapshot of committed versions, so readers never block writers and writers never block readers (writers still block writers on the same row). PostgreSQL, Oracle, MySQL InnoDB.
Under MVCC,
READ COMMITTEDtakes a fresh snapshot per statement,REPEATABLE READtakes one snapshot per transaction (at the first statement). The cost is old row versions to clean up (PostgreSQLVACUUM, InnoDB undo-log purge) — a long-running transaction keeps old versions alive and bloats tables.10Deadlocks
#c-deadlocksT1 locks row A then wants B; T2 locks B then wants A. The database detects the cycle and aborts one transaction with a deadlock error (Spring:
CannotAcquireLockException/DeadlockLoserDataAccessException). Mitigations: acquire locks in a consistent order (e.g. sort by id before updating multiple rows), keep transactions short, add proper indexes (an unindexedUPDATE … WHEREmay lock far more rows than intended), and retry the aborted transaction.11Transactions and the connection pool
#c-transactions-and-the-connection-poolA transaction owns one database connection for its entire duration. With HikariCP’s default pool of 10 connections, ten requests sitting in transactions that wait on a slow HTTP call block every other request (
Connection is not available, request timed out after 30000ms). Hence: keep transactions short, never do remote calls inside them, bewareREQUIRES_NEW(holds two connections per request), and be aware that OSIV holds the connection until the response is written. Pool size should match database capacity, not thread count — a small pool is often faster than a large one.