Part 5 · 10 chapters · ~20 min

Locking

MVCC let readers avoid writers. Locking is what happens when writers meet each other. This part covers the full hierarchy, the gap locks that surprise everyone, and the skill that separates people who fix deadlocks from people who add retry loops: reading SHOW ENGINE INNODB STATUS and knowing exactly which two statements collided.

51

The lock hierarchy

Four levels, from coarsest to finest. Most people know the last one and are surprised by the second.

LevelTaken byBlocksWhere it hurts
GlobalFLUSH TABLES WITH READ LOCK, LOCK INSTANCE FOR BACKUPAll writes, server-wideBackups. A held global lock stops the entire server writing.
Metadata (MDL)Every statement, automaticallyConcurrent DDL against the same tableChapter 52. This is the invisible one.
TableLOCK TABLES, and intention locks from InnoDBDepends on modeRare in InnoDB code; common in legacy code.
RowInnoDB, on current reads and writesOther transactions touching those rows or gapsChapters 54–60. Where deadlocks live.
52

Metadata locks, and the ALTER that hung everything

Every statement that touches a table takes a metadata lock on it, held until the transaction ends — not until the statement ends. Their purpose is to stop the table definition changing underneath a running transaction.

The failure mode is specific and catastrophic, and it is worth walking through carefully because it looks impossible when it happens.

why this is so confusing in the moment
The ALTER appears to be blocking every query on the table — but the ALTER is itself blocked, by a transaction that may have been idle for an hour and is running no query at all. Killing the ALTER usually fixes it instantly, which teaches people the wrong lesson. The actual culprit is the idle transaction, and it will cause the same incident next time.

MDL requests queue in order and an exclusive request blocks everything behind it. That is the crucial detail: without the queue, new simple reads could proceed alongside the existing one.
run it
-- 1. Confirm it is MDL. Look for "Waiting for table metadata lock".
SELECT id, user, host, db, command, time, state,
       LEFT(info, 80) AS query
FROM information_schema.processlist
WHERE state LIKE '%metadata lock%'
   OR command = 'Query'
ORDER BY time DESC;

-- 2. Who HOLDS the lock the ALTER wants? (needs the MDL instrument)
SELECT object_type, object_schema, object_name,
       lock_type, lock_status, owner_thread_id
FROM performance_schema.metadata_locks
WHERE object_schema = 'yourdb' AND object_name = 'users';

-- Enable it first if that returns nothing:
UPDATE performance_schema.setup_instruments
   SET enabled = 'YES' WHERE name = 'wait/lock/metadata/sql/mdl';

-- 3. Map the holding thread back to a connection you can kill.
SELECT m.owner_thread_id, t.processlist_id, t.processlist_user,
       t.processlist_host, t.processlist_command, t.processlist_time
FROM performance_schema.metadata_locks m
JOIN performance_schema.threads t ON t.thread_id = m.owner_thread_id
WHERE m.lock_status = 'GRANTED' AND m.object_name = 'users';

-- 4. Prevention: never let an ALTER wait forever.
SET SESSION lock_wait_timeout = 5;    -- seconds, default 31536000 (!)
ALTER TABLE users ADD COLUMN x INT;
--   fails fast instead of taking the site down. Retry in a loop.
the MDL queue
how one idle transaction stops a table
swipe the figure sideways, or tap expand for full screen
1/5
quiet
A table, quiet. Nothing unusual.
53

Intention locks

A problem: someone wants to lock a whole table. To know whether that is safe, they would have to check every row for existing row locks — which is impossibly slow on a large table.

The solution is to declare intent at the table level first. Before taking a row lock, a transaction takes a lightweight intention lock on the table: IS (intention shared) or IX (intention exclusive). Now a table-level request only has to check one table-level structure.

Crucially, IS and IX do not conflict with each other. Many transactions can hold IX on a table simultaneously; they only conflict at the row level.

ISIXSX
IS✓✓✓✗
IX✓✓✗✗
S✓✗✓✗
X✗✗✗✗

Read it as: can the row lock be granted while the column lock is held? The practical takeaway is the IX/IX cell — normal concurrent writing does not conflict at table level at all.

54

Record locks, gap locks, next-key locks

The source of most InnoDB surprises. InnoDB does not only lock rows that exist — it locks the spaces between them, to prevent phantoms.

LockCoversPrevents
RecordOne index entryOthers modifying that row
GapThe open interval between two index valuesInserts into that range. Does not block reads or updates of existing rows.
Next-keyA record plus the gap before it — the defaultBoth. This is what a range scan takes.
two rules that remove most of the surprise
Gap locks only exist under REPEATABLE READ. Switch to READ COMMITTED and they largely disappear — which is one legitimate reason to choose it.

A unique index lookup that finds its row degrades to a record lock. No gap is needed, because a unique index cannot grow a second row with that value. But if the lookup finds nothing, a gap lock is still taken — which is exactly the situation that produces the deadlock in chapter 55.
run it
CREATE TABLE t (id INT PRIMARY KEY, v INT, INDEX idx_v (v));
INSERT INTO t VALUES (1,10),(5,50),(10,100),(20,200);

BEGIN;
SELECT * FROM t WHERE id BETWEEN 5 AND 10 FOR UPDATE;

-- THE query. Shows every lock, its type, and the exact record.
SELECT object_name, index_name, lock_type, lock_mode,
       lock_status, lock_data
FROM performance_schema.data_locks;

-- lock_mode tells you which kind:
--   X                  → next-key  (record + gap before it)
--   X,REC_NOT_GAP       → record only
--   X,GAP               → gap only
--   X,INSERT_INTENTION  → waiting to insert into a gap

ROLLBACK;
lock types on a number line
record · gap · next-key
swipe the figure sideways, or tap expand for full screen
1/6
the index
An index containing four values. The spaces between them are as important as the values.
55

Insert intention locks, and the two-insert deadlock

An insert into a gap first takes an insert intention lock, signalling "I intend to insert here". Two inserts into the same gap at different positions do not conflict — that is the point, it keeps concurrent inserts fast. But an insert intention lock does conflict with a gap lock held by someone else.

This produces the most famous InnoDB deadlock, and it involves no shared rows at all:

session A
t1 BEGIN;
t2 SELECT * FROM t WHERE id=7
     FOR UPDATE;
     -- no row 7 exists.
     -- takes a GAP LOCK on (5,10)

t5 INSERT INTO t VALUES (7,70);
     -- needs insert intention in (5,10)
     -- B holds a gap lock there → WAIT

     -- ERROR 1213: Deadlock found
session B

t3 BEGIN;
t4 SELECT * FROM t WHERE id=8
     FOR UPDATE;
     -- also no row. ALSO takes a
     -- gap lock on (5,10) — gap locks
     -- do NOT conflict with each other

t6 INSERT INTO t VALUES (8,80);
     -- needs insert intention in (5,10)
     -- A holds a gap lock there → WAIT
     -- both now wait on each other
why this is genuinely counter-intuitive
Neither transaction touched a row the other touched. Neither inserted the same key. They deadlocked because two compatible gap locks were both held over the same interval, and each then needed to insert into an interval the other was protecting.

This is the mechanism behind the classic "check if it exists, then insert" pattern deadlocking under concurrency. The fix is not a retry loop — it is to stop checking first: use INSERT ... ON DUPLICATE KEY UPDATE or INSERT IGNORE and let the unique index do the work atomically.
56

AUTO-INC lock modes

Generating the next auto-increment value needs coordination. innodb_autoinc_lock_mode chooses how much.

ModeNameBehaviourGaps?
0traditionalA table-level AUTO-INC lock held for the whole statement. Fully serializes inserts.No
1consecutiveSimple inserts get values under a brief mutex; bulk inserts still take the table lock.Some
2interleavedDefault since 8.0. Only a brief mutex, never a statement-long lock. Maximum concurrency.Yes

Mode 2 means auto-increment values are not guaranteed to be consecutive, and concurrent multi-row inserts can interleave their ranges. This is fine — but it means you must never treat an auto-increment id as a gapless sequence. Gaps also appear from rolled-back transactions under every mode; the counter does not go backwards.

the 8.0 change worth knowing
Before 8.0 the counter lived only in memory and was recomputed as MAX(id)+1 on restart — so deleting the highest rows and restarting would reuse those ids. Since 8.0 the counter is persisted to the redo log, and that whole class of bug is gone. If you see advice about this hazard, it is pre-8.0 advice.
57

Deadlock detection

InnoDB maintains a wait-for graph: an edge from each waiting transaction to the one holding what it wants. A cycle in that graph is a deadlock. Detection runs when a lock wait begins, so deadlocks are found immediately rather than after a timeout.

The victim is whichever transaction has done the least work, measured roughly by the number of rows modified and undo records written. It gets ERROR 1213 and a full rollback; the other proceeds.

innodb_deadlock_detect = OFF
Detection walks the graph on every lock wait, and on a very hot row with thousands of waiters that walk becomes a bottleneck. Turning it off replaces detection with innodb_lock_wait_timeout — deadlocks then resolve by timing out instead, which is far slower per incident but removes the detection cost.

This is a specialist setting for extreme contention. Default is ON, and it should stay ON unless you have measured the graph walk as your bottleneck.
wait-for graph
cycle detection and victim choice
swipe the figure sideways, or tap expand for full screen
1/5
two transactions
Two transactions begin, touching two rows between them.
58

Reading INNODB STATUS line by line

This is a skill, not a fact. Here is a real deadlock section, annotated. Once you can read this, deadlocks become diagnosable rather than mysterious.

run it
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-09-21 14:03:11 0x7f2a1c0e7700
*** (1) TRANSACTION:                        ← the first transaction
TRANSACTION 48211, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 812, OS thread handle ..., query id 99210
  updating
UPDATE accounts SET bal = bal - 100 WHERE id = 2   ← what it was doing

*** (1) HOLDS THE LOCK(S):                  ← what it already had
RECORD LOCKS space id 421 page no 4 n bits 72
  index PRIMARY of table `bank`.`accounts`
  trx id 48211 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: 0: len 4; hex 80000001;  ← id = 1

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:  ← what it wanted
RECORD LOCKS space id 421 page no 4 n bits 72
  index PRIMARY of table `bank`.`accounts`
  trx id 48211 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: 0: len 4; hex 80000002;  ← id = 2

*** (2) TRANSACTION:                        ← the second transaction
TRANSACTION 48212, ACTIVE 2 sec starting index read
UPDATE accounts SET bal = bal + 100 WHERE id = 1   ← opposite order!

*** (2) HOLDS THE LOCK(S):
  ... hex 80000002;                         ← holds id = 2
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
  ... hex 80000001;                         ← wants id = 1

*** WE ROLL BACK TRANSACTION (2)            ← the victim

How to read it, in order

  1. Read the two queries. They are the only lines that map to your code.
  2. Decode the hex in the record locks. hex 80000001 is InnoDB's encoding of a signed INT: the high bit is flipped, so subtract 0x80000000 to get 1. That tells you which row.
  3. Compare holds versus waits. Transaction 1 holds row 1 and wants row 2. Transaction 2 holds row 2 and wants row 1. That is the cycle.
  4. Read lock_mode. locks rec but not gap is a plain record lock; locks gap before rec is a gap lock; insert intention points at chapter 55's pattern.
  5. Name the fix. Here the two transactions update the same two rows in opposite orders — the canonical case. Ordering the updates consistently by primary key eliminates it entirely.
the hex decoding trick
For a signed INT, 0x80000001 → 1, 0x80000002 → 2. For BIGINT it is 8 bytes with the same high-bit flip. Strings appear as readable ASCII. Being able to say “the deadlock was on rows 1 and 2 of accounts” rather than “there was a deadlock” is the entire difference in an incident review.
59

performance_schema.data_locks — the modern way

SHOW ENGINE INNODB STATUS shows only the latest deadlock and is hard to parse. Since 8.0 there are proper tables for live lock state.

run it
-- Every lock currently held or requested.
SELECT engine_transaction_id AS trx, object_name AS tbl,
       index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks;

-- The blocking graph: waiter → blocker.
SELECT * FROM performance_schema.data_lock_waits;

-- The one query worth saving. Full picture, ready to act on.
SELECT
  w.requesting_engine_transaction_id AS waiting_trx,
  wt.processlist_id                  AS waiting_conn,
  LEFT(wq.sql_text, 60)               AS waiting_query,
  w.blocking_engine_transaction_id   AS blocking_trx,
  bt.processlist_id                  AS blocking_conn,
  COALESCE(LEFT(bq.sql_text,60), '-- IDLE --') AS blocking_query,
  TIMESTAMPDIFF(SECOND, b_trx.trx_started, NOW()) AS blocker_age_s
FROM performance_schema.data_lock_waits w
JOIN performance_schema.threads wt
  ON wt.thread_id = w.requesting_thread_id
JOIN performance_schema.threads bt
  ON bt.thread_id = w.blocking_thread_id
LEFT JOIN performance_schema.events_statements_current wq
  ON wq.thread_id = w.requesting_thread_id
LEFT JOIN performance_schema.events_statements_current bq
  ON bq.thread_id = w.blocking_thread_id
LEFT JOIN information_schema.innodb_trx b_trx
  ON b_trx.trx_id = w.blocking_engine_transaction_id;

-- sys packages a simpler version of the same thing:
SELECT * FROM sys.innodb_lock_waits\G

-- How many deadlocks has this server had in total?
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';

-- Log every deadlock to the error log, not just the last one:
SET GLOBAL innodb_print_all_deadlocks = ON;
60

Designing deadlocks away

Retry loops are a necessary backstop, not a fix. These five patterns eliminate most deadlocks structurally.

1. Order your writes consistently

The single highest-value rule. If every transaction touches rows in ascending primary key order, a cycle cannot form.

code
-- vulnerable: order depends on the caller
UPDATE accounts SET bal = bal - ? WHERE id = @from;
UPDATE accounts SET bal = bal + ? WHERE id = @to;

-- safe: always lock the lower id first
SELECT id FROM accounts
 WHERE id IN (@from, @to)
 ORDER BY id
 FOR UPDATE;      -- both locks acquired in a defined order
-- ...then do the two updates

2. Do not read-then-insert; insert and handle the conflict

code
-- deadlock-prone (chapter 55's gap lock pattern)
SELECT id FROM t WHERE k = ? FOR UPDATE;
-- if empty: INSERT INTO t ...

-- safe: one atomic statement, no gap lock phase
INSERT INTO t (k, v) VALUES (?, ?)
  ON DUPLICATE KEY UPDATE v = VALUES(v);

3. Keep transactions short, and never span a network call

Every millisecond a lock is held is a millisecond of collision surface. The rule from chapter 45 applies here too: no HTTP calls, no queue publishes, no user input inside a transaction.

4. Index the columns you filter on when locking

An UPDATE ... WHERE unindexed_col = ? must scan, and it locks every row it examines, not only the ones it modifies. A missing index turns a one-row update into a table-wide lock. This is a frequent and invisible cause of contention.

5. Consider READ COMMITTED for high-write workloads

It removes most gap locks, which removes a whole family of deadlocks. The costs: non-repeatable reads become possible, and you must use row-based binary logging. Many high-throughput shops run READ COMMITTED deliberately for exactly this reason.

and still write the retry
Deadlocks cannot be eliminated entirely, and a deadlock rollback is safe by construction — nothing was applied. Catch error 1213 (and 1205 for lock wait timeout), retry the whole transaction two or three times with a little randomized backoff, and alert if the retry rate rises. The retry is the seatbelt; the five patterns above are driving carefully.

Parts 4 and 5 covered what you can see and what you can touch. Part 6 covers what survives: the redo log, the doublewrite buffer, and what actually happens between COMMIT returning and the data being safe on disk.