根本原因是ON DUPLICATE KEY UPDATE在冲突时先加S锁再升级X锁,形成两阶段加锁;配合REPEATABLE-READ下的Next-Key Lock及多唯一索引不确定性,易引发循环等待与死锁。

INSERT ... ON DUPLICATE KEY UPDATE 在高并发下容易触发锁等待甚至死锁,根本原因不是语句本身写错了,而是它在唯一键冲突路径上会申请 Next-Key Lock(临键锁),而多个事务对同一唯一值反复争抢时,极易形成“S 锁等待 X 锁释放 → X 锁回滚 → 多个 S 锁同时升级为 X 锁 → 互相等待”的循环。
INSERT ... ON DUPLICATE KEY UPDATE 的加锁行为你必须知道
- 没有冲突时:只加
insert intention lock(插入意向锁)和隐式X lock(排他记录锁),很快释放; - 发生唯一键冲突时:先加
S lock(共享锁)在冲突索引记录上,等 MySQL 层决定更新后,再升级为X lock—— 这个“先 S 后 X”的两阶段过程,就是锁等待的温床; - 如果表有多个唯一索引(比如主键 +
uk_phone+uk_email),InnoDB 检查冲突的顺序不确定,可能在不同事务中锁定不同索引路径,进一步放大死锁概率; - 隔离级别为
REPEATABLE-READ(默认)时,gap lock和next-key lock全量启用,间隙范围锁会让看似不相关的插入也互相阻塞。
唯一键设计不当会放大锁竞争
- 单一、高频写入的唯一键(如
phone或order_sn)是典型“热点”,所有并发ON DUPLICATE KEY请求都会落到同一个索引位置; - 组合唯一索引(如
(tenant_id, biz_key))比单列更安全:把热点分散到多个逻辑分区,降低单点锁冲突概率; - 避免用自增主键做
ON DUPLICATE KEY的判断依据(除非业务真需要按 ID 冲突更新),因为自增 ID 本身不会冲突,实际起作用的还是其他唯一索引; - 如果唯一索引字段值可预测或集中(如时间戳前缀、固定前缀 + 自增数),也会导致索引 B+ 树局部高度竞争,加剧 gap lock 等待。
批量插入时 VALUES 多行 vs 多条单行语句
- 用单条
INSERT ... ON DUPLICATE KEY UPDATE插入 100 行,InnoDB 会为每一行单独走一遍“检查→加 S 锁→升级 X 锁”流程,锁持有时间拉长,且无法控制行间顺序; - 若拆成 10 条各插 10 行,效果并无改善;真正有效的是:
- 对输入数据按唯一键字段预先排序(如按
phone升序),让多个事务以相同顺序访问索引; - 使用
INSERT ... SELECT ... ON DUPLICATE KEY UPDATE时,确保SELECT走索引且结果有序(加ORDER BY); - 极端场景下,干脆放弃批量 ODPU,改用临时表 +
INSERT IGNORE+UPDATE JOIN分两步,虽多一次 round-trip,但锁粒度可控、无升级风险。
- 对输入数据按唯一键字段预先排序(如按
最容易被忽略的三个实操细节
-
autocommit=0下手动事务中执行 ODPU,若未显式COMMIT或发生异常未ROLLBACK,S 锁会一直挂着,直接拖垮后续所有请求; -
SHOW ENGINE INNODB STATUS\G中若看到lock_mode X locks gap before rec insert intention waiting,说明已在等间隙锁,此时不是死锁,而是锁等待超时(Lock wait timeout exceeded),需优先优化唯一键分布或降并发; - MySQL 8.0.23+ 引入了
INSERT ... ON DUPLICATE KEY UPDATE的乐观锁支持(配合VALUES()函数和版本号字段),但前提是业务能接受“冲突时不更新、返回影响行为 0”,这比硬扛锁更轻量——只是很多人不知道这个模式可用。











