A Transaction, Inside the Engine
BEGIN hands out a transaction id and a snapshot; reads consult it, writes stamp it onto row versions and into the WAL; COMMIT is one log record, one fsync and one bit flip in the transaction status table — and ROLLBACK, in PostgreSQL, writes almost nothing at all.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
A transfer is two UPDATEs. Between them the process can die, and other sessions can look. The engine must make both changes appear together, to everyone, or not at all — and must still know which is which after a crash.
↓ - Naive solution
Copy the affected pages before touching them; on COMMIT delete the copies, on ROLLBACK put them back. Concurrent readers wait until the writer decides.
↓ - Why it breaks
Every writer stalls every reader on the same pages. Page copies for a transaction that touches 50,000 rows are 400 MB of scratch space. And after a crash nobody knows which copies belonged to a finished transaction and which to a dead one.
↓ - Better idea
Give each transaction a number. Stamp that number on everything it writes and on every log record it produces. Keep one small table that says, per number, "in progress / committed / aborted". Then a change is committed exactly when its number is.
↓ - Internal mechanism
BEGIN takes a snapshot of which numbers are running. Writes stamp the id onto row versions and go to the WAL. COMMIT appends a commit record, fsyncs the log, flips the status bit to committed, and drops the id from the running list; every stamped change becomes visible at once, without touching the pages again.
↓ - Trade-offs
The status table is consulted on every visibility check unless the answer is cached on the row; ids are finite and must be recycled; rolled-back rows (PostgreSQL) or undo records (InnoDB) are garbage someone must collect later.
↓ - Real database
PostgreSQL: 32-bit xids,
pg_xactwith two bits per transaction, hint bits on tuples. InnoDB: 48-bit trx ids stored in each row with a pointer into the undo log that ROLLBACK replays backwards.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
A transaction is a number and a state. Everything it writes carries the number; the state says whether those writes count. COMMIT changes the state; ROLLBACK changes it the other way. Readers decide what to believe by looking up states, not by looking at what the writer is doing right now.
That is why a commit of ten thousand rows takes about as long as a commit of one: the rows were already written, only the verdict was missing.
The lifecycle in one picture
The practical lesson Transactions and ACID draws a transaction as BEGIN … COMMIT. Inside the engine it is a sequence of six things, each owned by a different subsystem: the transaction manager hands out an id and a state; the snapshot machinery decides what this transaction may see; the executor reads through that snapshot and writes new row versions stamped with the id; the lock manager serialises conflicting writers; the WAL makes the writes durable; COMMIT ties them together with one record.
Nothing in this sequence touches the table pages a second time at commit. That single design fact explains most of what follows: a commit is cheap because the verdict is stored in one place, and every row version carries a reference to that place instead of carrying the verdict itself.
Transaction ids and the running list
The transaction id is the engine's name for "this unit of work". It is assigned from a global counter under a spinlock, so ids are monotonically increasing and the order of two ids is the order in which the transactions first wrote. The id is stamped onto every row version the transaction creates or invalidates and into every WAL record it emits. It is never stored *with* a verdict; the verdict lives elsewhere and is looked up.
Alongside the counter, shared memory holds the running list: one slot per session, containing the xid that session is currently running (or nothing). Taking a snapshot means walking this array under a shared lock and copying the ids that are in progress — typically a few dozen entries, a few hundred bytes. That copy is what "snapshot" means physically; the next lesson on MVCC Internals: Version Chains and Snapshots spells out how it is used.
global counter next_xid = 1043 running list (shared memory, one slot per backend) backend 1 xid 1038 started 12:00:01.204 holds: accounts row 7 (X) backend 2 xid 1041 started 12:00:01.910 holds: — backend 3 — (read-only: virtual xid only, consumes no number) backend 4 xid 1042 started 12:00:02.003 waiting on: accounts row 7 status table (durable, 2 bits per xid) xid 1036 1037 1038 1039 1040 1041 1042 state C A IP C C IP IP C=committed A=aborted IP=in progress
The transaction status table
PostgreSQL keeps the verdict per transaction in pg_xact (called pg_clog before version 10): a flat file of two-bit entries, indexed by xid. Four states fit in two bits — in progress, committed, aborted, and sub-committed for subtransactions — so an 8 KB page records 32,768 transactions and a busy system producing a thousand write transactions per second fills a page every half minute. The pages are cached in a small shared buffer (SLRU) and written like any other page; the WAL commit record is what makes the bit durable, so a crash before the page is written is repaired by redo.
Consulting pg_xact costs a lookup on every visibility check, and it is a random access into a file that can be gigabytes long. The fix is the hint bit: the first reader that finds a row whose creator has a final verdict writes HEAP_XMIN_COMMITTED or HEAP_XMIN_INVALID into the row header's infomask. The page is dirtied, but every subsequent reader skips the lookup. This is why the first sequential scan after a bulk load is slow and writes a lot: it is setting hint bits on every row.
pg_xact/0000 (xids 0 .. 262,143)
byte 0 xid 0..3 [01][01][01][01]
...
byte 259 xid 1036..1039 [01][10][00][01] 00 = in progress
byte 260 xid 1040..1043 [01][00][00][00] 01 = committed
10 = aborted
11 = sub-committed
tuple header, after a reader resolved xid 1036:
t_xmin = 1036 t_infomask |= HEAP_XMIN_COMMITTED (no lookup next time)What COMMIT actually does
Four steps, in a fixed order. Write the commit record to the WAL buffer, after every data record the transaction produced. Flush the WAL to disk up to that record — the fsync; this is the only synchronous disk write in the whole transaction, and it is why commit latency is measured in log-flush time (a few hundred microseconds on a fast SSD, milliseconds on a spinning disk, and the reason synchronous_commit = off exists). Mark the transaction committed in the status table. Remove the id from the running list — from this instant a new snapshot treats the transaction as finished — and release its locks, waking anyone queued on them.
Nothing is written to the table pages. Every row version the transaction created still says xmin = 1038; readers now find 1038 marked committed and believe the row. Ten rows or ten million, commit does the same four steps. The table pages will be written later, by the checkpointer or when the buffer pool evicts them — see Write-Ahead Logging for why that is safe.
1commit(txn):2 if txn.wrote_nothing: # read-only: no xid, no record, no fsync3 release_snapshot(txn); return4 lsn = wal_append(COMMIT_RECORD(txn.xid, now()))5 wal_flush(lsn) # the fsync; durability point6 status_table.set(txn.xid, COMMITTED)7 running_list.remove(txn.xid) # visibility point for new snapshots8 lock_manager.release_all(txn) # waiters wake up here9 return OK # only now does the client see "COMMIT"What ROLLBACK undoes
In PostgreSQL, ROLLBACK undoes almost nothing on disk. An abort record goes to the WAL (it need not be flushed — a crash produces the same outcome), the status bits are set to aborted, the id leaves the running list, locks are released. The row versions the transaction wrote stay exactly where they are, stamped with an xid that is now aborted; every visibility check will treat them as if they never existed, and VACUUM will remove them later like any other dead tuple. Rows the transaction deleted have their xmax pointing at an aborted transaction, which the visibility rule reads as "not deleted".
This is why a rollback of a million-row UPDATE returns in milliseconds and why it leaves a million dead tuples behind. It is also why a crash is just an implicit rollback: no undo phase is needed, because the aborted rows are indistinguishable from any other invisible ones.
What ROLLBACK undoes
InnoDB updates rows in place, so ROLLBACK has real work to do. Each row carries a 6-byte DB_TRX_ID and a 7-byte DB_ROLL_PTR pointing at an undo log record that holds the previous image of the row. Before every change the old image is written to an undo segment, itself protected by the redo log. ROLLBACK walks the transaction's undo records from newest to oldest and re-applies each old image, restoring the row and its index entries; only then is the transaction marked aborted and its locks released.
The cost is proportional to the work being undone: rolling back a million-row UPDATE takes about as long as running it did, sometimes longer. The benefit is the mirror image of PostgreSQL's: the table contains no aborted rows to clean up, and old versions live in undo space rather than in the table. After a crash, InnoDB recovery has an explicit undo phase for transactions that were in flight — see Crash Recovery.
clustered index record id=7 | DB_TRX_ID=1038 | DB_ROLL_PTR ─┐ | balance=80
│
undo log (rollback segment) ▼
undo rec #412 trx 1038 table accounts pk 7 old: balance=100 prev ─► #391 (trx 1015)
ROLLBACK trx 1038: read #412, write balance=100 back into the record, drop the pointer.Key points
- A transaction is a number plus a state. Row versions and WAL records carry the number; one status table holds the state.
- COMMIT: commit record, WAL fsync, status bit, leave the running list, release locks — in that order, and no table page is touched.
- PostgreSQL ROLLBACK writes nothing to the tables: aborted versions are simply invisible and become VACUUM's job.
- InnoDB ROLLBACK replays undo records to restore old images; the cost scales with the work undone.
- Read-only transactions in PostgreSQL never take an xid; hint bits keep visibility checks from hitting pg_xact repeatedly.
A transaction, inside the engine
- ↓
- ↓
- ↓
- ↓
- ↓
- ↓
- ↓
- ↓
The client sends BEGIN. Nothing that costs anything happens: no transaction id, no snapshot, no log record. PostgreSQL hands out a virtual xid (backend 3, local counter 12 → "3/12") so the transaction can be named in pg_locks without consuming a real xid — read-only transactions never need one. InnoDB likewise assigns a trx id lazily, on the first write.
vxid 3/12 — no real xid yet
—
—
v1 @(17,3): balance=500 xmin=8101 xmax=∅
Try it in the playground
When to use — and when not
- This design — verdict in one table, changes stamped with an id — fits any engine that must commit large transactions in constant time and recover without scanning the data.
- Read the transaction lifecycle this way whenever you are reasoning about commit latency, "idle in transaction" sessions or why a rollback was instant.
- A single-user embedded store with no concurrency can skip ids and status tables and simply copy pages — the machinery here pays for itself only under concurrent access.
Failure modes
- Assuming COMMIT writes the table pages — then wondering why a checkpoint later causes an I/O storm.
- A bulk load followed by a first read that is unexpectedly slow and write-heavy: hint bits being set.
- Treating ROLLBACK as free on InnoDB (it is not) or as space-neutral on PostgreSQL (it is not).
- Long transactions holding an xid open, which pins the running list and every snapshot taken after them.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- Operating SystemsCritical section guarded by a mutex → Transaction guarded by locks and a snapshotA critical section protects one resource for one thread until it exits; a transaction protects many resources across many statements, and its exit must survive a power cut.
- Operating SystemsProcess id and process table → Transaction id and transaction status tableThe kernel tracks process state in a table keyed by pid; the engine tracks transaction state in a table keyed by xid — and, like pids, the ids wrap.