相关子查询导致InnoDB逐行加锁,是因为外层每行触发一次子查询重执行;若子查询无索引,将全表扫描并对每行加Record Lock,达5000行后等效表锁,且锁持续至外层语句结束。

相关子查询为什么会让InnoDB逐行加锁
因为相关子查询(correlated subquery)在执行时,外层每行都触发一次子查询重执行,而子查询若无法利用索引快速定位,就会退化为全表扫描——InnoDB对扫描路径上的每一行都加Record Lock,最终锁住成千上万行,达到约5000行阈值后,效果等同于表锁。
典型写法:SELECT * FROM orders o1 WHERE o1.id = (SELECT id FROM orders o2 WHERE o2.user_id = o1.user_id ORDER BY created_at DESC LIMIT 1)。MySQL 5.7及之前版本无法下推LIMIT到子查询驱动侧,导致o1每有一行,就对整个orders表扫一遍。
- 即使只返回1行结果,也可能对10万行加临键锁(next-key lock)
- 锁不是语法决定的,是物化行为 + 全表扫描共同导致的
- 锁持续时间覆盖整个外层语句生命周期,而非单次子查询执行
WHERE条件无索引是锁升级的直接导火索
相关子查询中,ON或WHERE里的关联字段(如o2.user_id = o1.user_id)若没索引,优化器无法走ref/eq_ref,只能用ALL扫描。EXPLAIN显示type=ALL或key=NULL就是明确信号。
- 字段类型不匹配(如
user_id INT却传字符串'123')会触发隐式转换,索引失效 - 字符集不一致(列用
utf8mb4,参数用utf8)也会让索引无法命中 - 没有
ORDER BY时,子查询结果顺序随机,底层行锁获取顺序不可控,加剧并发冲突
为什么死锁比普通子查询更隐蔽
相关子查询引发的死锁不是来自“嵌套”本身,而是两个事务对同一组主键按不同路径加锁:事务A先锁o1行再查o2,事务B先查o2再锁o1,形成AB-BA循环等待。SHOW ENGINE INNODB STATUS\G里常看到一个事务HOLDING PRIMARY但WAITING FOR idx_user_id,另一个反过来。
- 锁常发生在二级索引(如
idx_user_id)上,而非主键,容易被忽略 - REPEATABLE READ隔离级别下,范围条件还会触发间隙锁,锁住整个索引区间
- ORM自动生成的相关子查询往往缺
ORDER BY和LIMIT,放大风险
替代方案必须切断逐行扫描逻辑
不能靠提示(如/*+ NO_MERGE() */)或改写为JOIN来解决——相关性决定了它天然无法一次性物化。真正有效的做法是把子查询提前独立执行,结果存入带主键的临时表,再与外层JOIN。
- 先
CREATE TEMPORARY TABLE tmp AS SELECT user_id, MAX(id) AS latest_id FROM orders GROUP BY user_id - 再
SELECT o1.* FROM orders o1 JOIN tmp ON o1.user_id = tmp.user_id AND o1.id = tmp.latest_id - 临时表必须有主键或唯一索引,否则JOIN仍可能触发全表扫描
- MySQL 8.0+可用窗口函数替代,如
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC),避免相关性
关键点在于:相关子查询的危险性不在“嵌套”,而在它强制数据库放弃一次性物化,转而接受逐行、重复、不可预测的加锁行为。只要外层数据量稍大,锁数量和持续时间就失控。

















