前言
死锁不是"偶发事故",而是设计问题
在实际业务中,很多同学第一次接触 MySQL 死锁,往往是在日志中看到一句:
Deadlock found when trying to get lock; try restarting transaction
然后的反应通常是:
- "是不是 MySQL 的 bug?"
- "加索引能不能解决?"
- "把事务拆小一点行不行?"
事实上,死锁不是 MySQL 的 bug,而是 InnoDB 锁设计 + 业务访问模式共同作用的必然结果。
本文将从一个非常真实的业务场景出发,深入剖析:
- MySQL 在什么情况下会产生死锁
- InnoDB 各类锁是如何协同工作的
- 为什么
SELECT ... FOR UPDATE + INSERT特别容易死锁- 幻读与 Next-Key Lock 在其中扮演了什么角色
- Online DDL 到底解决了什么问题(以及没解决什么)
一、一个非常典型的业务场景
场景描述:资源唯一性校验
假设有一张表 resource,业务语义是:某个资源只能被创建一次。
sql
CREATE TABLE resource (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
resource_key VARCHAR(64) NOT NULL,
UNIQUE KEY uk_resource_key (resource_key)
) ENGINE=InnoDB;
常见的业务代码逻辑是:
sql
BEGIN;
SELECT * FROM resource
WHERE resource_key = 'A'
FOR UPDATE;
-- 如果不存在
INSERT INTO resource(resource_key) VALUES ('A');
COMMIT;
乍一看,这段逻辑非常严谨:
- 使用事务
- 使用
SELECT FOR UPDATE防止并发插入 - 有唯一索引兜底
但在高并发下,这正是死锁高发现场。
二、InnoDB 锁体系总览(先有全局认知)
在分析死锁之前,我们必须先明确 InnoDB 中到底有哪些锁。
记录锁(Record Lock)
-
S 型记录锁(共享锁)
-
多个事务可以同时持有
-
用于普通
SELECT
-
-
X 型记录锁(排他锁)
-
同一条记录只能有一个事务持有
-
用于
UPDATE / DELETE / SELECT FOR UPDATE
-
记录锁只锁已经存在的记录
间隙锁(Gap Lock)
- 锁定的是索引记录之间的"区间"
- 不锁具体记录,只锁"范围"
- 用来防止 幻读
例如索引中存在:
sql
(10) ------ (20)
Gap Lock 可以锁住 (10, 20) 这个区间。
Next-Key Lock(临键锁)
InnoDB 中最重要、也是最容易导致死锁的锁
- = 记录锁 + 间隙锁
- 锁定区间:
(prev_key, current_key] - 是 InnoDB RR(可重复读)隔离级别的默认行为
SELECT ... FOR UPDATE
在 索引范围查询 下,几乎一定会触发 Next-Key Lock。
插入意向锁(Insert Intention Lock)
- 一种 特殊的间隙锁
- 是 S 型锁
- 用于表示:
"我想在这个 gap 里插入一条记录"
重点:
插入意向锁与 Gap Lock / Next-Key Lock 是冲突的
意向锁(Intention Lock)
- 表级锁(IS / IX)
- 表示事务"将要"在某些记录上加锁
- 用于提高锁冲突判断效率
- 不直接参与死锁,但几乎所有行锁都会先加意向锁
隐式锁 & 显式锁
-
隐式锁 :
插入时,事务尚未真正加行锁,但通过事务 ID 隐含保护
-
显式锁 :
当其他事务需要访问这条记录时,隐式锁会升级为显式锁
隐式锁升级的过程,是很多死锁的导火索
元数据锁(MDL)
- 控制表结构的并发访问
- DDL / DML 之间的互斥
- 是 Online DDL 要重点解决的问题(后文详述)
三、死锁是如何一步一步发生的?
下面我们用 事务 A / 事务 B 复盘整个死锁过程。
第一步:两个事务同时进入
sql
事务 A 事务 B
BEGIN; BEGIN;
第二步:SELECT FOR UPDATE
sql
SELECT * FROM resource
WHERE resource_key = 'A'
FOR UPDATE;
假设此时表中 还没有 resource_key = 'A' 的记录。
会发生什么?
- InnoDB 在 唯一索引
uk_resource_key上 - 对
A所在的索引区间加上 X 型 Next-Key Lock - 锁住的是一个"未来可能插入 A 的区间"
结果是:
sql
事务 A:持有 Next-Key Lock(X)
事务 B:也尝试加同一个 Next-Key Lock(X)
注意:
Next-Key Lock 是互斥的
所以:
- 事务 A 成功
- 事务 B 被阻塞,等待 A 释放锁
第三步:事务 A 执行 INSERT
sql
INSERT INTO resource(resource_key) VALUES ('A');
此时发生了一个非常关键的事情:
插入位置落在 已被 Next-Key Lock 锁住的 gap
InnoDB 会尝试:
-
将插入操作的 隐式锁
-
转换为 显式锁
同时需要获取一个 插入意向锁(S 型)
第四步:锁冲突出现
关键冲突点来了:
- 插入意向锁(S)
- 与事务 B 持有的 Next-Key Lock(X) 冲突
于是:
sql
事务 A:等待 B 释放 Next-Key Lock
事务 B:等待 A 释放 Next-Key Lock
循环等待成立,死锁产生
InnoDB 如何处理?
- InnoDB 会通过 死锁检测线程
- 选择 代价较小的事务回滚
- 抛出死锁异常
四、这类死锁的本质原因是什么?
总结一句话:
Next-Key Lock + 插入意向锁 + 并发存在性检查 = 死锁温床
更具体地说:
SELECT FOR UPDATE在 RR 下锁的是"范围",不是"记录"- 间隙锁之间是兼容的
- 插入时需要插入意向锁
- 插入意向锁与 Next-Key Lock 冲突
- 多事务互相等待,形成环
五、幻读与 Next-Key Lock 的关系
什么是幻读?
sql
事务 A:第一次查询没有记录
事务 B:插入一条新记录
事务 A:第二次查询发现"多了一条"
这就是幻读。
InnoDB 如何解决幻读?
- RR 隔离级别下
- 使用 Next-Key Lock
- 锁住"可能出现新记录的范围"
解决幻读的代价,就是更复杂的锁冲突模型
六、Online DDL 解决了什么问题?
传统 DDL 的问题
在早期 MySQL 版本中:
sql
ALTER TABLE resource ADD COLUMN ext VARCHAR(64);
会:
-
持有 排他 MDL 锁
-
阻塞所有:
-
SELECT
-
INSERT
-
UPDATE
-
DELETE
对线上业务是 灾难性的
Online DDL 的核心目标
Online DDL 的目标不是"无锁",而是:
最小化 DDL 对 DML 的影响
Online DDL 做了哪些事情?
MDL 锁拆分
- 大部分时间使用共享 MDL
- 只在最终切换阶段使用短暂排他锁
拷贝/重建过程异步化
- 后台重建数据
- 前台继续处理读写
引入 inplace / instant 算法
- MySQL 8.0 中大量 DDL 不再重建表
Online DDL 能解决死锁吗?
不能
Online DDL 解决的是:
- 表结构变更期间的 阻塞问题
- MDL 导致的长时间不可用
但它:
- 不会改变 InnoDB 行锁模型
- 不会避免 Next-Key Lock
- 不会消除业务逻辑导致的死锁
七、如何在业务层面规避这类死锁?
设计层面
避免:
sql
SELECT ... FOR UPDATE
+ INSERT
改为:
- 直接 INSERT
- 依赖唯一索引
- 捕获重复键异常
降低锁范围
- 精确命中唯一索引
- 避免范围扫描
- 减少 Next-Key Lock 触发概率
事务控制
- 事务尽量短
- 不要在事务中做无关操作
- 固定访问顺序,减少交叉等待
总结
为什么会死锁?
-
InnoDB 为了解决幻读,引入了 Next-Key Lock
-
Next-Key Lock + 插入意向锁存在天然冲突
-
并发事务在特定业务模式下形成循环等待
Online DDL 的定位
-
解决的是 DDL 阻塞问题
-
不是行锁死锁的"银弹"
一句话结论
MySQL 死锁不是偶然,而是锁模型与业务访问模式的必然结果
理解锁,才能真正写出高并发安全的 SQL。