前言
死锁不是“偶发事故”,而是设计问题
在实际业务中,很多同学第一次接触 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,业务语义是:某个资源只能被创建一次。
CREATE TABLE resource ( id BIGINT PRIMARY KEY AUTO_INCREMENT, resource_key VARCHAR(64) NOT NULL, UNIQUE KEY uk_resource_key (resource_key) ) ENGINE=InnoDB;常见的业务代码逻辑是:
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)
- 锁定的是索引记录之间的“区间”
- 不锁具体记录,只锁“范围”
- 用来防止幻读
例如索引中存在:
(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复盘整个死锁过程。
第一步:两个事务同时进入
事务 A 事务 B BEGIN; BEGIN;第二步:SELECT FOR UPDATE
SELECT * FROM resource WHERE resource_key = 'A' FOR UPDATE;假设此时表中还没有resource_key = 'A'的记录。
会发生什么?
- InnoDB 在唯一索引
uk_resource_key上 - 对
A所在的索引区间加上X 型 Next-Key Lock - 锁住的是一个“未来可能插入 A 的区间”
结果是:
事务 A:持有 Next-Key Lock(X) 事务 B:也尝试加同一个 Next-Key Lock(X)注意:
Next-Key Lock 是互斥的
所以:
- 事务 A 成功
- 事务 B 被阻塞,等待 A 释放锁
第三步:事务 A 执行 INSERT
INSERT INTO resource(resource_key) VALUES ('A');此时发生了一个非常关键的事情:
插入位置落在已被 Next-Key Lock 锁住的 gap
InnoDB 会尝试:
将插入操作的隐式锁
转换为显式锁
同时需要获取一个插入意向锁(S 型)
第四步:锁冲突出现
关键冲突点来了:
- 插入意向锁(S)
- 与事务 B 持有的Next-Key Lock(X)冲突
于是:
事务 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 的关系
什么是幻读?
事务 A:第一次查询没有记录 事务 B:插入一条新记录 事务 A:第二次查询发现“多了一条”这就是幻读。
InnoDB 如何解决幻读?
- RR 隔离级别下
- 使用Next-Key Lock
- 锁住“可能出现新记录的范围”
解决幻读的代价,就是更复杂的锁冲突模型
六、Online DDL 解决了什么问题?
传统 DDL 的问题
在早期 MySQL 版本中:
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
- 不会消除业务逻辑导致的死锁
七、如何在业务层面规避这类死锁?
设计层面
避免:
SELECT ... FOR UPDATE + INSERT改为:
- 直接 INSERT
- 依赖唯一索引
- 捕获重复键异常
降低锁范围
- 精确命中唯一索引
- 避免范围扫描
- 减少 Next-Key Lock 触发概率
事务控制
- 事务尽量短
- 不要在事务中做无关操作
- 固定访问顺序,减少交叉等待
总结
为什么会死锁?
InnoDB 为了解决幻读,引入了 Next-Key Lock
Next-Key Lock + 插入意向锁存在天然冲突
并发事务在特定业务模式下形成循环等待
Online DDL 的定位
解决的是DDL 阻塞问题
不是行锁死锁的“银弹”
一句话结论
MySQL 死锁不是偶然,而是锁模型与业务访问模式的必然结果
理解锁,才能真正写出高并发安全的 SQL。