04.3 - 死锁分析与排查
定位:死锁是 InnoDB 并发环境下的常见问题——理解死锁产生条件、检测机制和排查方法,是生产环境排障的关键技能 面试高频度:⭐⭐⭐⭐ 考查方式:死锁产生的四个必要条件、典型死锁场景、死锁排查命令、如何避免死锁
一、这是什么?为什么需要它?
是什么
死锁是两个或多个事务互相等待对方释放锁,导致所有事务都无法继续执行的状态。
事务 A: 持有锁(1),等待锁(2)
❌ ❌
事务 B: 持有锁(2),等待锁(1)
两个事务互相等待 → 死锁!为什么死锁不可避免?
死锁是并发系统的固有问题——只要有锁竞争,就可能发生死锁。InnoDB 的设计选择是:
- 允许死锁发生(不像某些数据库用超时机制避免)
- 自动检测死锁,并强制回滚代价最小的事务
- 应用层需要处理死锁重试
核心 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!为什么死锁?
- A 和 B 都持有 S 锁(兼容)
- A 想升 X 锁 → 需要 B 释放 S 锁 → 等待
- B 也想升 X 锁 → 需要 A 释放 S 锁 → 等待
- 互相等待 → 死锁!
解决办法:一开始就用 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锁分类**
- 行锁与间隙锁:**行锁与间隙锁**
- 事务隔离:**事务隔离级别**
- ← 返回 **锁机制索引**