【MySQL百日打怪升级第22天】事务死锁的成因与避免 —— 死锁检测、锁等待超时

简介: 【MySQL死锁精讲】第22天干货:剖析死锁成因(循环加锁)、检测机制(等待图算法)、四大场景(转账/外键/Gap Lock等)及实战避坑方案——统一加锁顺序、缩短事务、合理选隔离级别,附AI诊断工具箱。

【第22天】每天一个MySQL知识点,百日打怪升级

事务死锁的成因与避免 —— 死锁检测、锁等待超时


大家好,我是一名拥有10年以上经验的DBA老兵(没有那多)。

做这个系列,源于一个朴素的愿望:把踩过的坑、总结的经验系统化输出,希望能帮到刚入行或想进阶的兄弟们。

让我们开始今天的第22天内容。


昨天聊了 MVCC,有人问了个很实操的问题:

"线上突然出现 Deadlock found when trying to get lock 是什么情况?怎么处理?"

这个问题问得好。死锁是 MySQL 进阶路上绕不过去的坎。从 DBA 的角度看——死锁不是 bug,如果事务加锁顺序不一致,并发越高死锁概率越大。区别在于:有人能在几秒内定位并解决,有人却说要重启数据库😂。

死锁的官方定义很绕:两个或多个事务互相等待对方释放锁,导致所有事务都无法继续。 说白了就是——事务 A 锁住了行 1 等行 2,事务 B 锁住了行 2 等行 1,两边都不让。

今天就把这东西彻底讲清楚。


死锁的四个必要条件

先看一眼理论。死锁要发生,必须同时满足四个条件,缺一不可:

条件 含义 MySQL 场景
互斥 一个资源同时只能被一个事务占用 行锁本身就是互斥的
持有并等待 事务占着一个资源不释放,又去请求另一个资源 先 UPDATE 行 1,再 UPDATE 行 2
不可剥夺 已经获得的资源不能被强行拿走 行锁只能等事务主动提交或回滚释放
循环等待 两个事务互相等着对方释放资源 A 等 B,B 等 A

理论归理论,工作中你不需要背这四个条件。你只需要记住:当两个事务以不同的顺序锁住相同的资源时,死锁就容易发生。

看一个最经典的例子:

-- 事务 A:
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- 锁住 id=1
-- 等事务 B 释放 id=2...
UPDATE account SET balance = balance + 100 WHERE id = 2;

-- 事务 B:
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2;   -- 锁住 id=2
-- 等事务 A 释放 id=1...
UPDATE account SET balance = balance + 50 WHERE id = 1;

这就是死锁。 两个转账事务都锁住了自己更新的第一行,然后等对方释放第二行。MySQL 检测到死锁后,会选择一个"代价最小"的事务作为受害者,回滚它,让另一个事务继续。


📝 面试解答

Q: 死锁和锁等待是一回事吗?

不是。锁等待是事务 B 等事务 A 释放锁,A 提交后 B 就能继续——最终能结束。报错信息是:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

死锁是 A 等 B、B 等 A,没人会主动释放。MySQL 必须强行中止其中一个事务才能打破这个循环。报错信息是:

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

两个报错的区别很明显:1205 是等超时了,1213 是检测到死锁了。

Q: MySQL 怎么选择"受害者"?

InnoDB 会计算每个事务已经修改的行数(Undo 量),选择修改行数最少的事务回滚。如果修改行数相同,则选择后加锁的事务回滚。注意这个选择不是"最不重要的事务",而是"回滚代价最小的事务"。

Q: 死锁一定等超时才会处理吗?

不一定。InnoDB 默认开启死锁检测(innodb_deadlock_detect=ON),通过等待图(wait-for graph)算法实时检测循环等待。检测到死锁后立即回滚一个事务,不需要等到 innodb_lock_wait_timeout 超时。只有死锁检测关闭时,才需要等到 50 秒超时(默认值)才能解开。


InnoDB 的死锁检测机制

InnoDB 内部维护了一个等待图(wait-for graph),节点是事务,边是"一个事务在等另一个事务释放锁"。每当事务遇到锁等待时,InnoDB 就往这个图里加一条边,然后检查图里有没有环——有环就是死锁。

这个检测不是轮询的,而是在每个锁等待事件发生时立即触发。所以死锁的响应速度非常快——实际发生和检测到之间的时间差通常在微秒级。

但问题来了:如果并发特别高(几千个事务同时争抢同一行),等待图会变得非常大,每次锁等待都要做一轮图检测,CPU 消耗会急剧上升。

MySQL 5.7 之前这个问题很明显——高并发场景下,死锁检测本身就成了性能瓶颈。MySQL 8.0 做了优化(主要是等待图算法的局部优化),但如果你的业务场景是"每秒几千个事务更新同一行",死锁检测的 CPU 开销还是不容忽视。

解决方案

  • 如果确认业务逻辑不会产生死锁(比如所有事务都以相同的顺序访问行),可以关闭死锁检测:innodb_deadlock_detect=OFF。这样数据库不再做图检测,锁等待就靠 innodb_lock_wait_timeout 超时(默认50秒)来硬扛
  • 关闭死锁检测后,数据库不再主动检测死锁,锁等待事务需等到 innodb_lock_wait_timeout 超时后才报错回滚,间接打破死锁循环。所以只适合你确信"不会产生死锁"的场景。

常见死锁场景

场景一:转账类(不同加锁顺序)

上面那个转账例子就是典型。解决方案最简单:所有事务都按同一个顺序加锁。

-- 不管转出还是转入,都先锁 id 小的行,再锁 id 大的行
-- 比如统一按 id 升序加锁:
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;

这样就不会出现"A 锁 1 等 2,B 锁 2 等 1"的循环。

场景二:外键与主键的隐式锁

这是一个我踩过的坑。假设有父子表:

CREATE TABLE parent (
    id INT PRIMARY KEY
);

CREATE TABLE child (
    id INT PRIMARY KEY,
    parent_id INT,
    FOREIGN KEY (parent_id) REFERENCES parent(id)
);

在 RR 级别下,对 child 表的 INSERTUPDATE 会触发外键检查——需要检查 parent_id 在 parent 表中是否存在。这个检查会对 parent 表的对应记录加锁。

事务 A:INSERT INTO child VALUES (1, 100);  -- 对 parent.id=100 加 S 锁
事务 B:DELETE FROM parent WHERE id = 100;  -- 需要 X 锁,但被 A 的 S 锁阻塞
事务 A:继续执行其他操作...(还没提交)

如果事务 A 再对 parent 表做操作,就可能和事务 B 形成死锁。隐式锁最难排查——你盯着自己的 SQL 看半天,根本没写过 LOCK 关键字。

场景三:Gap Lock 导致的死锁

RR 级别下,Gap Lock 会把死锁的可能性放大。经典的构造如下:

-- 表:t(id INT PRIMARY KEY)
-- 数据:id=1, id=10

-- 事务 A:
BEGIN;
SELECT * FROM t WHERE id = 5 FOR UPDATE;  -- 在(1,10)加 Gap Lock(id=5 不存在)

-- 事务 B:
BEGIN;
SELECT * FROM t WHERE id = 6 FOR UPDATE;  -- 也在(1,10)加 Gap Lock,不冲突

注意这里 SELECT ... FOR UPDATE 加的是排他的 Next-Key Lock,但它内部的 Gap Lock 组件之间不互斥——间隙锁的目的是阻止插入,而非排斥其他间隙锁持有者。所以两个事务都能获取到 Gap Lock,此时一切正常,谁都不等谁。

然后矛盾在 INSERT 时爆发:

-- 事务 A 继续:
INSERT INTO t VALUES (6);  -- 需要插入意向锁,等待 B 的 Gap Lock

-- 事务 B 继续:
INSERT INTO t VALUES (5);  -- 也需要插入意向锁,等待 A 的 Gap Lock

插入意向锁(insert intention lock) 是 InnoDB 的一种特殊锁,在 INSERT 真正执行前获取。它和 Gap Lock 冲突——如果有其他事务持有同一间隙的 Gap Lock,INSERT 必须等它释放。这样就形成了"A 等 B 的 Gap Lock,B 等 A 的 Gap Lock"的死锁。

RR 级别下用 SELECT ... FOR UPDATE 做"是否存在"判断时尤其容易踩这个坑。


死锁的定位与诊断

线上出现死锁了,第一步可千万别重启数据库,也完全不需要,而是查死锁日志

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

重点关注输出中的 LATEST DETECTED DEADLOCK 章节。它会告诉你这几件事:

信息 含义
事务 A(WAITING FOR THIS LOCK TO BE GRANTED) A 在等什么锁
事务 A HOLDS THE LOCK A 持有什么锁
事务 B(WAITING FOR THIS LOCK TO BE GRANTED) B 在等什么锁
事务 B HOLDS THE LOCK B 持有什么锁
WE ROLL BACK TRANSACTION (2) 谁被回滚了(这里回滚了事务 2)
WAITING FOR THIS LOCK TO BE GRANTED 具体等的是哪行、什么类型的锁

SHOW ENGINE INNODB STATUS 有个问题——它只保留最近一次死锁的信息。 如果死锁频率高,历史记录会被覆盖。

更靠谱的持久化方案:开启死锁日志到 MySQL 错误日志。

# my.cnf
innodb_print_all_deadlocks = 1

开启后,每次死锁都会完整记录到 MySQL 错误日志(一般是主机上的 error.log),不会丢失。生产环境建议默认启用,性能影响极小。

此外,MySQL 8.0 的 performance_schemaevents_transactions_current 表也能辅助定位死锁,但最实时的办法还是三个:

  1. SHOW ENGINE INNODB STATUS — 看最近的死锁详情
  2. innodb_print_all_deadlocks=ON — 每条死锁都记日志
  3. SELECT * FROM performance_schema.data_lock_waits — 看当前谁在等锁(如果死锁还没被检测到)

死锁避免的最佳实践

死锁不可能完全消灭,但可以大幅降低发生频率。以下几条是经过生产验证的:

1. 统一加锁顺序

不管业务逻辑多复杂,所有事务都以相同的顺序访问表和行。这是最有效的死锁预防手段。

-- 坏:不固定顺序
IF a.id < b.id THEN
    UPDATE a ...; UPDATE b ...;
ELSE
    UPDATE b ...; UPDATE a ...;
END IF;

-- 好:按 id 升序
UPDATE a ...; -- 先小的
UPDATE b ...; -- 再大的

2. 减少事务持有锁的时间

事务越小、越快提交,死锁窗口越窄:

  • 大事务拆成小事务
  • 不要在事务中做耗时的外部调用(RPC、HTTP 请求、等待用户输入)
  • 大批量更新拆成多个批次

3. 选择合适的隔离级别

如果业务允许,用 RC 代替 RR。RC 下普通 DML 不再加 Gap Lock,死锁概率大幅降低(外键和唯一键检查仍可能触发 Gap Lock)。MySQL 8.0 的默认级别还是 RR,但很多互联网公司线上跑的是 RC,原因之一就是死锁更少。

4. 关键表加索引

无索引的 UPDATE 会锁住所有行(第20天的内容),这等于把锁竞争的范围从"几行"放大到"整张表"。死锁概率指数级上升。

5. 真正高并发的场景考虑用 Redis

MySQL 里的版本号乐观锁虽然能避免显式加锁,但本质上还是在用 MySQL 的行锁做更新——只是把"先查再改"变成了"条件更新+重试"。高并发下冲突率高了,重试次数飙升,MySQL 的 CPU 和行锁竞争压力反而更大。

对于真正的高并发原子操作(秒杀扣库存、账户余额变更),更好的做法是把工具换掉——Redis

Redis 单线程执行命令,天然没有锁竞争的问题:

# 扣库存:Redis单线程原子操作
DECR stock:10001

# 读取余额然后扣减:事务保证原子性
WATCH account:1:balance
balance = GET account:1:balance
if balance >= 100
    MULTI
    DECRBY account:1:balance 100
    EXEC

Redis 的原子操作、Lua 脚本、加上 WATCH/MULTI/EXEC 的事务机制,在高并发下比 MySQL 的乐观锁重试高效得多。而 MySQL 更适合保证最终一致性——比如用 Redis 扣完库存后,异步写回 MySQL 作为可靠落盘。

不是所有锤子都能解决所有钉子。高并发原子操作 → 用 Redis;数据可靠性和复杂查询 → 用 MySQL。


🤖 AI实战工具箱:让AI帮你造死锁测试

死锁是最难手工复现的问题之一,但 AI 最擅长生成"对称时序"的脚本。

场景一:经典循环等待死锁

把下面这段粘给 AI:

帮我写一组 MySQL 测试 SQL,演示两个事务互相等待的死锁场景。建两张表(account_a 和 account_b,各有一条记录 id=1, balance=100)。终端 A 先 UPDATE account_a 再 SLEEP,然后 UPDATE account_b;终端 B 先 UPDATE account_b 再 SLEEP,然后 UPDATE account_a。用 SLEEP 精确控制时序,让两个事务形成循环等待。最后用 SHOW ENGINE INNODB STATUS 查看死锁信息,代码中标注谁被回滚了。

场景二:Gap Lock 导致的死锁

帮我写一组 MySQL 测试 SQL(RR 隔离级别),演示 Gap Lock 导致的死锁。建表 t(id INT PRIMARY KEY),已有 id=1 和 id=10。
终端 A:SELECT * FROM t WHERE id=5 FOR UPDATE; —— 锁定间隙 (1,10),获取 Gap Lock。
终端 B:SELECT * FROM t WHERE id=6 FOR UPDATE; —— 也锁定同一间隙,Gap Lock 不互斥,不阻塞。
然后 A 执行 INSERT INTO t VALUES (6);,B 执行 INSERT INTO t VALUES (5);。观察死锁是否发生,最后用 SHOW ENGINE INNODB STATUS 查看。注释每一步说明插入意向锁与 Gap Lock 的冲突。

场景三:分析自己的死锁日志

下面是我数据库的 SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK 输出,帮我分析:1. 哪两个事务在死锁?2. 分别持有和等待什么锁?3. 谁被回滚了?4. 如何修改代码避免这个死锁?[粘贴你的日志]

第三个场景最实用——线上抓到死锁日志,贴给 AI,几秒钟就能把锁的竞争关系拆清楚。


思考题

🤔 互动时间:

  1. 如果两个事务都 UPDATE 同一行,会发生死锁吗?
  2. 为什么把隔离级别从 RR 改成 RC 能减少死锁?RC 下还有 Gap Lock 吗?
  3. innodb_deadlock_detect=OFF 有什么风险?什么场景下才适合关闭?

总结

🎯 面试考点

  • 死锁的本质:两个及以上事务循环等待对方释放锁,InnoDB 通过等待图检测
  • 四个必要条件:互斥、持有并等待、不可剥夺、循环等待(理论只需了解,实操看循环等待)
  • 最有效的预防:所有事务统一加锁顺序,这是最简单且最有效的方法
  • 各隔离级别的影响:RR 有 Gap Lock → 死锁概率高;RC 无 Gap Lock → 死锁概率低
  • 死锁诊断SHOW ENGINE INNODB STATUS(最近一次)+ innodb_print_all_deadlocks=ON(持久化)
  • InnoDB 的选择:回滚 Undo 量最小的事务,而不是"最不重要的那个"
  • 死锁 vs 锁等待:死锁立即检测回滚;锁等待靠超时释放

下期预告:慢查询日志分析 —— 面试必问!

全本合集《每天一个MySQL知识点,百日打怪升级》


有问题欢迎评论区交流,明天见!

相关文章
|
9天前
|
Shell API 开发工具
Claude Code 快速上手指南(新手友好版)
AI编程工具卷疯啦!Claude Code凭借任务驱动+终端原生的特性,成了开发者的效率搭子。本文从安装、登录、切换国产模型到常用命令,手把手带新手快速上手,全程避坑,30分钟独立用起来。
2837 17
|
6天前
|
人工智能 开发工具 iOS开发
Claude Code 新手完全上手指南:安装、国产模型配置与常用命令全解
Claude Code 是一款运行在终端环境中的 AI 编程助手,能够直接在命令行中完成代码生成、项目分析、文件修改、命令执行、Git 管理等开发全流程工作。它最大的特点是**任务驱动、终端原生、轻量高效、多模型兼容**,无需图形界面、不依赖 IDE 插件,能够深度融入开发者日常工作流。
2450 5
|
21天前
|
人工智能 JSON 供应链
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
LucianaiB分享零成本畅用JVS Claw教程(学生认证享7个月使用权),并开源GeoMind项目——将JVS改造为科研与产业地理情报可视化AI助手,支持飞书文档解析、地理编码与腾讯地图可视化,助力产业关系图谱构建。
23561 14
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
|
2天前
|
人工智能 Linux BI
国内用 Claude Code 终于不用翻墙了:一行命令搞定,自动接 DeepSeek
JeecgBoot AI专题研究 一键脚本:Claude Code + JeecgBoot Skills + DeepSeek 全平台接入 一行命令装好 Claude Code + JeecgBoot Skills + DeepSeek 接入,无需翻墙使用 Claude Code,支持 Wind
1435 1
国内用 Claude Code 终于不用翻墙了:一行命令搞定,自动接 DeepSeek
|
8天前
|
人工智能 JSON BI
DeepSeek V4-Pro 接入 Claude Code 完全实战:体验、测试与关键避坑指南
Claude Code 作为当前主流的 AI 编程辅助工具,凭借强大的代码理解、工程执行与自动化能力深受开发者喜爱,但原生模型的使用成本相对较高。为了在保持能力的同时进一步降低开销,不少开发者开始寻找兼容度高、价格更友好的替代模型。DeepSeek V4 系列的发布带来了新的选择,该系列包含 V4-Pro 与 V4-Flash 两款模型,并提供了与 Anthropic 完全兼容的 API 接口,理论上只需简单修改配置,即可让 Claude Code 无缝切换为 DeepSeek 引擎。
2131 2
|
7天前
|
人工智能 安全 开发工具
Claude Code 官方工作原理与使用指南
Claude Code 不是传统代码补全工具,而是 Anthropic 推出的终端 AI 代理,具备代理循环、双驱动架构(模型+工具)、全局项目感知、6 种权限模式等核心能力,本文基于官方文档系统解析其工作原理与高效使用技巧。
1152 0
|
15天前
|
人工智能 缓存 Shell
Claude Code 全攻略:命令大全 + 实战工作流(完整版)
Claude Code 是一款运行在终端环境下的 AI 编码助手,能够直接在项目目录中理解代码结构、编辑文件、执行命令、执行开发计划,并支持持久化记忆、上下文压缩、后台任务、多模型切换等专业能力。对于日常开发、项目维护、快速重构、代码审查等场景,它可以大幅减少手动操作、提升编码效率。本文从常用命令、界面模式、核心指令、记忆机制、图片处理、进阶工作流等维度完整说明,帮助开发者快速上手并稳定使用。
3516 6

热门文章

最新文章