数据库死锁检测与处理实战

从死锁成因与四大必要条件出发,详解 InnoDB 死锁检测机制、锁等待监控、死锁日志分析方法,并给出解除与预防死锁的工程化清单。

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_detectON加锁阻塞时检测死锁并回滚一方保持 ON
innodb_lock_wait_timeout50s锁等待超时兜底,防止永久阻塞业务侧 5~10s
innodb_rollback_on_timeoutOFF超时后是否回滚整个事务通常 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

分析死锁日志的四个步骤:

  1. 看 WAITING FOR THIS LOCK:两个事务分别等待哪张表的哪一行。
  2. 看 HOLDS THE LOCK(S):各自的锁被谁持有。
  3. 看回滚方:WE ROLL BACK TRANSACTION (2),确认是哪一方被牺牲。
  4. 反推业务路径:哪个接口先锁 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 + 应用层死锁重试

死锁本身不可消灭(它是并发锁的固有属性),但完全可以被检测、被控制、被预防。记住这条主线:统一加锁顺序治本,缩短持锁时间治标,死锁重试做兜底——三管齐下,死锁从"事故"降级为"可接受的正常噪声"。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. Supabase 平台与 PostgreSQL 边缘函数实践
  2. PostgreSQL 事件触发器与审计日志实现
  3. Kubernetes 上 PostgreSQL 运维与 CloudNativePG 实战