Skip to content

04.3 - 死锁分析与排查

定位:死锁是 InnoDB 并发环境下的常见问题——理解死锁产生条件、检测机制和排查方法,是生产环境排障的关键技能 面试高频度:⭐⭐⭐⭐ 考查方式:死锁产生的四个必要条件、典型死锁场景、死锁排查命令、如何避免死锁


一、这是什么?为什么需要它?

是什么

死锁是两个或多个事务互相等待对方释放锁,导致所有事务都无法继续执行的状态。

事务 A: 持有锁(1),等待锁(2)
                ❌       ❌
事务 B: 持有锁(2),等待锁(1)

两个事务互相等待 → 死锁!

为什么死锁不可避免?

死锁是并发系统的固有问题——只要有锁竞争,就可能发生死锁。InnoDB 的设计选择是:

  1. 允许死锁发生(不像某些数据库用超时机制避免)
  2. 自动检测死锁,并强制回滚代价最小的事务
  3. 应用层需要处理死锁重试

核心 insight:死锁不是 bug,是并发系统的正常现象。关键不是"杜绝死锁"(不可能),而是"减少死锁 + 正确处理死锁回滚"。


二、原理拆解

2.1 死锁的四个必要条件

条件含义在数据库中的表现
互斥资源不能共享X 锁排他性
持有并等待持有锁的同时等待锁事务先 UPDATE 再加另一个 UPDATE
不可剥夺锁只能持有者主动释放只有 COMMIT/ROLLBACK 才释放锁
循环等待形成等待环A→B→C→A

破坏任意一个条件,死锁就不会发生。

2.2 典型死锁场景

场景1:AB-BA 死锁(最常见)

sql
-- 表 t: id=1, id=2 两行

-- 事务 A                        事务 B
BEGIN;                           BEGIN;
UPDATE t SET val=100 WHERE id=1;  -- 锁 id=1
                                 UPDATE t SET val=200 WHERE id=2;  -- 锁 id=2
UPDATE t SET val=300 WHERE id=2;  -- 等待 B 释放 id=2
                                 UPDATE t SET val=400 WHERE id=1;  -- 等待 A 释放 id=1
                                 → DEADLOCK!

图解

            持有 id=1 ← 等待 id=2
事务 A:     ┌────┐     ┌────┐
            │持有│ ←──→│等待│
            └────┘     └────┘
                ↑         ↓
            ┌────┐     ┌────┐
事务 B:     │等待│ ←──→│持有│
            └────┘     └────┘
            等待 id=1 ← 持有 id=2

场景2:S 锁升级 X 锁死锁

sql
-- 锁升级死锁
-- 事务 A                             事务 B
BEGIN;                                BEGIN;
SELECT * FROM t WHERE id=1 FOR SHARE; -- S 锁
                                      SELECT * FROM t WHERE id=1 FOR SHARE; -- S 锁(兼容)
                                      -- A 和 B 都持有 S 锁
UPDATE t SET val=100 WHERE id=1;      -- 尝试升级为 X 锁
                                      -- ❌ 被 B 的 S 锁阻塞
                                      UPDATE t SET val=200 WHERE id=1;
                                      -- ❌ 被 A 的 S 锁阻塞
                                      → DEADLOCK!

为什么死锁?

  1. A 和 B 都持有 S 锁(兼容)
  2. A 想升 X 锁 → 需要 B 释放 S 锁 → 等待
  3. B 也想升 X 锁 → 需要 A 释放 S 锁 → 等待
  4. 互相等待 → 死锁!

解决办法:一开始就用 SELECT ... FOR UPDATE(直接加 X 锁),而不是先 S 锁再升级。

场景3:间隙锁死锁

sql
-- 间隙锁死锁(RR 隔离级别下)
-- 数据: id=1, id=10

-- 事务 A                             事务 B
BEGIN;                                BEGIN;
SELECT * FROM t WHERE id=5 FOR UPDATE;
-- 加了间隙锁 (1, 10)
                                      SELECT * FROM t WHERE id=8 FOR UPDATE;
                                      -- 也加了间隙锁 (1, 10)
                                      -- 间隙锁之间是兼容的!
INSERT INTO t (id) VALUES (7);        INSERT INTO t (id) VALUES (9);
-- ❌ 等待 B 的间隙锁                   -- ❌ 等待 A 的间隙锁
→ DEADLOCK!

为什么间隙锁会死锁? 间隙锁不是互斥的——两个事务可以在同一个间隙上加 Gap Lock。但当它们都试图插入时,就互相阻塞了。

2.3 InnoDB 死锁检测机制

事务开始执行

每个事务请求锁时

┌──────────────────────────────┐
│ waits-for graph (等待图)      │
│ 检测是否存在循环等待           │
│                              │
│  事务 A → 等待 id=1 的 X 锁   │
│  事务 B → 等待 id=2 的 X 锁   │
│  事务 C → 等待 id=3 的 X 锁   │
│                              │
│  InnoDB 维护一个"等待图"       │
│  记录:谁在等谁的什么锁         │
│  每次加锁时检查是否有环         │
└──────────────┬───────────────┘

     ┌─────────┴─────────┐
     ▼                   ▼
   有环                 无环
    │                    │
    ▼                    ▼
 选择代价最小的事务     正常执行
 回滚(释放其所有锁)


 返回错误:
 ERROR 1213 (40001): Deadlock found
 when trying to get lock;
 try restarting transaction

死锁检测的开销:事务越多,等待图越大,检测越慢。高并发场景(如每秒上千事务)下,死锁检测本身可能成为瓶颈。

优化innodb_deadlock_detect = OFF 可以关闭死锁检测。此时依赖 innodb_lock_wait_timeout(默认 50s)超时回滚。但通常不建议关闭,除非确定不会死锁或能接受 50 秒超时。

2.4 避免死锁的实践策略

策略方法原理
固定顺序访问所有事务按相同顺序操作资源破坏循环等待条件
缩短事务事务尽量短小,快速提交减少持有锁的时间
减少锁范围用 RC 代替 RR(不加间隙锁)减少锁竞争
直接加 X 锁用 FOR UPDATE 而不是 LOCK IN SHARE MODE避免 S→X 升级死锁
合理索引让 WHERE 条件精确命中索引减少锁定行数
重试机制应用层捕获 1213 错误并重试InnoDB 自带的处理方式

三、图解全景

             死锁检测与处理流程

事务 A 加锁请求 → 等待图检测

                 ┌────┴────┐
                 │ 有环吗? │
                 └────┬────┘

            ┌─────────┴─────────┐
            ▼                   ▼
      有死锁                 无死锁
            │                   │
            ▼                   ▼
  ┌─────────────────────┐  ┌────────────┐
  │ 选择回滚代价最小的事务 │  │ 正常加锁    │
  │                     │  │ 继续执行    │
  │ 回滚代价 =           │  └────────────┘
  │  修改的行数           │
  │  + Undo Log 大小    │
  │  + 已执行时间         │
  └──────────┬──────────┘


  ┌─────────────────────┐
  │ 被选中的事务回滚      │
  │ 释放所有锁           │
  │ 返回 1213 错误       │
  └─────────────────────┘


  ┌─────────────────────┐
  │ 另一个事务继续执行    │
  │ 获取锁 → 提交成功    │
  └─────────────────────┘

四、实战验证

制造一个简单的死锁

sql
-- 准备
CREATE TABLE test_deadlock (
  id INT PRIMARY KEY,
  val INT
);
INSERT INTO test_deadlock VALUES (1, 100), (2, 200);

-- 会话 A(事务 1)
BEGIN;
UPDATE test_deadlock SET val = 111 WHERE id = 1;  -- 锁 id=1

-- 会话 B(事务 2)
BEGIN;
UPDATE test_deadlock SET val = 222 WHERE id = 2;  -- 锁 id=2

-- 会话 A
UPDATE test_deadlock SET val = 333 WHERE id = 2;  -- 等待 B 释放 id=2

-- 会话 B
UPDATE test_deadlock SET val = 444 WHERE id = 1;
-- ⚠️ DEADLOCK!
-- InnoDB 检测到死锁,回滚其中一个事务
-- 返回: ERROR 1213 (40001): Deadlock found when trying to get lock

查看死锁日志

sql
-- 查看最近一次死锁的详细信息
SHOW ENGINE INNODB STATUS\G

-- 重点关注 LATEST DETECTED DEADLOCK 部分:
-- ------------------------
-- LATEST DETECTED DEADLOCK
-- ------------------------
-- 2024-01-15 10:30:25 0x7f...
-- *** (1) TRANSACTION:           ← 事务 1
-- TRANSACTION 12345, ACTIVE 5 sec
-- MySQL thread id 8, OS thread handle ...
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
--   Record lock, ... waiting for lock mode X   ← 等什么锁
-- *** (2) TRANSACTION:           ← 事务 2
-- TRANSACTION 12346, ACTIVE 3 sec
-- *** (2) HOLDS THE LOCK(S):        ← 持有这个锁
-- *** (2) WAITING FOR THIS LOCK TO BE GRANTED:
-- *** WE ROLL BACK TRANSACTION (1) ← 回滚了谁

启用死锁日志

sql
-- 查看当前死锁日志配置
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';

-- 开启记录所有死锁(而不是只记录最近一次)
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 所有死锁会记录到 MySQL 错误日志

应用层重试示例(Java Pseudocode)

java
public void transferMoney(int from, int to, int amount) {
    int maxRetries = 3;
    for (int i = 0; i < maxRetries; i++) {
        try {
            // 按固定顺序获取锁(避免死锁)
            // 总是先处理 id 小的行
            int first = Math.min(from, to);
            int second = Math.max(from, to);
            
            connection.beginTransaction();
            updateBalance(first, -amount);   // 先锁 first
            updateBalance(second, amount);   // 再锁 second
            connection.commit();
            return;  // 成功
        } catch (DeadlockException e) {
            connection.rollback();
            if (i == maxRetries - 1) {
                throw e;  // 重试耗尽,仍失败
            }
            // 等待随机时间后重试
            Thread.sleep(random.nextInt(100));
        }
    }
}

五、面试视角

追问答案要点
死锁的四个必要条件?互斥、持有并等待、不可剥夺、循环等待
InnoDB 如何检测死锁?waits-for graph 检测循环等待,每次加锁请求时检查
死锁时 InnoDB 怎么处理?回滚代价最小(修改行数最少、Undo 最小)的事务
如何查看死锁信息?SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK
怎么避免死锁?固定访问顺序、缩短事务、RC 代替 RR、合理索引、应用层重试
间隙锁会导致死锁吗?会。两个事务在同一个间隙加 Gap Lock(兼容),但都试图插入时互相等待
innodb_deadlock_detect = OFF 什么后果?依赖超时(50s),超时后回滚,适合确认不会死锁的高并发场景

📚 相关链接

  • 锁分类:**InnoDB锁分类**
  • 行锁与间隙锁:**行锁与间隙锁**
  • 事务隔离:**事务隔离级别**
  • ← 返回 **锁机制索引**

Knowledge4J — Java 知识库