MonPG Engineering avatar MonPG Engineering Engineering Team MySQL 4 min read

InnoDB Lock Escalation and Cascading Deadlocks: What Actually Happens Under Concurrency

During a high-concurrency inventory reservation event, MySQL CPU hit 100% with lock wait timeouts everywhere. Here is the deep architectural mechanics of InnoDB next-key locks, gap locks, and deadlock detection cascades.

MySQL

During a high-traffic flash sale, our MySQL 8.0 primary instance’s CPU spiked from 18% to 100% within 40 seconds. Application threads began throwing Lock wait timeout exceeded; try restarting transaction errors across all payment and checkout services.

The most puzzling aspect was that the inventory reservation table contained fewer than 50,000 rows, and every query was using the primary key or a unique index. When I ran SHOW ENGINE INNODB STATUS, the deadlock detector was analyzing over 1.2 million lock-wait relationships per second.

The database was not suffering from poor query plans or disk I/O latency. It was caught in a cascading lock contention storm caused by InnoDB’s next-key gap locking interacting with concurrent batch updates.

Record locks, Gap locks, and Next-Key locks explained

In MySQL’s InnoDB storage engine, row-level locking is implemented on index records rather than table rows. Under the default REPEATABLE READ transaction isolation level, InnoDB utilizes next-key locking to prevent phantom rows from appearing during transactions.

A next-key lock is a combination of an index record lock and a gap lock on the space immediately preceding that index record. If an index contains values 10 and 20, a next-key lock on 20 locks the value 20 AND the gap between 10 and 20.

When multiple concurrent transactions perform SELECT ... FOR UPDATE or conditional UPDATE statements that touch ranges or non-unique indexes, gap locks frequently overlap. While two transactions can hold conflicting gap locks without error, the moment either transaction tries to insert or update a row inside that gap, a lock wait is triggered.

The O(N^2) deadlock detector trap

InnoDB includes an automatic deadlock detector that inspects transaction wait-for graphs to detect circular lock dependencies. When Transaction A holds Lock 1 and waits for Lock 2, while Transaction B holds Lock 2 and waits for Lock 1, InnoDB detects the cycle and rolls back the transaction with the fewest modified rows.

This algorithm works flawlessly when concurrency is low. But when dozens or hundreds of concurrent worker threads all queue up behind overlapping row and gap locks, the wait-for graph grows exponentially. Checking the graph for cycles becomes an O(N^2) operation.

The CPU cores spend 90% of their execution time walking through thousands of lock wait queues instead of processing database writes. High concurrency transforms the deadlock detector itself into the primary cause of downtime.

-- Checking current lock waits and blocked transactions in MySQL 8.0
SELECT 
  r.trx_id AS waiting_trx_id,
  r.trx_mysql_thread_id AS waiting_thread,
  r.trx_query AS waiting_query,
  b.trx_id AS blocking_trx_id,
  b.trx_mysql_thread_id AS blocking_thread,
  b.trx_query AS blocking_query
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id;

Why switching to READ COMMITTED eliminates gap locks

One of the highest-leverage architectural adjustments you can make for high-throughput MySQL write workloads is switching the default transaction isolation level from REPEATABLE READ to READ COMMITTED.

In READ COMMITTED, InnoDB disables gap locking for search and index scans. Locks are only acquired on existing index records, not the gaps between them. If an UPDATE query scans rows that do not match the WHERE clause, InnoDB immediately releases the locks on the non-matching rows rather than holding them until transaction commit.

Requirement: Your binary log format must be set to binlog_format=ROW (which is standard in modern MySQL 8.0) to ensure statement replication consistency without gap locks.

-- Switching isolation level to eliminate gap lock contention
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
SET SESSION transaction_isolation = 'READ-COMMITTED';

-- Verify binlog format is ROW to prevent replication divergence
SHOW GLOBAL VARIABLES LIKE 'binlog_format';

Architectural patterns for high-concurrency inventory

Never use a single row for global counters or hot inventory records. If 1,000 customers try to purchase tickets from the same event row simultaneously, every thread must wait sequentially in line for an exclusive X-lock.

Architectural patterns that resolve this include: batching reservations in Redis with atomic Lua scripts, partitioning hot inventory rows across multiple bucket rows (inventory_buckets), and using conditional updates that check available quantities in a single atomic statement without long-lived transactions.

The practical standard

High-level database architecture is not about drawn boxes on an infrastructure diagram. It is about how the engine manages shared resources under concurrency — memory, latches, write-ahead logs, and lock tables. When things break at 2 AM, the fix is rarely adding another replica or throwing more CPU at the host. The fix is understanding the underlying resource bottleneck, measuring the exact wait event, and applying the architectural constraint that makes the system predictable.