1. 什么是死锁:一场永久的相互等待
一句话总结: 死锁是「两个及以上事务各自持有一把锁,又在等待对方释放锁」的循环,任何一方都不肯先放手,最终系统被迫牺牲其中一个事务来打破僵局。
死锁不是数据库的 Bug,而是并发事务加锁顺序不一致的自然结果。它每天都在高并发系统中发生,关键是能否快速检测、自动化解、事后预防。
事务 A: 事务 B:
1. 锁定行 1 ✅ 1. 锁定行 2 ✅
2. 等待行 2 ... ⏳ 2. 等待行 1 ... ⏳
A 等 B 持有的行 2,B 等 A 持有的行 1 → 死锁!
数据库无法预测死锁,只能事后发现。发现得越早、回滚代价越小,系统抖动就越小。
2. 死锁的四大必要条件
死锁产生必须同时满足四个条件,破坏任意一个,死锁即被消除:
| 条件 | 含义 | 数据库中的表现 | 破局手段 |
|---|---|---|---|
| 互斥 | 资源同一时刻只能被一个事务占用 | 行锁、间隙锁互斥 | 无法避免(锁的本质) |
| 持有并等待 | 持有部分锁的同时等待更多锁 | 事务先锁 A 再锁 B | 一次获取全部所需锁 |
| 不可剥夺 | 已持有的锁不能被强制夺走 | InnoDB 不主动夺锁 | 缩小加锁范围、缩短持有时间 |
| 循环等待 | 等待关系形成环 | A 等 B,B 等 A | 统一加锁顺序 |
四大条件里,循环等待是唯一能被业务侧直接控制的一个。绝大多数死锁预防方案,本质上都是在打破循环等待。
3. InnoDB 锁的类型与死锁的诞生
3.1 行锁、间隙锁与临键锁
InnoDB 默认隔离级别 REPEATABLE READ 下,索引扫描不仅加行锁,还会加间隙锁(Gap Lock)与临键锁(Next-Key Lock),防止幻读。间隙锁不锁具体行,而是锁"行与行之间的空隙",这让死锁更容易在范围查询中发生。
-- 在 REPEATABLE READ 下,以下查询会给 (10,20) 区间加间隙锁
SELECT * FROM orders WHERE amount BETWEEN 10 AND 20 FOR UPDATE;
-- 另一事务想插入 amount=15 的新行 → 阻塞在间隙锁上
INSERT INTO orders (amount) VALUES (15);
间隙锁之间的加锁顺序如果与另一事务的插入路径相反,就极易形成死锁。
3.2 一条典型的死锁 SQL 序列
时刻 | 事务 T1 | 事务 T2
-----+-----------------------------+----------------------------
1 | UPDATE stock SET qty=qty-1 | UPDATE order SET status=1
| WHERE id=100; (锁行100) | WHERE id=200; (锁行200)
2 | UPDATE order SET status=1 | UPDATE stock SET qty=qty-1
| WHERE id=200; (等锁) | WHERE id=100; (等锁)
3 | 💥 DEADLOCK | 💥 DEADLOCK
两条 UPDATE 的加锁顺序相反(T1 先库存后订单、T2 先订单后库存),等待关系形成环,InnoDB 检测到后回滚代价更小的一方(通常是最少 undo 记录数的事务)。
3.3 间隙锁死锁的一个真实序列
只对已有行加锁的事务很少死锁,死锁高频区是「范围扫描 + 插入」的间隙锁。看一个真实序列:
时刻 | 事务 T1 | 事务 T2
-----+---------------------------------------+---------------------------
1 | 锁住 emp 100~200 的间隙(FOR UPDATE) | 想插入 id=150,等间隙锁
2 | 想插入 id=150,等 T2 的插入意图锁 | 锁住 emp 100~200 的间隙
3 | 双方僵持,InnoDB 判死锁 | (回滚代价小的一方)
T1 锁住间隙后要插入 150,T2 也要插入 150 并加插入意图锁,两条路径交叉在同一个间隙上——间隙锁之间、间隙锁与插入意图锁之间都会互等。这类死锁在「批量导入 + 并发查询」混合业务中特别常见。
4. InnoDB 死锁检测:等待图与自动回滚
4.1 等待图(Wait-for Graph)
InnoDB 维护一张事务等待图:每个事务是一个节点,事务 A 等待事务 B 持有的锁 就画一条 A→B 的有向边。每当一个事务加锁被阻塞时,InnoDB 会检查这张图是否存在环。
检测频率:每次加锁等待时触发
图结构: 节点 = 事务,边 = 等待关系
判环: DFS/BFS 找环路,找到即判死锁
回滚: 回滚 undo 量最小(代价最小)的事务
死锁检测不是实时的后台扫描,而是加锁被阻塞的那一瞬间触发。因此被锁等待的时长越短,死锁被发现的越快。
4.2 死锁检测开关与锁等待超时
# my.cnf
# 是否启用死锁检测。禁用后靠 innodb_lock_wait_timeout 兜底
innodb_deadlock_detect = ON
# 锁等待超时(秒),超时后自动回滚当前 SQL
innodb_lock_wait_timeout = 50
| 参数 | 默认值 | 作用 | 建议 |
|---|---|---|---|
innodb_deadlock_detect | ON | 加锁阻塞时检测死锁并回滚一方 | 保持 ON |
innodb_lock_wait_timeout | 50s | 锁等待超时兜底,防止永久阻塞 | 业务侧 5~10s |
innodb_rollback_on_timeout | OFF | 超时后是否回滚整个事务 | 通常 OFF,仅回滚当前语句 |
一句话: 死锁检测负责"主动破局",锁等待超时负责"兜底保命"。两者配合,任何锁等待最终都会被终止。
5. 锁等待监控:在死锁发生前发现问题
死锁日志只记录"已发生的死锁"。更主动的做法是实时监控锁等待,在演变为死锁或长时间阻塞前就告警。
5.1 information_schema 三张表
-- 当前正在运行的事务
SELECT * FROM information_schema.innodb_trx\G
-- 当前被加锁的行/锁等待关系
SELECT * FROM information_schema.innodb_lock_waits\G
-- 事务持有的锁
SELECT * FROM information_schema.innodb_locks\G
5.2 一条常用告警查询
-- 找出"等待别人锁超过 N 秒"的事务,往往是死锁前兆
SELECT
trx_id,
trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_age,
trx_rows_locked,
trx_rows_modified
FROM information_schema.innodb_trx
WHERE trx_state = 'LOCK WAIT'
ORDER BY trx_age DESC;
# 在 Linux 上用脚本每 30s 采样一次锁等待,超过阈值触发告警
while true; do
mysql -e "SELECT COUNT(*) FROM information_schema.innodb_lock_waits" \
| awk '$1>10 {print "LOCK_WAIT_ALERT", $1, strftime("%F %T")}'
sleep 30
done
监控的价值在于:锁等待曲线持续走高,往往意味着某个长事务没提交,正在大面积阻塞其它事务。在死锁日志出现之前,先把长事务揪出来。
5.3 用 performance_schema 定位阻塞源头
当发现锁等待时,需要进一步回答「谁锁住了谁」。performance_schema 提供了更细的锁等待链路:
-- 查看当前正在等待锁的会话及其等待的锁对象
SELECT OBJECT_NAME, LOCK_TYPE, LOCK_STATUS, SOURCE, INDEX_NAME
FROM performance_schema.data_locks;
-- 查看锁等待关系(哪个线程等哪个线程)
SELECT * FROM performance_schema.data_lock_waits\G
配合 sys 库可以一键输出阻塞会话:
-- 直接列出阻塞者与被阻塞者(含 SQL 文本)
SELECT * FROM sys.innodb_lock_waits\G
监控链路建议:锁等待数 → 具体等待对(data_lock_waits)→ 阻塞会话 SQL → 业务归属。四步走完,长事务与死锁前兆基本无处遁形。
6. 死锁日志分析:读懂 MySQL 的错误日志
6.1 如何触发与查看
死锁发生时,MySQL 把详情写入错误日志(error log):
# 把死锁信息输出到日志文件(生产可临时开启观察)
SET GLOBAL innodb_print_all_deadlocks = ON;
# 查看最近一次死锁详情
SHOW ENGINE INNODB STATUS\G
6.2 死锁日志的四个核心段落
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-09-30 10:00:01 0x7f3a0b0c1700
*** (1) TRANSACTION: ← 事务 1
TRANSACTION 348179, ACTIVE 3 sec, 2 lock struct(s)
MySQL thread id 123, OS thread handle ...
UPDATE stock SET qty=qty-1 WHERE id=100
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 5 page no 12 n bits 80 index PRIMARY of table `db`.`stock`
*** (2) TRANSACTION: ← 事务 2
TRANSACTION 348180, ACTIVE 2 sec, 2 lock struct(s)
UPDATE order SET status=1 WHERE id=200
*** (2) HOLDS THE LOCK(S): ← 事务 2 持有哪些锁
RECORD LOCKS ... of table `db`.`order`
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... of table `db`.`stock`
*** WE ROLL BACK TRANSACTION (2) ← 回滚的是事务 2
分析死锁日志的四个步骤:
- 看
WAITING FOR THIS LOCK:两个事务分别等待哪张表的哪一行。 - 看
HOLDS THE LOCK(S):各自的锁被谁持有。 - 看回滚方:
WE ROLL BACK TRANSACTION (2),确认是哪一方被牺牲。 - 反推业务路径:哪个接口先锁 A 再锁 B,另一个接口先锁 B 再锁 A。
一句话总结: 死锁日志是「事后取证」的最佳材料——把两个事务的加锁顺序排出来,死锁原因通常一目了然,剩下就是统一加锁顺序。
7. 死锁解除与预防:工程化清单
7.1 统一加锁顺序(治本)
所有访问同一批资源的事务,严格按同一顺序加锁。这是最直接、最有效的预防手段。
-- 统一约定:先锁订单表,再锁库存表(所有事务都遵守)
BEGIN;
UPDATE orders SET status=1 WHERE id=200; -- 第一步:订单
UPDATE stock SET qty=qty-1 WHERE id=100; -- 第二步:库存
COMMIT;
7.2 缩短事务时间(治标)
| 手段 | 说明 |
|---|---|
| 缩小事务范围 | 只把真正需要原子性的语句放进事务 |
| 避免事务内做 RPC/网络请求 | 持锁等待网络是死锁放大器 |
| 批量任务拆小批 | 每批 100~500 行,及时提交 |
| 减少事务内 SELECT | 查询尽量在事务外完成,事务内只做写 |
7.3 让索引与加锁更精准
□ 所有 WHERE/JOIN 列都走索引:避免锁升级为全表/大范围扫描
□ 用覆盖索引减少回表:回表行数越少,锁行越少
□ 避免大范围 UPDATE/DELETE:分批 + LIMIT
□ 同一隔离级别下,能降级就降级(READ COMMITTED 无间隙锁)
7.4 兜底与重试机制
即便预防到位,死锁仍可能在极端并发下发生。业务侧必须写死锁重试:
import time
def exec_with_deadlock_retry(sql, retries=3):
for i in range(retries):
try:
return execute(sql) # 业务执行
except DeadlockError: # 1062/1213: 死锁或唯一键冲突
time.sleep(0.05 * (i + 1)) # 递增退避
raise DeadlockError("retry exhausted")
一句话: 死锁预防做在前面(统一顺序、短事务),死锁重试兜底做在最后。两者都到位,死锁才不会成为线上事故。
7.5 死锁处理 SOP
把前面的手段收敛成一套可执行的应急流程:
| 步骤 | 动作 | 工具/命令 |
|---|---|---|
| 1 | 发现告警:死锁或锁等待告警 | 监控平台 + 慢日志 |
| 2 | 快速止血:kill 长事务/阻塞会话 | KILL <thread_id> |
| 3 | 取证:导出死锁日志与锁等待链路 | SHOW ENGINE INNODB STATUS |
| 4 | 归因:找出加锁顺序相反的接口 | 日志分析 + 代码走查 |
| 5 | 修复:统一加锁顺序 / 缩事务 / 加索引 | 发布代码 |
| 6 | 回归:压测复现原并发场景 | 压测工具 + 监控 |
| 7 | 复盘:写死锁案例库,沉淀规范 | 文档/规约 |
止血要快,归因要准,修复要稳。 线上死锁告警时,先 KILL 掉最老的那个事务恢复业务,再慢慢分析日志——不要停在线上现场长考。
8. 总结
| 环节 | 要点 |
|---|---|
| 成因 | 四大必要条件,关键是打破循环等待 |
| 检测 | InnoDB 加锁阻塞时查等待图,回滚代价小的一方 |
| 监控 | innodb_trx / innodb_lock_waits 盯锁等待曲线 |
| 日志 | SHOW ENGINE INNODB STATUS 分析 WAITING/HOLDS |
| 预防 | 统一加锁顺序、短事务、精准索引 |
| 兜底 | innodb_lock_wait_timeout + 应用层死锁重试 |
死锁本身不可消灭(它是并发锁的固有属性),但完全可以被检测、被控制、被预防。记住这条主线:统一加锁顺序治本,缩短持锁时间治标,死锁重试做兜底——三管齐下,死锁从"事故"降级为"可接受的正常噪声"。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。