Databases · Transactions and MVCC
First-Committer-Wins Validation in Snapshot Isolation
How snapshot isolation validates writes against concurrent committers, why a write-write check is sufficient for its own guarantees, and the serializability it leaves on the table.
Snapshot isolation replaces the lock-based question “may I read this now” with two cheaper questions: which committed state does this transaction see, and did anyone else write what I am about to write. The first is answered by a read view fixed at transaction start. The second is answered at commit time by a validation that aborts on write-write conflict. Together they deliver a level that eliminates dirty reads, non-repeatable reads, and phantoms while permitting almost all reads to proceed without blocking, which is why the mechanism displaced two-phase locking as the default in multi-version engines.
The two rules
Snapshot isolation is defined by two properties, and everything else follows from them.
A transaction reads from a snapshot consisting of all transactions that committed before it started, and none that committed after. The snapshot is taken once, at the start of the transaction, and does not change. Reads therefore never block and never see another transaction’s uncommitted work.
At commit, the transaction’s write set is checked against the write sets of all transactions that committed after its snapshot was taken. If any item it wrote was also written by a transaction that committed in that window, the transaction aborts. This is first-committer-wins: the transaction that committed first keeps its version, and the later one is rolled back and may retry.
The second rule is what makes the first safe. Without it, two transactions could each read the same old value, each compute a new value, and each write; the later write would silently discard the earlier one, which is the lost update. With it, the second committer is aborted, so the anomaly becomes a retry rather than a wrong answer.
class Txn:
def __init__(self, start_seq):
self.start_seq = start_seq # snapshot boundary
self.writes = set()
self.committed = False
def commit(txn, committed_after_snapshot):
"""committed_after_snapshot: [(commit_seq, write_set)] for txns that
committed after txn.start_seq. Returns True if commit is allowed."""
for seq, write_set in committed_after_snapshot:
if txn.writes & write_set:
return False # first-committer-wins: abort
return True
The check is a set intersection per concurrent committer, so its cost is proportional to the number of transactions that committed during the committing transaction’s lifetime, multiplied by the size of the write set. Long-running writers pay more, which is the first hint that snapshot isolation’s cost is paid by the writer rather than the reader.
What the validation does not check
The validation compares write sets. It does not compare a transaction’s read set against other transactions’ write sets. That omission is not an oversight; it is the design. Adding read-set validation at commit turns the mechanism into something closer to serializable snapshot isolation, described in the dangerous-structure analysis, and that addition costs aborts on workloads that never needed them.
The consequence is that a transaction can read an item, another transaction can modify that item and commit, and the first transaction can still commit successfully as long as it does not write the same item. Its reads were from a snapshot that is now stale, but snapshot isolation does not care: the transaction’s own view was internally consistent, and the writes it made were based on a state that did exist.
This is exactly right for the guarantees snapshot isolation claims and exactly wrong for serializability. Consider a constraint enforced by reading a set and writing a member of a different set. Two transactions, each reading the same two rows and each updating a different one, both pass validation because their write sets are disjoint, and the constraint they were each trying to preserve is violated after both commit. The anomaly is write skew, and it is permitted by definition, not by accident.
Where a write-write check is enough
For a large class of transactions the write-write check is sufficient in a stronger sense: those transactions are serializable under snapshot isolation regardless of what else runs concurrently. The characterization is that a transaction whose write set is empty, or whose reads and writes all touch the same items, cannot participate in a write-skew cycle. A read-only transaction is trivially serializable. A transaction that reads x and writes x conflicts with any other writer of x and is therefore validated.
The general condition is that the transaction must not have a read that another transaction’s write invalidates while that other transaction has a read invalidated by this transaction’s write. Cycles require at least two transactions with disjoint write sets and overlapping read-write relationships. A workload in which every transaction’s read set is a subset of its write set has no such cycles.
This is the practical basis for the common claim that snapshot isolation is serializable for “most” transactions. The claim is true of transactions that read only what they write, which describes single-row updates, counters updated through an atomic operation, and any transaction whose reads are purely for computing the value it stores back. It is false of transactions that read a set to enforce an invariant over that set, which is precisely the pattern of application-level constraints.
Aborts, retries, and the cost model
An aborted transaction under first-committer-wins has done its work and must redo it. If the transaction held locks or performed external effects, the abort is visible to the outside world; snapshot isolation therefore requires that external effects be deferred until commit, which is a constraint on the application rather than on the engine.
Retry loops interact badly with contention. Two transactions updating the same hot row will conflict every time one of them commits during the other’s lifetime, so under sustained contention one of them can abort repeatedly. The mitigation in practice is to shorten transactions so their snapshots overlap less, or to serialize access to hot items through an explicit lock or queue, which reintroduces blocking for that item only.
The memory cost is the other side. A snapshot that is never advanced forces the engine to retain every row version visible to it, so a long-running read-only transaction holds back version collection for the whole database. This is the mechanism behind the storage growth described in vacuum horizons and dead version collection, and it is a cost that read-only transactions impose without paying any validation cost themselves.
| Property | Locking (2PL) | Snapshot isolation |
|---|---|---|
| Read blocks write | Yes | No |
| Write blocks read | Yes | No |
| Write blocks write | Yes | Yes, at commit |
| Aborts on read conflict | No | No |
| Aborts on write conflict | Deadlock only | Always, first committer wins |
| Permits write skew | No | Yes |
| Cost borne by | Readers and writers | Writers and storage |
Why the snapshot must be taken once
If the snapshot were refreshed per statement, the level would degrade to read committed with statement-level consistency, and the write-write validation would have to be redefined, because the “concurrent window” would differ per statement. Taking it once is what makes the transaction’s reads coherent and what makes the validation window well defined: everything that committed after start_seq and before this transaction’s commit sequence is a potential conflict.
There is a subtlety in when the snapshot is taken. Taking it at the first statement rather than at BEGIN means a transaction that begins and then waits does not hold a stale snapshot while idle, and it means the snapshot boundary is the first read rather than the BEGIN statement. Both choices exist in real engines, and the difference is observable: a transaction that begins, sleeps, and then reads sees a state that may be much newer than its BEGIN time. The mechanics of that choice, and the in-progress set that defines it, are covered in snapshot acquisition and commit ordering.
The other subtlety is that the commit sequence must be assigned in a way that all participants agree on, or two transactions can each believe they committed first. A single atomic counter in a shared log serves; a set of independent clocks does not, because clock skew can place a transaction’s commit before the snapshot of a transaction that actually started later. This is why commit ordering is a shared resource even when reads and writes are distributed.
The boundary of the guarantee
Snapshot isolation’s guarantee can be stated compactly: every transaction observes a state produced by a set of committed transactions, and no two transactions that write the same item both commit if their lifetimes overlap. That is enough to prevent lost updates and inconsistent reads, and not enough to prevent write skew, read-only anomalies, or the more elaborate cycle structures that require three transactions.
An application that stays inside the guarantee gets non-blocking reads and predictable single-row conflict semantics. An application that steps outside it gets a level that looks serializable and is not, with failures that appear only under concurrency and only for particular data. The cheapest defence is to make the constraint explicit in the schema so the engine enforces it, and the strongest is to run at a level that checks read sets as well as write sets.
References
- A Critique of ANSI SQL Isolation Levels — Berenson, Bernstein, Gray, Melton, O'Neil, O'Neil; SIGMOD 1995
- Serializable Isolation for Snapshot Databases — Cahill, Röhm, Fekete; SIGMOD 2008
- PostgreSQL Documentation — Transaction Isolation — PostgreSQL Global Development Group