Isolation Levels
Dirty read, phantom, write skew across RC / SI / SSI. Ch 7.
This interactive explanation is built for system design interview prep: step through Isolation Levels, watch the internal state change, and connect the concept to real distributed-system trade-offs.
Overview
Transaction isolation is the lie databases tell you to keep concurrent programming tractable. Every engine serves many transactions at once, but pretends to each one that it is the only transaction in the world. The degree to which that illusion holds up is the isolation level. The ANSI levels — Read Uncommitted, Read Committed, Repeatable Read, Serializable — were defined by the anomalies they forbid (dirty reads, non-repeatable reads, phantoms), but Kleppmann shows these definitions are historical and full of gaps. Modern systems ship Snapshot Isolation and Serializable Snapshot Isolation, which are not in the original standard. The classic demo is write-skew: two on-call doctors each check that at least one doctor is still on call, then each go off call simultaneously, leaving nobody on call. No single-row conflict fires, no lost update is visible, and yet an application invariant is broken. Only true serializability prevents it.
How it works
Read Committed (RC) guarantees you never see uncommitted data, typically via row-level locks on writes and MVCC snapshots on reads. Each statement sees a fresh snapshot, so two reads within one transaction can return different values — non-repeatable reads are allowed. Repeatable Read (RR) takes a snapshot at transaction start; all reads see that same snapshot regardless of concurrent commits. In MySQL's implementation, RR prevents non-repeatable reads and (surprisingly) most phantoms via gap locks. Snapshot Isolation (SI), as implemented in Postgres and Oracle, uses MVCC: readers see the snapshot at transaction start, writers check for write-write conflicts at commit. SI prevents lost updates via first-committer-wins but permits write-skew because the conflict is across different rows. Serializable Snapshot Isolation (SSI), Postgres's SERIALIZABLE mode, layers conflict detection on top of SI: it tracks read-write dependencies between concurrent transactions and aborts one if they form a cycle that would be non-serializable. The cost is false-positive aborts — your transaction may fail for reasons invisible to it — but application-level invariants hold without pessimistic locking. Two-phase locking (2PL) is the historical serializable implementation: hold read and write locks until commit, which serializes any conflicting pair but tanks throughput under contention.
Implementation
import java.sql.*;
import java.util.concurrent.*;
/** Two doctors are on call. Each checks "at least one other doctor is on call"
* and then goes off. Under RC and SI both succeed and nobody is left on call. */
public class TransactionRunner {
private final String url;
public TransactionRunner(String url) { this.url = url; }
/** Attempt to go off call. Returns true if the transaction committed. */
public boolean tryGoOffCall(int doctorId, int isolationLevel) throws SQLException {
try (Connection c = DriverManager.getConnection(url)) {
c.setAutoCommit(false);
c.setTransactionIsolation(isolationLevel);
try (PreparedStatement q = c.prepareStatement(
"SELECT COUNT(*) FROM doctors WHERE on_call = true")) {
ResultSet rs = q.executeQuery();
rs.next();
int onCall = rs.getInt(1);
if (onCall < 2) {
c.rollback();
return false;
}
}
try (PreparedStatement u = c.prepareStatement(
"UPDATE doctors SET on_call = false WHERE id = ?")) {
u.setInt(1, doctorId);
u.executeUpdate();
}
try {
c.commit();
return true;
} catch (SQLException e) {
// SSI can raise a serialization failure here. RC and SI do not.
c.rollback();
return false;
}
}
}
/** Run both doctors concurrently and show the result under a given level. */
public void demo(int level, String levelName) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<Boolean> a = pool.submit(() -> tryGoOffCall(1, level));
Future<Boolean> b = pool.submit(() -> tryGoOffCall(2, level));
boolean aOk = a.get(), bOk = b.get();
pool.shutdown();
System.out.printf("%s: doctor1=%s doctor2=%s -> invariant broken? %s%n",
levelName, aOk, bOk, (aOk && bOk));
}
public static void main(String[] args) throws Exception {
TransactionRunner r = new TransactionRunner(args[0]);
r.demo(Connection.TRANSACTION_READ_COMMITTED, "READ_COMMITTED");
r.demo(Connection.TRANSACTION_REPEATABLE_READ, "SNAPSHOT_ISOLATION");
r.demo(Connection.TRANSACTION_SERIALIZABLE, "SSI (Postgres)");
}
}
Complexity
- Read Committed read:
O(1) MVCC snapshot per statement - Snapshot Isolation read:
O(1) snapshot at txn start, O(writes) conflict check at commit - SSI conflict tracking:
O(R*W) predicate locks across concurrent txns - 2PL worst-case throughput under contention:
O(1/lock_wait_time) - Serializable deadlock detection:
O(T) over active transactions
Key design decisions & trade-offs
- Pessimistic vs optimistic concurrency — Chosen: Optimistic (SI, SSI) in most OLTP engines. Pessimistic 2PL holds locks for the duration of the transaction, which destroys throughput under contention and produces deadlocks. Optimistic defers conflict to commit, letting readers never block writers and vice versa, at the cost of false-positive aborts.
- Preventing write-skew — Chosen: Use SERIALIZABLE (SSI) or explicit SELECT FOR UPDATE on the predicate rows. Application-level retry loops and version columns catch some skews but not predicate skews. SSI catches them generically; SELECT FOR UPDATE works only when the predicate rows exist (doctor rows yes, but not "no-user-with-this-email" skews).
- Retry policy — Chosen: Bounded retries on serialization failure with exponential backoff. SSI aborts are normal; the application must retry. Unbounded retries can livelock; retry 5-10 times then surface to the user. Read-only transactions are cheap to retry; long-running writes deserve care.
- Isolation level per workload — Chosen: Default RC for high-volume endpoints, SERIALIZABLE only where invariants require it. Most web traffic does not have cross-row invariants. Paying for SSI everywhere costs throughput without benefit. Scope serializability to the critical-section endpoints (booking, billing, quota).
Common pitfalls
- Assuming REPEATABLE READ in MySQL means the same thing as REPEATABLE READ in Postgres; MySQL's is effectively SI, Postgres's is also SI despite the name
- Assuming SERIALIZABLE in Postgres blocks until the right answer; it aborts instead, and code that does not retry silently loses writes
- Using SELECT-then-UPDATE without SELECT FOR UPDATE and being surprised by lost updates in RC
- Writing a test that passes locally with single-threaded execution and concluding the code is race-free
- Confusing dirty-read prevention with snapshot isolation; RC prevents dirty reads but still lets you see a value that was true when you read and false when you write
Interview follow-ups
- Reproduce write-skew on Postgres under REPEATABLE READ, then show SSI preventing it
- Benchmark SSI abort rate under increasing contention and plot the retry curve
- Design an application-level optimistic concurrency pattern using version columns
- Combine isolation levels with quorum reads in a distributed SQL database
Recommended reading
- Alex Petrov, Database Internals — storage engines and distributed systems internals.
- Martin Kleppmann, Designing Data-Intensive Applications (DDIA) — data models, replication, partitioning, consistency.
- The System Design Primer — high-level design building blocks.
- Foundational networking + web-security references (TCP/IP, TLS 1.3, OWASP Top 10).