The Lock Manager
A lock is a row in a hash table keyed by the resource, with a list of who holds it in which mode and a queue of who is waiting; the compatibility matrix decides grant or wait, intention locks let row and table granularity coexist, and PostgreSQL avoids the table entirely for row locks by writing the holder's id into the row itself.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
Two-phase locking needs someone to answer, thousands of times per second: "may transaction 1042 take an exclusive lock on row 7 while 1038 holds it?", and to wake 1042 the instant 1038 lets go.
↓ - Naive solution
One mutex per table. Any write to any row of
accountslocks the whole table.↓ - Why it breaks
A bank with 800 accounts can process one transfer at a time. Readers of unrelated rows wait behind writers. Throughput on a 64-core box is that of one core.
↓ - Better idea
Lock the smallest thing that conflicts — the row — but keep a cheap way to know whether *any* row of a table is locked, so a table-level operation such as ALTER TABLE does not have to scan a million row locks.
↓ - Internal mechanism
A lock table: a hash keyed by resource id, each entry carrying granted holders with their modes and a FIFO wait queue. A compatibility matrix decides grant or wait. Intention locks on the table announce row locks below. Release walks the queue and wakes whoever is now compatible.
↓ - Trade-offs
Every lock is memory and a hash-table probe under a latch; a transaction touching a million rows owns a million entries. Escalating to a table lock saves memory and destroys concurrency. Fine granularity multiplies deadlock opportunities.
↓ - Real database
PostgreSQL keeps table-level locks in a partitioned shared-memory hash and stores row locks in the tuple header, using the lock table only for waiters. InnoDB keeps record, gap and next-key locks in a hash keyed by page with a bitmap per page.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
The lock manager is a shared table: for each locked thing, who holds it and in what mode, and who is waiting. A request is granted when it is compatible with every holder; otherwise the requester is put to sleep in a queue and woken when a release makes it compatible. All the behaviour you see — a blocked UPDATE, an ALTER TABLE that stalls the application — is this table doing its job.
Modes and the compatibility matrix
A lock request names a resource and a mode. Shared (S) is for reading under locking protocols: any number of transactions may hold S on the same resource. Exclusive (X) is for writing: one holder, nothing else granted. With row-level locking a third question arises — can a transaction that wants to lock the whole table (DDL, LOCK TABLE, a full-table write) cheaply discover that some other transaction holds locks on individual rows? Scanning the lock table for every row of the relation would be absurd.
Intention locks answer that. A transaction that is going to take S locks on rows first takes IS on the table; one that will take X on rows first takes IX. Intention locks are compatible with each other (many transactions may work on rows of one table) and conflict with table-level S or X, so a table-level request checks one entry. SIX — shared on the whole table, intending exclusive on some rows — is the classic fifth mode for "read everything, update a few". The matrix below is the entire grant policy; everything the lock manager decides is a lookup in it.
| requested \ held | IS | IX | S | SIX | X |
|---|---|---|---|---|---|
| IS | yes | yes | yes | yes | no |
| IX | yes | yes | no | no | no |
| S | yes | no | yes | no | no |
| SIX | yes | no | no | no | no |
| X | no | no | no | no | no |
The lock table
Structurally the lock manager is a hash table in shared memory — a Hash Table keyed by a resource tag: (relation 16403), (relation 16403, page 91, tuple 7), (transaction 1038), and so on. Each entry records the modes currently granted and by whom, and a queue of requests that could not be granted. A lookup is a hash of the tag, a bucket walk under a partition latch, and a matrix check of the requested mode against the union of granted modes. Grant appends the requester to the holders; wait appends it to the queue and puts the process to sleep on its own semaphore.
Release is where the wake-up logic lives. The releaser removes its entry, recomputes the granted-mode set, and walks the wait queue from the head. Each waiter whose mode is now compatible with the granted set *and* with the waiters ahead of it is granted and signalled; the walk stops at the first waiter that still conflicts, so requests are served in arrival order and a stream of shared requests cannot starve an exclusive one indefinitely. A waiter that wakes up re-checks nothing: the releaser granted it before signalling.
bucket 0x3a1 tag=(rel 16403 "accounts")
granted: [ xid 1038: IX ] [ xid 1041: IS ] [ xid 1042: IX ]
waiting: [ xid 1050: AccessExclusive (ALTER TABLE) ] <- blocked by IX/IS above
and blocks every new IS/IX behind it
bucket 0x7c2 tag=(rel 16403, page 91, tuple 7)
granted: [ xid 1038: X ]
waiting: [ xid 1042: X ] [ xid 1047: S ]
release by 1038:
entry 0x7c2 granted set = {} -> walk queue: 1042 X compatible -> grant, signal
1047 S conflicts with 1042 X -> stopWaiting and waking up
A blocked request does not spin. The lock manager records it in the queue, records what it waits for (the edge that Deadlock Detection: The Waits-For Graph will read), and the process sleeps on a semaphore until the releaser signals it. The wake-up therefore costs the releaser a queue walk and a signal per granted waiter, and the waiter one context switch. Under contention on one hot row this is a convoy: dozens of processes sleeping and waking in turn, each holding the row for a few microseconds, with the scheduler overhead dominating the useful work. Lock waits are visible as such — pg_stat_activity.wait_event_type = 'Lock' in PostgreSQL, data_lock_waits in MySQL — and a lock wait that lasts seconds is almost always a transaction holding a lock across something it should not be doing.
Timeouts hang off the same queue. lock_timeout arms a timer when the request is queued and removes it from the queue with an error if it fires; NOWAIT skips the queue entirely and errors on the first incompatible holder; SKIP LOCKED treats an incompatible row as if it did not match. All three are policies of the waiter, not of the table.
Lock escalation
Every lock is an entry in a fixed-size shared-memory table. A transaction that updates ten million rows under row-level locking would need ten million entries — and in engines that store row locks in the table, that is where lock escalation comes from: past a threshold (SQL Server: about 5,000 locks on one object; DB2: a configured fraction of the lock list), the engine replaces the row locks with a single table lock. Memory is saved; every other transaction on the table now blocks. Escalation is the reason a batch job can freeze an application without any explicit LOCK TABLE in its code.
PostgreSQL does not escalate, because it does not store row locks in the table at all (next section). InnoDB does not escalate either; its bitmap-per-page representation keeps a million row locks affordable. Both can still run out of lock memory — PostgreSQL with "out of shared memory: increase max_locks_per_transaction" when a transaction touches thousands of *relations* (partitions are the usual cause), InnoDB when the lock heap exhausts the buffer pool.
Row locks in the tuple header
PostgreSQL keeps relation-level locks in the shared hash described above, but a row lock is stored *in the row*. Locking a tuple — by UPDATE, DELETE or SELECT … FOR UPDATE — writes the locker's transaction id into the tuple's xmax field and sets infomask bits (HEAP_XMAX_LOCK_ONLY, HEAP_XMAX_EXCL_LOCK or HEAP_XMAX_KEYSHR_LOCK) that say "this xmax is a lock, not a deletion". The lock costs no shared memory, survives as long as the page does, and there is no limit on how many a transaction may hold. It also means locking a row dirties its page and produces a WAL record — a SELECT … FOR UPDATE over a million rows is a write of a million rows.
Conflict detection is then a read of the header: a second transaction finds xmax = 1038, checks whether 1038 is still running, and if so must wait. To wait it *does* use the lock table: it takes a heavyweight lock on the tuple tag (so that several waiters queue in order rather than all racing for the row when it frees) and then requests a shared lock on the *transaction id* 1038 — a lock every transaction holds exclusively on its own xid from start to end, released by COMMIT or ROLLBACK. That release is the wake-up. This is why pg_locks shows the waiter with locktype = transactionid, and why a row lock is released exactly at transaction end and never earlier. Shared row locks with many holders do not fit in one xmax; they are represented by a multixact id stored in xmax that points to a list of member xids kept in pg_multixact.
heap page 91, tuple 7 t_xmin = 1015 t_xmax = 1038 t_infomask: HEAP_XMAX_LOCK_ONLY | HEAP_XMAX_EXCL_LOCK -> "row is locked by 1038; it was not deleted" pg_locks (heavyweight table) locktype tag mode granted pid transactionid 1038 ExclusiveLock t (holder, backend 1) tuple rel 16403 pg 91 tup 7 ExclusiveLock t (waiter, backend 4) transactionid 1038 ShareLock f (waiter, backend 4) <- sleeps here
Record, gap and next-key locks
InnoDB locks live in the lock system, hashed by the page they refer to, and come in three shapes because InnoDB's REPEATABLE READ must stop phantoms in locking reads using locks alone. A record lock covers one index record. A gap lock covers the open interval *between* two index records — an insert into that interval waits, which is how a range scan prevents a row from appearing inside its range. A next-key lock is a record lock plus the gap before the record; a locking range scan (SELECT … WHERE id BETWEEN 10 AND 20 FOR UPDATE) takes next-key locks on every index record it visits and one more on the gap after the last, sealing the whole interval.
Gap locks are the source of the deadlocks and the lock waits that surprise people migrating from PostgreSQL: two transactions inserting different ids into the same gap conflict, and an UPDATE by a non-indexed column locks *every* record of the table it scanned, plus every gap. Under READ COMMITTED InnoDB drops gap locks for ordinary statements, which is the usual first remedy. Unique-key equality lookups take only a record lock, which is why point updates by primary key remain cheap.
index records: ... 8 10 14 17 20 25 ...
next-key locks: (8,10] (10,14] (14,17] (17,20] (20,25)
^ gap after last match
INSERT id=15 by another transaction -> waits on gap (14,17]
UPDATE id=25 by another transaction -> proceeds (record 25 not locked, only the gap before it)Key points
- The lock manager is a hash table keyed by resource with granted holders and a FIFO wait queue; grant or wait is a compatibility-matrix lookup.
- Intention locks (IS, IX, SIX) let table-level requests detect row-level activity with one entry.
- Release walks the wait queue in order, granting compatible waiters and stopping at the first conflict — that is both fairness and starvation avoidance.
- Engines that store row locks in the table escalate to table locks under pressure; PostgreSQL stores the row lock in the tuple's xmax and queues waiters on the holder's transaction id instead.
- InnoDB's gap and next-key locks seal ranges against inserts, which prevents phantoms in locking reads and produces the lock waits that surprise PostgreSQL users.
Lock manager
| resource | granted (holders) | waiting (FIFO) |
|---|---|---|
| row r1 | — | — |
| row r2 | — | — |
| row r3 | — | — |
| row r4 | — | — |
| table T | — | — |
| IS | IX | S | X | |
|---|---|---|---|---|
| IS | ✓ | ✓ | ✓ | ✗ |
| IX | ✓ | ✓ | ✗ | ✗ |
| S | ✓ | ✗ | ✓ | ✗ |
| X | ✗ | ✗ | ✗ | ✗ |
- IS intention shared — "I will take S locks on some rows below"
- IX intention exclusive — "I will take X locks on some rows below"
- S shared — read; others may also read
- X exclusive — write; nobody else may read or write
When to use — and when not
- A lock table with modes and intention locks fits any engine that must serialise writers at row granularity while still supporting cheap table-level operations.
- In-row lock storage (PostgreSQL) fits when transactions may lock unbounded numbers of rows and lock memory must never be the limit.
- A central lock table does not fit a read-mostly workload where readers must never wait — that is what multi-versioning removes from the lock manager entirely.
- Gap locking does not fit insert-heavy tables with range updates; drop to READ COMMITTED or index the predicate.
Failure modes
- ALTER TABLE queued behind one long transaction's IX lock, and every new query queued behind the ALTER.
- "out of shared memory: increase max_locks_per_transaction" from a query touching thousands of partitions.
- A FOR UPDATE over a large range on PostgreSQL producing a surprising volume of WAL and dirty pages.
- InnoDB gap-lock deadlocks between concurrent inserts into the same key range.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSAHash table with chaining → Lock table keyed by resource tagBucket = resource; each bucket carries a granted list and a wait queue rather than a single value.
- DSAQueue → Lock wait queue (FIFO to prevent starvation)
- Operating SystemsReader–writer lock → Shared / exclusive lock modesThe same S/X idea, extended with intention modes and made to span a whole transaction rather than a code block.