Transactions, when everyone writes at once
Isolation levels are usually taught as a dial from fast-and-loose to slow-and-safe, with a table of anomalies to memorise. They are not a dial, and the table is a consequence rather than a definition. There is one idea underneath: a schedule is correct when it produces the same result as some order in which the transactions ran one at a time — and every anomaly is one specific way a schedule fails to have such an order.
Two people buy the last ticket
One row, one counter, and the most ordinary code anybody writes:
SELECT seats FROM events WHERE id = 1; -- 1
-- application decides there is a seat, and takes it
UPDATE events SET seats = 0 WHERE id = 1; -- soldTwo customers run it at once. Both SELECT before either UPDATE. Both see one seat. Both sell it. Every statement succeeded, every transaction committed, no error was raised and nothing was written to a log — and you have sold a seat twice.
The size of the problem is set by how many clients have that window open at the same time. With a hundred clients incrementing, and ten of them overlapping at any moment, the counter finishes at 10 instead of a hundred: 90 increments silently gone. Not slow, not erroring — gone, with the only evidence being a number that is too small.
Can I not just be careful — check before writing, or keep the window short? No, and the reason is worth being exact about, because it is what makes this a database problem rather than an application one. Checking before writing is another read, with its own window. Shortening the window reduces the probability and changes nothing about correctness, which means the bug survives every test you write and appears at the traffic level where it costs the most. The application cannot fix this because the application does not control the interleaving. Only the thing that sees all the operations can.
The one idea: correct means “as if one at a time”
Run the transactions one after another, with no overlap at all, and there is no problem — the same observation the replication side of this subject starts from. Each transaction sees a database nobody else is touching, so any invariant it checks stays true while it acts on it.
That gives the standard of correctness directly. A concurrent schedule is correct if it produces the same result as some serial order of the same transactions. That property is called serializability, and note the word some: the database is not obliged to produce any particular order, only to produce an outcome that could have come from one.
Which raises the question the rest of the post answers: given a schedule, how would you know? You cannot try every order — there are n! of them. You need something local.
Anomalies are cycles
Here is the move that turns the whole subject from a list into a derivation.
Two operations conflict if they touch the same item and at least one of them is a write. Two reads do not conflict — swapping them changes nothing — which leaves exactly three kinds: write-then-write, write-then-read, and read-then-write.
Each conflict forces an order. If T1 read x and T2 then wrote it, any equivalent serial order has to put T1 first, because in the schedule T1 saw the world before T2 changed it. Draw an arrow for each conflict, from the transaction that must come first to the one that must come second.
Now the payoff: a schedule is serializable exactly when those arrows contain no cycle. Acyclic means a topological order exists, and that order is the serial execution the schedule is equivalent to. A cycle means every candidate order contradicts itself — T1 must precede T2 and T2 must precede T1 — so no serial execution could have produced this outcome.
Every anomaly in every textbook table is a named shape of cycle, and that is why the table can be derived instead of memorised. The read-then-write edge — the anti-dependency — deserves special attention, because it is the one a system that only tracks writes cannot see. Keep an eye on it; it explains snapshot isolation’s single famous weakness.
If cycle detection settles it, why does any database use anything else? Because tracking every read of every transaction to build that graph is expensive, and the graph must be maintained across every concurrent transaction in the system. The classical technique — serialization graph testing — is exactly what the engine further down does, and it is largely of theoretical interest for that reason. Real systems use cheaper approximations: locking prevents the conflicting operations from ever being concurrent, snapshots make some conflicts impossible by construction, and SSI watches for a specific two-edge pattern that every cycle must contain. All three are described below, and each is a different answer to “how do I avoid computing this graph?”.
Isolation levels: which cycles you decline to look for
An isolation level is a promise about which schedules the database will refuse to produce. Stronger levels refuse more, and the anomaly names are just the shapes each level still allows through.
- Read uncommitted — you may see values from transactions that have not committed and may never commit. A dirty read: you act on a number that gets rolled back and never existed.
- Read committed — every read sees only committed data, but each read gets the latest committed data. Two reads in one transaction can disagree, which is a non-repeatable read; and reading two rows can catch a transfer halfway, which is read skew.
- Repeatable read — a row read twice reads the same. Classically this meant holding read locks; in practice most systems deliver it with a snapshot.
- Snapshot isolation — the whole transaction sees the database as of the instant it began, and two transactions writing the same row cannot both commit (first committer wins). This kills dirty reads, non-repeatable reads, read skew, lost updates and phantoms.
- Serializable — no cycles at all, by whatever means. The only level that also stops write skew.
Rather than assert that, here is the engine below run over four schedules at three levels. Each cell is what actually committed:
| Schedule | read committed | snapshot isolation | serializable |
|---|---|---|---|
| Lost update | T1, T2 — anomaly | T1 | T1 |
| Non-repeatable read | T2, T1 — anomaly | T2, T1 | T2, T1 |
| Write skew | T1, T2 — anomaly | T1, T2 — anomaly | T1 |
| Read skew | T2, T1 — anomaly | T2, T1 | T2, T1 |
Read the write-skew row across. Snapshot isolation commits both transactions; the dependency graph it produced contains a cycle all the same. The schedule was never serializable, and the level simply was not looking.
Read-your-writes is not repeatable read
This pair of terms is the single most common confusion in the area, and it is worth separating carefully because the two live on different axes, break for different reasons, and are fixed by different machinery.
Repeatable read is about one transaction. It promises that within your transaction, reading a row twice gives the same answer no matter who commits in between. It is broken by a concurrent writer, and it is delivered by a snapshot (or by holding read locks until commit). It is a concurrency-control property, and a database on a single machine with a single copy of the data can give it to you perfectly.
Read-your-writes is about one client, across separate requests. You post a comment, the page reloads, and the comment is there. It is broken by a stale replica — your write went to the leader and your read was served by a follower that has not caught up — and it is fixed by routing you back to something that has your write, or by carrying a version token so the replica knows to wait. It is a replication property, and it only exists as a problem once there is more than one copy.
So could I have repeatable read and still not read my own writes? Easily, and it is a common production shape. Your write commits on the leader. Your next request opens a perfectly isolated, perfectly repeatable-read transaction — on a read replica that is two seconds behind. That transaction is internally consistent and completely wrong about the world. Isolation is a promise about other transactions; it says nothing about which copy you are talking to. The replication half of this is the subject of the companion post on consistency.
How databases actually do it
Three families, and they are exhaustive in the sense that there are only three things you can do about a conflict: prevent it, make it impossible, or detect it after the fact.
Two-phase locking — prevent it
Take a shared lock to read, an exclusive lock to write, and — the part that makes the name — never acquire a lock after releasing one. A transaction has a growing phase and then a shrinking phase.
That one rule is what buys serializability, and it is worth seeing why. Between the two phases there is an instant at which a transaction holds every lock it will ever hold. No two conflicting transactions can be at that instant simultaneously, so those instants place all the transactions in a total order — and that order is a serial schedule equivalent to what ran. Strict 2PL holds every lock to commit, which additionally stops anyone reading uncommitted data.
Where it breaks: deadlock is not an edge case but a structural certainty — two transactions taking the same two rows in opposite orders will eventually wait on each other forever. Databases detect it by looking for a cycle in the wait-for graph (a cycle again) and shoot one transaction. Beyond that, readers block writers and writers block readers, so a single long analytical query can stall writes across the table, and lock convoys turn a brief spike into a long one. Phantoms need predicate or next-key locks, which lock rows that do not exist yet and cost more than they look like they should.
MVCC and snapshot isolation — make it impossible
Never overwrite a row; write a new version of it stamped with the transaction that made it. A transaction reads the versions that were committed when it began. Readers therefore never block writers and writers never block readers, which is the single biggest throughput win in the history of database engines.
Under a snapshot, two of the three conflict kinds simply cannot bite. A write-then-read conflict is impossible because you never see uncommitted versions. A repeated read cannot change because your snapshot does not move. Write-then-write is caught explicitly, by first committer wins: if someone committed a version of a row you are writing after your snapshot was taken, you abort.
Where it breaks: the read-then-write edge. Nothing in that scheme notices that you read something someone else then wrote, because your snapshot hid their write and you never wrote the row they touched. Two such edges in opposite directions is write skew, and SI permits it by construction — this is not an implementation gap, it is what SI is. Separately, versions must be garbage collected, and a single long-running reader pins every version newer than its snapshot, so an idle transaction left open in a console can bloat a table until the disk fills.
Optimistic and SSI — detect it
Run without taking anything, record what you read and wrote, and at commit time check whether what you read is still true. If it is not, abort and start over. Kung and Robinson’s optimistic concurrency control (1981), and the model behind every WHERE version = 7 you have ever written.
Serializable snapshot isolation is the refinement that made this practical, and it rests on a genuinely beautiful result. Fekete et al. proved that every cycle possible under snapshot isolation contains two adjacent read-then-write edges. So you do not need the graph: track rw dependencies only, watch for a transaction that has one incoming and one outgoing rw edge — the “dangerous structure” — and abort. Cahill et al. (2008) turned that into an algorithm, and it has been PostgreSQL’s SERIALIZABLE since 9.1.
Where it breaks: the pattern is necessary but not sufficient, so SSI aborts some transactions that would have been fine — false positives are the price of not building the graph. And the failure mode under contention is not graceful. As conflicts rise, aborts rise, retries add load, which raises conflicts: throughput does not degrade, it collapses. Every caller needs a retry loop, and the retry must re-read — replaying the writes alone reintroduces exactly the lost update you started with.
Deterministic execution — refuse to have the problem
Decide the order before executing anything, then have every node execute in that order. Since the order is agreed up front, there is nothing to detect, nothing to abort, and no two-phase commit. Calvin (Thomson et al., 2012) is the reference design. Where it breaks: you must declare the read and write set before running, which is awkward when the rows you touch depend on what you read — handled with a reconnaissance pass that can itself be invalidated.
The engine, in fulla multi-version store and a cycle detector — the code that produced every table on this page
The store keeps committed versions with the step at which they were committed, hands a reader either the latest version or the latest as of its own start, buffers writes until commit, and applies first-committer-wins. At serializable it does the expensive thing the cross-question above says real systems avoid: it builds the dependency graph and refuses any commit that would close a cycle.
// A multi-version store that runs a schedule under a chosen isolation level.
//
// Pure and deterministic (code_guidelines.md §2): "time" is the index of the
// step being executed, not a clock, so the same schedule always produces the
// same events and the post can print them.
import { cycle, type Edge } from './graph.ts'
export type Level = 'read committed' | 'snapshot isolation' | 'serializable'
export type Step =
| { readonly t: number; readonly op: 'read'; readonly key: string }
| { readonly t: number; readonly op: 'write'; readonly key: string; readonly value: number }
| { readonly t: number; readonly op: 'commit' }
export type Event = {
readonly step: Step
readonly saw?: number
readonly outcome: 'ok' | 'aborted'
readonly why?: string
}
export type Result = {
readonly events: readonly Event[]
readonly edges: readonly Edge[]
readonly cycle: readonly number[] | null
readonly committed: readonly number[]
readonly final: Readonly<Record<string, number>>
}
type Version = { key: string; value: number; by: number; at: number }
type Txn = { id: number; startedAt: number; reads: Map<string, number>; writes: Map<string, number>; live: boolean }
const INITIAL = 0
export function run(schedule: readonly Step[], level: Level): Result {
const versions: Version[] = [] // committed only, in commit order
const txns = new Map<number, Txn>()
const events: Event[] = []
const edges: Edge[] = []
const committed: number[] = []
const open = (id: number, at: number): Txn => {
const existing = txns.get(id)
if (existing !== undefined) return existing
const fresh: Txn = { id, startedAt: at, reads: new Map(), writes: new Map(), live: true }
txns.set(id, fresh)
return fresh
}
// What a reader sees. The only difference between read committed and the
// snapshot levels is which committed versions are visible: the latest, or
// the latest as of when this transaction began.
const visible = (txn: Txn, key: string): Version | undefined => {
const candidates = versions.filter(
(v) => v.key === key && (level === 'read committed' || v.at <= txn.startedAt),
)
return candidates[candidates.length - 1]
}
schedule.forEach((step, at) => {
const txn = open(step.t, at)
if (!txn.live) {
events.push({ step, outcome: 'aborted', why: 'transaction already aborted' })
return
}
if (step.op === 'read') {
const own = txn.writes.get(step.key)
const seen = own ?? visible(txn, step.key)?.value ?? INITIAL
txn.reads.set(step.key, seen)
// wr: this read took a value some other transaction committed.
const from = visible(txn, step.key)
if (own === undefined && from !== undefined && from.by !== txn.id) {
edges.push({ from: from.by, to: txn.id, kind: 'wr', key: step.key })
}
events.push({ step, saw: seen, outcome: 'ok' })
return
}
if (step.op === 'write') {
txn.writes.set(step.key, step.value)
// rw: everyone who already read this key must be ordered before us.
for (const other of txns.values()) {
if (other.id !== txn.id && other.reads.has(step.key)) {
edges.push({ from: other.id, to: txn.id, kind: 'rw', key: step.key })
}
}
events.push({ step, outcome: 'ok' })
return
}
// Commit. First-committer-wins: under either snapshot level, a write
// conflicts if someone committed the same key after we began.
if (level !== 'read committed') {
const clash = [...txn.writes.keys()].find((key) =>
versions.some((v) => v.key === key && v.at > txn.startedAt && v.by !== txn.id),
)
if (clash !== undefined) {
txn.live = false
events.push({ step, outcome: 'aborted', why: `another transaction committed ${clash} first` })
return
}
}
// ww edges against whoever last committed each key we are about to write.
for (const key of txn.writes.keys()) {
const last = versions.filter((v) => v.key === key).at(-1)
if (last !== undefined && last.by !== txn.id) {
edges.push({ from: last.by, to: txn.id, kind: 'ww', key })
}
}
// Serializability, tested directly: would committing close a cycle?
//
// Only among transactions that have actually committed, plus this one. An
// edge pointing at a transaction still running is not yet part of any
// serialization order, and counting it would abort the *first* of a
// conflicting pair as well as the second — leaving neither able to make
// progress.
if (level === 'serializable') {
const settled = new Set([...committed, txn.id])
const found = cycle(edges.filter((e) => settled.has(e.from) && settled.has(e.to)))
if (found !== null && found.includes(txn.id)) {
txn.live = false
events.push({ step, outcome: 'aborted', why: `committing would close the cycle ${found.join(' → ')}` })
return
}
}
for (const [key, value] of txn.writes) versions.push({ key, value, by: txn.id, at })
txn.live = false
committed.push(txn.id)
events.push({ step, outcome: 'ok' })
})
const final: Record<string, number> = {}
for (const v of versions) final[v.key] = v.value
return { events, edges, cycle: cycle(edges), committed, final }
}
And the cycle test, which is an ordinary depth-first search:
// Conflict dependencies between transactions, and the cycle that makes a
// schedule wrong. Pure (code_guidelines.md §2).
//
// Two operations conflict when they touch the same item and at least one is a
// write. That gives three edge kinds, and the whole theory of isolation is the
// observation that a schedule is serializable exactly when these edges form no
// cycle.
/** `ww` write-then-write · `wr` write-then-read · `rw` read-then-write
* (the "anti-dependency": T2 wrote what T1 had already read, so T1 must be
* ordered first, and rw edges are the ones snapshot isolation cannot see). */
export type Kind = 'ww' | 'wr' | 'rw'
export type Edge = { readonly from: number; readonly to: number; readonly kind: Kind; readonly key: string }
/** The first cycle found, as the sequence of transaction ids, or null.
* Depth-first with a recursion stack — the textbook cycle test, which is all
* serialization-graph testing is. */
export function cycle(edges: readonly Edge[]): readonly number[] | null {
const out = new Map<number, number[]>()
for (const e of edges) out.set(e.from, [...(out.get(e.from) ?? []), e.to])
const state = new Map<number, 'open' | 'done'>()
const path: number[] = []
const walk = (node: number): readonly number[] | null => {
state.set(node, 'open')
path.push(node)
for (const next of out.get(node) ?? []) {
if (state.get(next) === 'open') return [...path.slice(path.indexOf(next)), next]
if (state.get(next) === undefined) {
const found = walk(next)
if (found !== null) return found
}
}
path.pop()
state.set(node, 'done')
return null
}
for (const node of out.keys()) {
if (state.get(node) === undefined) {
const found = walk(node)
if (found !== null) return found
}
}
return null
}
What this leaves out: predicates, and therefore real phantoms — keys here are single items, so a range query has nothing to lock; durability, recovery and the write-ahead log, which is a whole subject of its own; deadlock, because nothing ever waits — this engine aborts where a locking system would block; lock modes, escalation and convoys; garbage collection of old versions; and distribution. It also uses full cycle detection rather than SSI’s two-edge approximation, which makes it more accurate than PostgreSQL and far slower — the opposite trade from the one a real system wants.
What to actually do when two people update one row
The practical question, and it has four good answers and one bad one. The bad one is the code at the top of this post.
| Approach | When it is right | What it costs |
|---|---|---|
One statementUPDATE c SET n = n + 1 | Whenever the new value is a function of the old one that SQL can express. Always try this first. | Nothing. The database does the read and write under its own row lock. |
Lock firstSELECT … FOR UPDATE | The decision needs application logic, and contention is low enough that queueing is acceptable. | The row is held for a round trip, so writers serialise. Deadlock if lock order is inconsistent. |
Compare and setWHERE version = 7 | Contention is low and you would rather retry than wait. The only option on stores with no transactions. | You write the retry loop. Zero rows updated means someone beat you. |
| Serializable + retry | The invariant spans several rows — the on-call roster, the ledger, the booking. | Aborts under contention, and every caller needs a correct retry. |
| Read, compute, write | Never, across two round trips, without one of the above. | Silent data loss proportional to concurrency. |
Why does an atomic UPDATE not need a transaction? It is already one. Every statement in a SQL database runs in a transaction whether you asked for one or not — BEGIN only lets you put several statements inside the same one. An UPDATE … SET n = n + 1 therefore does its read and its write with nothing able to intervene. The lost update in this post exists solely because the read and the write were in different transactions with your application’s thinking in between.
Two rules that are worth more than the table. Retry the whole transaction, not the write — re-read everything, recompute, and try again, because replaying the write with the stale value you already had is precisely the bug. And do nothing irreversible inside a transaction that may retry: a charged card or a sent email does not roll back, and a transaction that aborts twice will do it three times.
When the rows are on different machines
Everything above assumed one database could see every operation. Split the data across shards and the atomic-commit problem appears: shard A is ready to commit and shard B is not, and neither may decide alone.
Two-phase commit is the answer and is simpler than its reputation. A coordinator asks every participant to prepare; a participant that answers yes has promised it can commit no matter what happens next, and must hold its locks until told. When all have said yes, the coordinator writes its decision and tells them to commit.
Where it breaks: the coordinator dies after the prepare votes and before the decision. The participants cannot commit — the decision might have been abort — and cannot abort — it might have been commit. They hold their locks and wait, indefinitely, and no timeout is safe. This is why 2PC is called a blocking protocol, and the fix is not a cleverer protocol but a more reliable coordinator: replicate the coordinator’s decision log with consensus so it always comes back. That is exactly what Spanner does — two-phase commit layered over Paxos groups — and why 2PC deserves its reputation only when the coordinator is a single machine.
Who does what
The one-pager below carries the table. Three things about it matter more than the rows.
The names lie. PostgreSQL’s REPEATABLE READ is snapshot isolation, which is stronger than the standard’s repeatable read. Oracle’s SERIALIZABLE is snapshot isolation, which is weaker than the standard’s serializable — it will let write skew through. Setting your isolation level by the name in the SQL standard is not sufficient anywhere.
The defaults are weak. Read committed is the default in PostgreSQL, Oracle and SQL Server, and it permits lost updates. Most applications run there and most never find out, because the window is small and the losses are silent.
The distributed ones default to serializable and mean it. CockroachDB, YugabyteDB and FoundationDB will hand you a serialization failure under contention as a matter of routine, and code written against a read-committed database will not have the retry loop it needs.
Where this is, as of September 2026
Dated, because it moves.
- Serializable by default has gone mainstream in new systems and is spreading in old ones. The distributed SQL generation shipped it as the default; CockroachDB added read committed in 23.2 for applications migrating from PostgreSQL that could not tolerate retries, which is a nice illustration of the pressure running both ways.
- Deterministic and leaderless designs are the active research front. Calvin’s lineage removed 2PC by agreeing the order first; Cassandra’s Accord (CEP-15) is pursuing general transactions with no designated leader and one round trip in the common case. The prize in both cases is the coordination round trip, which is the dominant cost in every mechanism on this page.
- Checking has caught up. Elle (Kingsbury and Alvaro, 2020) infers which anomalies a real system exhibits from observed histories, without needing to search for a serial order. It found violations in shipping databases that their documentation denied, and it made “does this system actually provide what it claims” an answerable question rather than an argument.
- What has not changed: the anomaly-is-a-cycle result from 1976, and the fact that write skew separates snapshot isolation from serializability. Everything above is an engineering response to those two facts, and neither is going to move. ◆
◆ Version-specific claims about shipping products were accurate to the best of my checking at the time of writing and are the first thing to verify against your own deployment. The papers are stable; the products are not.
The whole thing, on one page
For revision, and for the afternoon you need the table rather than the argument. It prints.
Transactions on one page
Everything below follows from the first panel.
The invariant
A schedule is correct if it produces the same result as some order in which the transactions ran one at a time. Two operations conflict when they touch the same item and at least one writes; each conflict forces an order; draw one arrow per conflict and a schedule is serializable exactly when the arrows have no cycle. Every anomaly is a named cycle. Every isolation level is a decision about which cycles you decline to look for.
The three conflicts
| ww | write then write. Caught by first-committer-wins. |
| wr | write then read. Caught by reading only committed data. |
| rw | read then write — the anti-dependency. Invisible to a snapshot, and the edge every hard anomaly is made of. |
Fekete et al.: every cycle under SI contains two adjacent rw edges. That is the whole basis of SSI.
Anomalies, as cycles
| anomaly | stopped at |
|---|---|
| dirty write | read uncommitted |
| dirty read | read committed |
| non-repeatable read | repeatable read / snapshot |
| read skew | snapshot |
| lost update | snapshot (first-committer-wins) |
| phantom | snapshot / serializable |
| write skew | serializable only |
Not the same promise
| read-your-writes | repeatable read | |
|---|---|---|
| axis | replication — many copies | concurrency — many writers |
| scope | one client, across requests | one transaction |
| broken by | a stale replica | a concurrent committer |
| fixed by | sticky routing, or a version token the client carries | a snapshot, or read locks held to commit |
You can have either without the other. A single-node database gives repeatable read and cannot give read-your-writes across replicas it does not have.
Mechanisms
| family | on conflict | breaks at |
|---|---|---|
| 2PL / SS2PL | block | deadlock; readers block writers; lock convoys |
| MVCC + SI | version, first committer wins | write skew; version bloat from long readers |
| OCC | abort at validation | high contention — wasted work grows with collisions |
| SSI | abort on dangerous structure | false positives; throughput collapses rather than degrades |
| deterministic | order first, never abort | needs the read/write set up front |
Handling a concurrent update
- One statement. UPDATE c SET n = n + 1. Always first choice.
- Lock it. SELECT … FOR UPDATE, then write. Correct; writers queue.
- Compare and set. WHERE version = 7; zero rows means retry.
- Serializable + retry. Catch 40001 and run it again.
- Never read, compute and write across two round trips with none of the above.
Deadlock
- Two transactions each hold what the other wants. Unavoidable under locking.
- Detected with a wait-for graph — a cycle again — or by timeout.
- Prevent by taking locks in a consistent order, everywhere.
- A deadlock is not a bug in the database; it is the database telling you it broke the tie.
Across shards
- 2PC: prepare, then commit. Two round trips, locks held throughout.
- Blocks forever if the coordinator dies after prepare — the participants may not unilaterally decide.
- Fixed by replicating the coordinator log with consensus (Spanner: 2PC over Paxos groups).
- Avoided entirely by deterministic ordering (Calvin) or by not spanning shards.
Defaults (check your version)
| system | default | note |
|---|---|---|
| PostgreSQL | read committed | REPEATABLE READ is SI; SERIALIZABLE is SSI |
| MySQL / InnoDB | repeatable read | locking reads; next-key locks stop phantoms |
| Oracle | read committed | SERIALIZABLE is SI — write skew is possible |
| SQL Server | read committed (locking) | RCSI and snapshot optional; true serializable available |
| CockroachDB, YugabyteDB, FoundationDB | serializable | retry loops are mandatory, not optional |
| MongoDB | snapshot in transactions | multi-document transactions since 4.0 |
| DynamoDB | no isolation between separate calls | conditional writes; TransactWriteItems for atomic groups |
Retry, correctly
- Retry the whole transaction, re-reading everything. Replaying the writes alone reintroduces the lost update.
- Bound the attempts, and back off — retries under contention add the load that caused them.
- Nothing outside the database may be done inside the transaction. A retried email is sent twice.
- Postgres 40001 serialization_failure, 40P01 deadlock_detected. Both mean "run it again".
Check yourself
Quick reference
Every abbreviation this post used, expanded, plus the neighbouring terms you will meet in the papers below. Ordered for lookup, not for reading.
Glossary29 terms, A–Z
- 2PC
- Two-phase commit: prepare, then commit, across several machines. Blocks if the coordinator dies mid-flight.
- 2PL
- Two-phase locking: acquire all locks before releasing any. The classical route to serializability.
- ACID
- Atomicity, Consistency, Isolation, Durability. The C is your invariants; the I is this post.
- anti-dependency
- A read-then-write conflict (rw). The edge a snapshot cannot see, and the one write skew is made of.
- atomicity
- All of a transaction happens, or none of it does.
- conflict
- Two operations on the same item, at least one a write. Conflicts force an order.
- dangerous structure
- A transaction with one incoming and one outgoing rw edge. SSI aborts on it.
- deadlock
- Two transactions each holding what the other needs. Detected as a cycle in the wait-for graph.
- dirty read
- Reading data a transaction has written but not committed.
- durability
- A committed transaction survives a crash. Normally by write-ahead log.
- first committer wins
- Under snapshot isolation, two transactions writing one row: the later one aborts.
- isolation level
- A promise about which interleavings the database will refuse to produce.
- lost update
- Two read-modify-writes; one silently overwrites the other.
- MVCC
- Multi-Version Concurrency Control: never overwrite, write a new version. Readers never block writers.
- OCC
- Optimistic Concurrency Control: run freely, validate at commit, abort on conflict.
- phantom
- A row appearing in a repeated range query that was not there before.
- predicate lock
- A lock on rows matching a condition, including rows that do not exist yet. Stops phantoms.
- read skew
- Reading two rows and catching a transaction halfway between them.
- read-your-writes
- A replication promise: your own later requests see your write. Not repeatable read.
- repeatable read
- A transaction promise: a row read twice reads the same. Not read-your-writes.
- schedule
- The interleaved sequence of operations from concurrent transactions.
- serializability
- The result equals some serial order of the transactions.
- serialization failure
- The database refusing a commit because it would break serializability. PostgreSQL SQLSTATE 40001.
- SGT
- Serialization graph testing: build the dependency graph, refuse cycles. Correct, and too expensive.
- SI
- Snapshot Isolation: read as of your start, first committer wins. Permits write skew.
- SS2PL
- Strict strong two-phase locking: hold every lock until commit.
- SSI
- Serializable Snapshot Isolation: SI plus rw tracking, aborting on the dangerous structure.
- WAL
- Write-ahead log: record the change before applying it, so a crash can be recovered.
- write skew
- Two transactions read overlapping rows, write disjoint rows, and jointly break an invariant.
References
Primary sources first, each with what you will find there — including at least one that complicates the argument above.
References9 sources
- Berenson, Bernstein, Gray, Melton, O’Neil & O’Neil, A Critique of ANSI SQL Isolation Levels (1995) — why the level names do not mean what they say, and where snapshot isolation and write skew were first named. Short, and it settles most arguments about isolation on its own.
- Adya, Weak Consistency (1999) — the implementation-independent definitions built on dependency graphs. This is where “an anomaly is a cycle” is made fully precise, and it is the formal version of this post’s central section.
- Fekete, Liarokapis, O’Neil, O’Neil & Shasha, Making Snapshot Isolation Serializable (2005) — the result the whole of SSI rests on: every cycle under SI contains two adjacent anti-dependency edges. Read it for the proof that you never need the full graph.
- Cahill, Röhm & Fekete, Serializable Isolation for Snapshot Databases (2008) — that result turned into the algorithm PostgreSQL ships. The paper to read before arguing that serializable is too slow.
- Kung & Robinson, On Optimistic Methods for Concurrency Control (1981) — the original case for validating instead of locking, and still the clearest statement of when optimism pays and when it does not.
- Gray, Lorie, Putzolu & Traiger, Granularity of Locks and Degrees of Consistency in a Shared Data Base (1976) — where degrees of consistency and lock granularity come from. Everything in the ANSI standard is a lossy retelling of this.
- PostgreSQL: Transaction Isolation — the best-written vendor documentation on this subject, and the place to confirm that
REPEATABLE READmeans snapshot isolation. The worked write-skew example is the one everybody quotes. - Thomson et al., Calvin: Fast Distributed Transactions for Partitioned Database Systems (2012) — agreeing the order first and removing two-phase commit entirely. The strongest argument that the mechanisms above are a choice rather than a necessity.
- Jepsen analyses, and Elle — the reading that complicates this post. Repeatedly, systems documented as providing a level did not provide it. Take every row of the defaults table — and every claim here — as a hypothesis about your deployment rather than a fact about it.