ON DUPLICATE KEY UPDATE 会锁所有扫描到的唯一索引记录及其间隙,而非仅冲突行;并发插入可能因多唯一索引加锁顺序不一致导致死锁,需通过统一访问顺序、减少唯一约束、捕获死锁后重试来缓解。

ON DUPLICATE KEY UPDATE 会锁哪些行
它不是只锁“将要插入的那条记录”,而是先按 INSERT 路径走:对唯一索引(PRIMARY KEY 或 UNIQUE KEY)匹配的冲突行加 INSERT intention lock,再升级为 X lock;如果没冲突,则对插入位置加 gap lock 或 next-key lock。关键点在于——只要涉及唯一索引查找,InnoDB 就会对**所有扫描到的唯一索引记录及其间隙**加锁,哪怕最终只更新其中一条。
常见误判是认为“只锁冲突行”,实际中:INSERT ... ON DUPLICATE KEY UPDATE 在唯一索引上执行时,会触发和 SELECT ... FOR UPDATE 类似的加锁行为,尤其当唯一索引非主键、或存在多个唯一约束时,锁范围可能意外扩大。
为什么两个并发 INSERT 可能死锁
典型死锁场景:事务 A 和 B 同时执行相同语句,但扫描/加锁顺序不同(比如因索引 B+ 树分裂、页分裂导致遍历路径不一致),或它们分别命中了不同唯一索引(如一个走 uk_email,另一个走 uk_phone),就可能形成循环等待。
- 事务 A 先锁住
uk_email上的某条记录 X,再尝试获取uk_phone上的记录 Y - 事务 B 先锁住
uk_phone上的 Y,再尝试获取uk_email上的 X
MySQL 无法预判这种跨索引的锁依赖,只能在加锁失败时检测并回滚其中一个事务。这类死锁不会报 Lock wait timeout,而是直接返回 Deadlock found when trying to get lock。
UPDATE 部分是否影响锁行为
不影响加锁范围,只影响是否释放锁。无论 UPDATE 子句有没有实际修改字段值(比如 SET status = status),只要语句进入 UPDATE 分支,就会持有被更新行的 X lock 直到事务结束。但注意:UPDATE 中引用的非唯一字段(如普通二级索引列)不会额外加锁,除非该字段出现在 WHERE 条件里且触发了索引扫描。
一个易忽略点:ON DUPLICATE KEY UPDATE 的 UPDATE 部分不支持子查询或函数调用(如 SET ts = NOW() 是允许的,但 SET val = (SELECT ...) 会报错),这限制了部分动态赋值场景,也间接减少了因子查询引入的额外锁。
如何降低死锁概率
核心思路是让并发操作尽可能按相同顺序访问索引,减少不确定性。具体可做:
- 确保
INSERT的值在唯一索引上有稳定排序(例如插入前对 key 做哈希或归一化处理,避免随机字符串导致 B+ 树分裂不可控) - 尽量只定义一个唯一约束(最好是主键),避免多唯一索引交叉加锁
- 用
REPLACE INTO替代?不行——它本质是DELETE + INSERT,锁更重、还可能触发外键级联和触发器,死锁风险更高 - 业务层加分布式锁?过度设计;更轻量的做法是捕获
Deadlock found when trying to get lock后退避重试(指数退避,最多 3 次)
真正难调试的是那些不常复现的间隙锁竞争——比如两个事务恰好落在同一个 gap 区间内插入不同值,又都触发了 ON DUPLICATE 分支,此时锁行为高度依赖当前索引页状态,连 SHOW ENGINE INNODB STATUS 里的 TRANSACTIONS 都不一定能还原全貌。










