INSERT … ON DUPLICATE KEY UPDATE 的锁粒度与死锁风险分析

6次阅读

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

INSERT … ON DUPLICATE KEY UPDATE 的锁粒度与死锁风险分析

ON DUPLICATE KEY UPDATE 会锁哪些行

它不是只锁“将要插入的那条记录”,而是先按 INSERT 路径走:对唯一索引(PRIMARY KEY 或 UNIQUE KEY)匹配的冲突行加 INSERT intention lock,再升级为 X lock;如果没冲突,则对插入位置加 gap locknext-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 UPDATEUPDATE 部分不支持子查询或函数调用(如 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 都不一定能还原全貌。

text=ZqhQzanResources