MySQL

MySQL Deadlock Analysis: Reading InnoDB Status and Writing Deadlock-Safe Code

Decode MySQL deadlock messages from SHOW ENGINE INNODB STATUS, understand lock ordering, and write application code that eliminates deadlocks permanently.

JusDB Team
November 27, 2025
9 min read
176 views

Deadlocks Are Not Random

Every MySQL deadlock has a root cause that can be found and fixed. Deadlocks occur when two transactions each hold a lock the other needs, and neither can proceed. InnoDB detects this cycle and rolls back the transaction with the least undo log — the 'victim'.

The key insight: deadlocks are almost always caused by inconsistent lock ordering or missing indexes that cause lock escalation.

Reading the Deadlock Output

text
SHOW ENGINE INNODB STATUS\G

Look for the LATEST DETECTED DEADLOCK section:

text
------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-11-27 09:15:33 0x7f8b2c001700
*** (1) TRANSACTION:
TRANSACTION 421938, ACTIVE 0 sec starting index read
MySQL thread id 142, OS thread handle 140234..., query id 8291 ...
UPDATE orders SET status='shipped' WHERE id=100
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 23 page no 4 n bits 72 index PRIMARY of table `shop`.`orders`
lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: ... id=100
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 24 page no 6 n bits 72 index PRIMARY of table `shop`.`shipments`
lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: ... order_id=100
*** (2) TRANSACTION:
TRANSACTION 421939, ACTIVE 0 sec starting index read
UPDATE shipments SET tracking='1Z...' WHERE order_id=100
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY of table `shop`.`shipments` lock_mode X locks rec but not gap
Record lock ... order_id=100
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index PRIMARY of table `shop`.`orders` lock_mode X locks rec but not gap
Record lock ... id=100
*** WE ROLL BACK TRANSACTION (2)

This tells you exactly: TX1 locked orders row 100, then tried to lock shipments row 100. TX2 locked shipments row 100, then tried to lock orders row 100. Classic lock order inversion.

The Fix: Consistent Lock Ordering

text
-- WRONG: Different code paths lock in different order
-- Path A: UPDATE orders → UPDATE shipments
-- Path B: UPDATE shipments → UPDATE orders

-- RIGHT: Always lock in the same order across all code paths
-- Rule: Always lock orders BEFORE shipments
BEGIN;
UPDATE orders SET status='shipped' WHERE id=100;
UPDATE shipments SET tracking='1Z...' WHERE order_id=100;
COMMIT;

Missing Index Deadlocks

When InnoDB can't use an index, it escalates to a gap lock or next-key lock, which can cause unexpected deadlocks:

text
-- Table without index on status
CREATE TABLE jobs (
  id BIGINT PRIMARY KEY,
  status VARCHAR(20),
  created_at DATETIME
);

-- This UPDATE scans all rows (no index on status),
-- taking next-key locks on everything in its range
UPDATE jobs SET status='done' WHERE status='pending' AND created_at < NOW();

-- Fix: add index
ALTER TABLE jobs ADD INDEX idx_status_created (status, created_at);

SELECT ... FOR UPDATE Deadlocks

text
-- Pattern that causes deadlocks in high-concurrency queues:
BEGIN;
SELECT * FROM jobs WHERE status='pending' LIMIT 1 FOR UPDATE;
-- Both TX1 and TX2 might grab the same row gap, then deadlock
UPDATE jobs SET status='running' WHERE id=?;
COMMIT;

-- Fix: use SKIP LOCKED (MySQL 8.0+)
BEGIN;
SELECT * FROM jobs WHERE status='pending' LIMIT 1 FOR UPDATE SKIP LOCKED;
UPDATE jobs SET status='running' WHERE id=?;
COMMIT;

Enable Deadlock Logging

text
[mysqld]
innodb_print_all_deadlocks = ON  -- Log ALL deadlocks (not just latest)

This writes every deadlock to the MySQL error log, allowing you to analyze patterns over time rather than only seeing the most recent one via SHOW ENGINE INNODB STATUS.

Application-Level Retry Logic

text
def execute_with_retry(conn, fn, max_retries=3):
    """Retry transaction on deadlock (MySQL error 1213)."""
    for attempt in range(max_retries):
        try:
            with conn.cursor() as cur:
                fn(cur)
            conn.commit()
            return
        except pymysql.err.OperationalError as e:
            if e.args[0] == 1213:  # ER_LOCK_DEADLOCK
                conn.rollback()
                if attempt == max_retries - 1:
                    raise
                time.sleep(0.05 * (2 ** attempt))  # Exponential backoff
            else:
                raise

Deadlock Prevention Checklist

  • Always acquire locks in the same order across all code paths
  • Keep transactions short — don't hold locks across network calls
  • Add indexes on columns used in WHERE clauses of UPDATE/DELETE
  • Use SKIP LOCKED for queue-based patterns
  • Enable innodb_print_all_deadlocks to capture history
  • Implement retry logic with exponential backoff in your application
  • Use SELECT ... FOR UPDATE only when truly necessary

Share this article

JusDB Team

Official JusDB content team