主键冲突本质是innodb锁等待假象,非数据重复;高并发下insert ... on duplicate key update会在检查前加x锁和插入意向锁,导致事务卡在“gap before rec insert intention waiting”状态。

主键冲突不是“数据重复”,是锁等待卡住的假象
高并发下存储过程报 Duplicate entry for key 'PRIMARY'(错误码 1062),大概率不是真插了重复值,而是多个事务在间隙锁上互相等待超时后抛错。InnoDB 对 INSERT ... ON DUPLICATE KEY UPDATE 会在检查唯一性前就对目标间隙加 X lock 和 insert intention lock;第一个事务持锁,其余全卡在 lock_mode X locks gap before rec insert intention waiting 状态。
典型诱因包括:
- 存储过程里用
SELECT MAX(id) + 1生成主键——没加锁,多线程读到同一MAX值,写入相同 ID - 流水号拼接逻辑(如
CONCAT('ORD', DATE_FORMAT(NOW(), '%y%m%d'), LPAD(@cnt, 4, '0')))未串行化,计数器更新和拼接分离 - 分库分表环境下雪花 ID 生成器漂移,不同节点产出相近值,又没做内存排序就批量插入
为什么加事务不一定能解决,反而可能加重冲突
单纯在调用层或存储过程开头加 BEGIN TRANSACTION,对主键生成逻辑本身无保护作用——除非事务内显式锁定计数器行或间隙。比如下面这段看似“加了事务”的代码依然会撞:
BEGIN TRANSACTION; UPDATE seq_table SET val = val + 1 WHERE name = 'order_id'; SELECT @new_id := val FROM seq_table WHERE name = 'order_id'; INSERT INTO orders(id, ...) VALUES(@new_id, ...); COMMIT;
问题出在 SELECT 和 INSERT 之间仍有窗口:两个事务都执行完 UPDATE,都读到同一 @new_id,然后同时 INSERT。真正要锁的是 UPDATE 这一行,且必须确保后续操作不脱离该锁范围。
正确做法是把生成和使用合并为原子动作:
- 用
INSERT ... SELECT ... FROM DUAL WHERE NOT EXISTS (...)配合唯一索引兜底 - MySQL 8.0+ 可用
GET_LOCK('seq_order_id', 5)做应用级互斥(但必须配RELEASE_LOCK()和异常处理器) - SQL Server 应用
sp_getapplock时,@Resource必须带业务上下文(如'order_seq_' + CAST(@tenant_id AS VARCHAR)),不能硬编码'mylock'
INSERT IGNORE 和 ON DUPLICATE KEY UPDATE 的并发表现差异极大
INSERT IGNORE 在检测到唯一键冲突时直接跳过,不加 X lock,也不触发二级索引变更;而 ON DUPLICATE KEY UPDATE 即使 SET id=id 这种无效更新,也会对目标行加 X lock,并可能引发索引分裂。语义允许“冲突即丢弃”时,应无条件选 INSERT IGNORE。
但要注意限制:
-
ROW_COUNT()返回1表示新增,0表示被忽略;返回-1才是其他错误(如字段类型不匹配),不能靠它区分所有失败场景 - 只对
PRIMARY KEY或明确的UNIQUE KEY生效,普通INDEX不触发 ignore 行为 - 批量插入时,
VALUES()中的主键必须ORDER BY id ASC,否则乱序会触发交叉间隙锁死等
触发器里手动生成主键是最危险的坑
MySQL 触发器中禁止在 BEFORE INSERT 里显式赋值 NEW.id,尤其不能用 SELECT MAX(id)+1 或从另一张表查值——并发下必然撞车。更隐蔽的是:触发器调用存储过程,过程里再 INSERT 同表,MySQL 会把整个链路视为“同一语句上下文”,导致锁机制失效。
安全替代方案只有三个:
- 直接用
AUTO_INCREMENT,让引擎保证唯一性和顺序性 - 用
UUID()或UUID_SHORT(),不依赖表状态 - 改用应用层生成(如雪花 ID),并在插入前内存排序、单批升序提交
所有自定义主键逻辑,一旦离开单线程或强事务边界,就必须面对间隙锁、时间精度、节点漂移三重干扰——这不是配置问题,是设计层面的不可规避复杂度。











