死锁主因是加锁顺序不一致而非SELECT FOR UPDATE本身:事务A先锁order再锁user,事务B反向加锁,形成循环等待;即使查不到记录,在RR级别下也会因间隙锁与插入意向锁冲突导致死锁。

死锁几乎从不因为用了 SELECT FOR UPDATE,而是多个事务对同一组资源加锁顺序不一致——比如事务 A 先锁 order 再锁 user,事务 B 反过来,InnoDB 在加锁过程中就形成环路,MySQL 只能回滚其中一个并报错 Deadlock found when trying to get lock。
为什么加锁顺序不一致就会死锁
死锁本质是循环等待:A 持有 X 锁等待 B 的某行,B 又持有 Y 锁等待 A 的某行。InnoDB 不会主动协调顺序,只按 SQL 执行时实际访问的索引路径加锁。
- 哪怕语句字面一样,
UPDATE t SET x=1 WHERE id IN (2,1)和WHERE id IN (1,2)的加锁顺序也可能不同——它取决于 B+ 树遍历路径,不是传参顺序 - ORM 自动生成 SQL 时,
WHERE条件字段顺序不固定(如status = ? AND user_id = ?有时变user_id = ? AND status = ?),可能触发不同索引,导致加锁顺序天然不一致 - 一个事务先
SELECT ... FOR UPDATE查order表,再更新inventory;另一个事务反向操作,就构成闭环
为什么没查到记录也会死锁
在默认 REPEATABLE READ 隔离级别下,SELECT ... FOR UPDATE 即使命中唯一索引但记录不存在,也会加间隙锁(Gap Lock)——锁定该值“本应存在”的区间。
- 事务 T1 和 T2 同时执行
SELECT * FROM orders WHERE order_no = 'ABC' FOR UPDATE,都未命中,但都在同一间隙(如('AAA', 'ZZZ'))上加了可共存的间隙锁 - 接着两者都尝试
INSERT INTO orders (order_no, ...) VALUES ('ABC', ...),需要申请插入意向锁(Insert Intention Lock) - 插入意向锁与间隙锁互斥,T1 等 T2 释放间隙锁,T2 又等 T1,死锁立即形成
-
EXPLAIN显示type = const且rows = 1也不能避免——只要记录不存在,间隙锁就生效
为什么索引没走对会让死锁更频繁
WHERE 条件没走索引时,SELECT ... FOR UPDATE 会退化为全表扫描,每行都加 X 锁,在 RR 级别下近乎等价于锁整张表。
- 常见失效场景:
WHERE DATE(created_at) = '2024-01-01'(函数导致索引失效)、WHERE user_id = '123'(隐式类型转换)、WHERE status = 1对联合索引(user_id, status)(最左前缀不匹配) -
EXPLAIN FORMAT=TRADITIONAL必须检查三处:type不能是ALL或index,key必须显示具体索引名(如PRIMARY),rows应接近预期行数 - 用
SELECT *而非只查主键,会延长事务时间、增加锁持有窗口,间接放大冲突概率
怎么验证和收敛锁行为
死锁不是靠猜,得看 InnoDB 实际怎么加锁。每次上线前必须用 EXPLAIN 和 SHOW ENGINE INNODB STATUS 交叉验证。
-
SHOW ENGINE INNODB STATUS输出里的LATEST DETECTED DEADLOCK区域,比应用日志更权威,直接告诉你哪两个事务、哪几行、什么锁类型撞上了 - 多表操作必须强制顺序:要么按表名字母序(
order_item → order → user),要么按业务流(order → order_item → payment),禁止用if/else动态拼 SQL 决定先查哪张表 - 高频争抢场景(如秒杀订单号),优先用
INSERT INTO ... ON DUPLICATE KEY UPDATE替代“先查后更”,前提是字段有唯一索引——它是一条原子语句,加锁路径可控
真正难处理的从来不是“要不要加锁”,而是“锁住谁、按什么顺序、锁多久”。间隙锁不可见,加锁顺序藏在执行计划里,而死锁日志只给结果不给原因——这些地方最容易被忽略,也最需要实操验证。


















