根本原因是InnoDB在非唯一二级索引上加锁非原子:先锁索引项再回表锁主键,若事务扫描顺序不一致则形成循环等待;唯一索引查询也可能因隐式转换、函数等导致索引失效而扩大锁范围。

为什么 SELECT ... FOR UPDATE 在非唯一索引上容易引发死锁
根本原因不是语句本身,而是 MySQL 的加锁范围:InnoDB 在二级索引(非唯一)上执行 SELECT ... FOR UPDATE 时,会先锁定匹配的索引记录,再回表查主键,最后锁定对应主键行——但这个过程不是原子的。如果两个事务按不同顺序扫描同一组二级索引项,就可能形成循环等待。
比如 WHERE status = 'pending' 这种常见查询,若 status 是非唯一索引,MySQL 可能按 B+ 树物理顺序加锁,而不同事务扫描起点或优化器选择的访问路径稍有差异,就会让锁序不一致。
- 避免在非唯一二级索引字段上直接
FOR UPDATE,尤其当该字段值重复率高(如状态字段) - 强制走唯一路径:用
WHERE id = ? AND status = ?组合,并确保id是主键或唯一索引,让 MySQL 能快速定位单行并只锁这一行 - 如果必须按状态批量处理,先用
SELECT id FROM t WHERE status = 'pending' ORDER BY id LIMIT 100拿到有序 ID 列表,再用SELECT ... FOR UPDATE WHERE id IN (...)——此时 InnoDB 会按id升序加锁,锁序可预测
唯一索引 + 等值查询为何仍可能死锁
即使查询命中唯一索引,只要语句里带了 OR、函数、隐式类型转换,或者优化器放弃走索引,就可能退化为全表扫描或范围扫描,导致锁住不止一行。
典型例子:SELECT * FROM order WHERE order_no = 12345 FOR UPDATE 看似安全,但如果 order_no 是字符串类型,而传入的是数字 12345,MySQL 会做隐式转换,无法使用索引,最终锁住所有行。
- 检查执行计划:务必确认
EXPLAIN输出中type是const或eq_ref,且key显示用了预期索引 - 禁止在索引列上用函数:
WHERE UPPER(name) = 'ABC'会让索引失效;应统一存储规范格式,查询时直接比对 - 字符串索引字段,应用层传参必须是字符串类型,避免数据库自动转换
INSERT … ON DUPLICATE KEY UPDATE 的锁行为陷阱
这个语句看着像“先试插、冲突则更新”,实际执行中,InnoDB 会对**插入意向间隙锁(insert intention gap lock)** 和 **唯一键冲突行的记录锁** 同时加锁。如果两个事务同时尝试插入相同唯一键值,又各自持有其他行的锁,就极易死锁。
更隐蔽的是:即使没发生冲突,MySQL 也会在唯一索引的间隙上加 insert intention lock——这意味着它和相邻的 SELECT ... FOR UPDATE 或 UPDATE 之间也可能因间隙锁重叠而死锁。
- 减少并发写同一业务键:例如订单号生成尽量分散(加时间戳、分库分表哈希),别全靠数据库自增或简单序列
- 把
ON DUPLICATE KEY UPDATE拆成显式SELECT+INSERT/UPDATE时,必须加FOR UPDATE且保证锁顺序一致,否则更易出问题 - 监控
SHOW ENGINE INNODB STATUS中的LATEST DETECTED DEADLOCK,重点关注lock_mode X locks gap before rec insert intention waiting这类提示
如何验证一个查询到底锁了哪些行和间隙
不能只信 EXPLAIN,得看真实锁状态。MySQL 8.0+ 提供了 performance_schema.data_locks 表,是目前最直接的方式;5.7 只能靠 INFORMATION_SCHEMA.INNODB_TRX 配合日志分析,但信息有限。
关键点在于:锁对象不只是“行”,还包括“间隙”(gap)、“临键”(next-key)。比如 WHERE id > 100 AND id ,即使表里没有 id=150 的记录,也会锁住 (100, 200) 这个间隙。
- 开启锁监控:
SET GLOBAL performance_schema = ON;,然后查SELECT * FROM performance_schema.data_locks WHERE OBJECT_SCHEMA = 'your_db' AND OBJECT_NAME = 'your_table'; - 注意
LOCK_DATA字段:显示NULL表示间隙锁,显示具体值(如150)才是记录锁 - 测试时用两个 session 手动模拟并发操作,不要依赖压测工具一次性发一堆请求——那样看不出锁等待链
死锁不是“有没有索引”的问题,而是“锁的粒度、顺序、范围”是否可控。最容易被忽略的,是那些看似只影响一行、实则触发间隙锁或索引合并扫描的操作。线上一旦出现死锁,优先看 data_locks 里锁的 LOCK_MODE 和 LOCK_DATA,而不是急着加索引或改 SQL 结构。










