各位后端的老铁,我是老王。
当年做电商订单中心和支付网关的时候,我最怕半夜两点手机响。只要报警短信里带「Deadlock found when trying to get lock」,基本意味着有大促漏斗或者并发量冲上来了,接着就是一连串因事务挂起导致的数据库连接池被打满。
今天老王不背书,结合我之前踩过的几个大坑,从底层逻辑、真实案例因果链和排查套路三个维度,把 MySQL (InnoDB) 的锁机制和死锁彻底拆解一遍。
一、 先看清对手:InnoDB 锁机制的因果链
很多刚从 Java 或 Golang 转入高并发业务开发的同学,往往以为「我改这一行,就只锁这一行」。在 InnoDB 里,这个直觉是完全错的。
InnoDB 的锁机制有一条铁律:锁是加在索引上的,而不是加在记录上的。如果查询没有走索引,它就会退化为全表扫描并锁住整张表。
在默认的 Repeatable Read (RR) 隔离级别下,为了解决幻读,InnoDB 引入了间隙锁。因果链条是这样的:
- 记录锁 (Record Lock):命中精准索引且记录存在,锁住单行。
- 间隙锁 (Gap Lock):锁定一个开区间,阻止其他事务向这个区间插入新数据。注意:间隙锁与间隙锁之间并不互斥,它们存在的唯一目的就是阻止别人 INSERT。
- 临键锁 (Next-Key Lock):Record Lock + Gap Lock 的组合,锁定左开右闭区间(比如
(10, 20])。InnoDB 默认使用 Next-Key Lock,只有在特定条件下(如唯一索引等值查询命中)才会降级为 Record Lock。 - 插入意向锁 (Insert Intention Lock):一种特殊的间隙锁。在插入之前,如果目标间隙被加了 Gap Lock,INSERT 操作就会挂起,申请插入意向锁等待。
死锁的本质,就是两个或两个以上的事务,持有了对方下一步需要的锁资源,同时还在等待对方释放锁,形成了闭环。
二、 真实翻车案例:支付流水的「幽灵死锁」
1. 业务背景
那是在做第三方支付回调处理时,本地有一张支付流水表 pay_flow,结构简化如下:
CREATE TABLE `pay_flow` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL,
`status` tinyint(4) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
业务逻辑很简单:第三方支付异步通知回调,为了防止重复处理,先 SELECT ... FOR UPDATE 查一下,如果不存在就写入,存在就更新。代码用 Go 写的,开了一个事务:
tx := db.Begin()
// 1. 查询订单是否存在
var flow PayFlow
err := tx.Raw("SELECT id, order_no, status FROM pay_flow WHERE order_no = ? FOR UPDATE", orderNo).Scan(&flow).Error
// 2. 如果不存在则插入
if errors.Is(err, gorm.ErrRecordNotFound) {
tx.Exec("INSERT INTO pay_flow(order_no, status) VALUES (?, ?)", orderNo, 1)
}
tx.Commit()
2. 死锁复现与因果链
压测时,当两个不同的线程几乎同时处理两个系统中不存在的订单(比如 order_no = 'A100' 和 order_no = 'B200')时,DB 瞬时抛出死锁异常。
让我们来看看这背后的因果死环:
[事务 1 (A100)] [事务 2 (B200)]
| |
1. SELECT FOR UPDATE (A100 不存在) |
-> 获得 idx_order_no 上的 Gap 锁 |
| 2. SELECT FOR UPDATE (B200 不存在)
| -> 也获得了同一区间的 Gap 锁
| (间隙锁互不排斥,两者和平共处)
| |
3. INSERT INTO (A100) |
-> 需要在间隙中插入数据 |
-> 被事务 2 的 Gap 锁阻塞 |
-> 进入等待 (等待事务 2 提交) |
| 4. INSERT INTO (B200)
| -> 也需要在该间隙插入数据
| -> 被事务 1 的 Gap 锁阻塞
| -> 形成闭环!
| |
[ MySQL 检测到死锁,根据成本回滚了其中一个事务 ]
看到了吗?正是因为两个事务都拿到了兼容的 Gap Lock,随后又同时想要往对方锁定的间隙里插入数据(申请插入意向锁),直接打成死结。
三、 老王的死锁排查四步法
出了死锁别慌,按老王的套路走,十五分钟内定位根因:
第一步:抓现场 —— 调取死锁日志
在 MySQL 终端中直接执行:
SHOW ENGINE INNODB STATUS\G
找到 ------------------------ LATEST DETECTED DEADLOCK ------------------------ 这一段。
第二步:看日志,提炼关键要素
日志里重点关注两块:(1) TRANSACTION 和 (2) TRANSACTION。
- 持有锁情况:看
HOLDS THE LOCK(S),找清楚事务持有的是什么锁(lock_mode X是排他锁,locks gap before rec说明带间隙)。 - 等待锁情况:看
WAITING FOR THIS LOCK TO BE GRANTED,重点看insert_intention、lock_mode X waiting。 - 执行的 SQL:找到诱发死锁的最终那句 SQL(注意:日志只显示死锁时最后执行的一条 SQL,前面持锁的 SQL 往往需要去应用层查调用链路)。
第三步:代码还原因果链
拿死锁日志里的 SQL,去 Java/Go 代码里找对应的方法。看这个方法所在的本地事务里,从事务开始到死锁发生,中间到底依次执行了哪些操作。
第四步:在测试环境 EXPLAIN 锁范围
很多人看 SQL 以为走的是主键,其实由于隐式转换或者优化器抽风走了全表扫描。
用 EXPLAIN 查看执行计划,重点关注 type、key 和 rows。
四、 架构与研发避坑指南
结合做订单和分布式系统的经验,老王总结了几条治理死锁的「硬规则」:
1. 降低隔离级别:RC 替代 RR
这是很多大厂互联网业务的标配方案。
把隔离级别改为 Read Committed (RC),同时配合 binlog_format = ROW。在 RC 级别下,除了外键约束和重复键检查,InnoDB 默认不开启 Gap Lock,能直接灭掉 70% 以上莫名其妙的间隙死锁。
2. 将「SELECT FOR UPDATE + INSERT」改为唯一索引冲突捕获
上面的支付流水案例,改写思路很直接:
别查了,利用数据库的唯一索引(UNIQUE KEY idx_order_no)。直接 INSERT,通过捕获数据库的 DuplicateKey 异常(Java 中是 DuplicateKeyException,Go 里判断特定驱动错误码),捕获到就走 UPDATE 逻辑;或者直接用 INSERT INTO ... ON DUPLICATE KEY UPDATE(但注意该语句在某些场景下也有锁升级陷阱,需控制并发路径)。
3. 严格规范事务内的加锁顺序
如果你的事务需要批量更新多条记录,比如扣减库存:
- 错误写法:入参传什么顺序,就按什么顺序
UPDATE ... WHERE id = ?。- 事务 A 先锁 1 再锁 2;事务 B 先锁 2 再锁 1,极高概率死锁。
- 正确写法:在内存中对主键或者唯一键先做严格排序(从小到大),然后按顺序执行 SQL。大家都按同一个顺序拿锁,只会出现等待,绝不会死锁。
// Java 示例:批量操作必须先排好序再进 DB
List<Long> sortedIds = goodsList.stream()
.map(Goods::getId)
.sorted()
.collect(Collectors.toList());
for (Long id : sortedIds) {
goodsMapper.reduceStock(id, count);
}
4. 控制事务粒度,严禁在事务里混入 RPC 调用
很多人喜欢在 Spring 的 @Transactional 里发 HTTP 请求或者调用 Redis。
外部接口耗时一旦抖动到 2 秒,这个事务持有的 MySQL 行锁就维持了 2 秒。在并发场景下,持锁时间越长,发生碰撞打成死锁的概率是呈指数级上升的。
原则:事务只包裹纯粹的 DB 写操作,快进快出,毫秒级提交。
老王总结
数据库并发控制,本质上是在数据一致性和吞吐性能之间做平衡。死锁是并发控制中不可避免的产物,MySQL 选择回滚一个事务来解除死锁,本身就是一种自保机制。
我们做架构设计的,不能指望死锁永远不发生,而是要:应用层保持幂等支持重试、代码层规范加锁顺序、隔离层尽量规避间隙干扰。
把底层的因果链想透了,半夜报警响的时候,你才能稳坐钓鱼台。有任何死锁排查的疑难杂症,欢迎在评论区贴日志,老王帮你盘它。
许可协议:CC BY-NC 4.0
更新于 12 小时前
觉得文章有帮助?点个赞吧!
0 条评论


