根本原因不是并发高,而是多个事务在唯一索引上以不可预测顺序加间隙锁(GAP lock)+ 插入意向锁,形成循环等待;空表下因间隙范围大、锁冲突概率更高,且多唯一键导致InnoDB索引检查顺序不确定,进一步加剧死锁风险。

根本原因不是并发高,而是多个事务在唯一索引上以不可预测顺序加间隙锁(GAP lock)+ 插入意向锁(insert intention lock),形成循环等待。
为什么空表下 INSERT … ON DUPLICATE KEY UPDATE 也会死锁
很多人以为“表里没数据就不会锁”,实际恰恰相反:空表或数据稀疏时,INSERT … ON DUPLICATE KEY UPDATE 更容易触发 GAP 锁冲突。
- 当
user_id = 1001不存在时,InnoDB 会在唯一索引uk_user_id上加 GAP 锁,范围是(-, 1001);插入user_id = 1003则加(1001, 1003);再插入user_id = 1002就必须申请(1001, 1003)的 GAP 锁 —— 此时若前两个事务还没提交,就会阻塞 - 三个事务交叉申请同一区间 GAP 锁,又各自持有插入意向锁(该锁与 GAP 锁兼容但与其它插入意向锁互斥),最终形成 A→B→C→A 的等待环
- 这种行为在 RR 隔离级别下确定发生,与是否开启
autocommit无关;SHOW ENGINE INNODB STATUS中会明确看到lock_mode X locks gap before rec insert intention waiting
UNIQUE 索引数量直接影响死锁概率
官方文档已明确标注:INSERT … ON DUPLICATE KEY UPDATE 在多唯一键表中属于 “unsafe” 操作 —— 不是因为语法错,而是 InnoDB 检查索引的顺序不确定。
- 假设有主键
id和唯一键uk_user_id,InnoDB 可能先检查主键,也可能先检查uk_user_id,取决于建表时索引添加顺序 - 事务 A 按主键顺序判断冲突,事务 B 按唯一键顺序判断,二者加锁路径不同,即使操作同一行,也可能先锁 A 再锁 B vs 先锁 B 再锁 A
- 更危险的是:主从复制场景下,若主库和从库索引创建顺序不一致,同一语句在两边加锁顺序不同,不仅死锁,还可能造成数据不一致
ON DUPLICATE KEY UPDATE 的真实加锁流程
它不是“原子动作”,而是分阶段加锁:先 S 锁读取,再 X 锁更新 —— 中间存在窗口期。
- 步骤 1:尝试插入,发现唯一键冲突 → 对冲突行加 S 锁(共享锁),防止被删除
- 步骤 2:MySQL 层拿到该行后执行 UPDATE → 尝试升级为 X 锁(排他锁)
- 步骤 3:若此时另一事务也正卡在步骤 1,并已对同一行加了 S 锁,则双方都等对方释放 S 锁来升级 X 锁 → 死锁
- 注意:这个 S 锁不是“只读锁”,它会阻塞其他事务对该行的 X 锁请求,但允许其他事务加 S 锁 —— 这正是多事务并发时锁升级失败的根源
最容易被忽略的陷阱:隐式锁升级 + 范围锁放大
你以为只锁一行?其实很可能锁住一个区间,且锁类型随条件动态变化。
- 如果
WHERE条件走的是二级唯一索引(比如uk_user_id),InnoDB 会加 NEXT-KEY LOCK(= GAP LOCK + RECORD LOCK),范围远大于单行 - 而如果走主键,只加 RECORD LOCK,锁粒度最小 —— 所以务必确保
ON DUPLICATE KEY UPDATE的冲突判断基于主键,而非二级唯一索引 - 批量执行时(如一次插 100 行),每个值都会独立触发上述加锁流程,锁竞争面指数级扩大;拆成单条执行反而更安全
- 线上已发死锁,临时缓解不是调大
innodb_lock_wait_timeout,而是用SELECT ... FOR UPDATE显式提前锁定主键范围 —— 但要注意这会把锁提前暴露,可能引发更广的阻塞


















