Internals · MVCCAtlasDB V8 · Transactionstransaction idxidcommit logclogpg_xact

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.

▶ InteractiveTry queriesInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. Real database

    PostgreSQL: 32-bit xids, pg_xact with 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 numbered unit of work

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.

One transaction through the subsystems
error / ROLLBACKBEGIN: id + snapshotreads filter versions by snapshotwrites: new version stamped xidlock manager: row X-lockWAL record appendedCOMMIT: record, fsync, status bitROLLBACK: status = abortedvisible to new snapshots
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Transaction state as the engine sees it (educational layout)
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 implementation

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: two bits per transaction, 32,768 per 8 KB page
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.

COMMIT, as the transaction manager runs it
1commit(txn):
2 if txn.wrote_nothing: # read-only: no xid, no record, no fsync
3 release_snapshot(txn); return
4 lsn = wal_append(COMMIT_RECORD(txn.xid, now()))
5 wal_flush(lsn) # the fsync; durability point
6 status_table.set(txn.xid, COMMITTED)
7 running_list.remove(txn.xid) # visibility point for new snapshots
8 lock_manager.release_all(txn) # waiters wake up here
9 return OK # only now does the client see "COMMIT"

What ROLLBACK undoes

PostgreSQL implementation

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

MySQL / InnoDB implementation

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.

InnoDB row header and its undo pointer (conceptual)
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

A transaction, inside the engine
UPDATE accounts SET balance = 400 WHERE id = 1, followed through the four structures an engine keeps: the transaction table, the lock table, the write-ahead log and the version store.
Lifecycle
BEGIN

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.

Transaction table (pg_xact + ProcArray)
vxid 3/12 — no real xid yet
Lock table
Write-ahead log
Version store (heap page 17)
v1 @(17,3): balance=500 xmin=8101 xmax=∅
PostgreSQL implementation
Educational simulation — xids, LSNs and page offsets are illustrative; the ordering of steps is the real one.
1/9 · BEGIN

Try it in the playground

When to use — and when not

Use it when
  • 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.
Avoid it when
  • 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.

Cross-domain bridges
  • 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.