搞 MySQL 开发的人,十有八九都用过 INSERT ... ON DUPLICATE KEY UPDATE。这语法看起来就一句话,但真要放在“非主键唯一字段”这种场景里,坑一个接一个:明明更新了却没生效、影响行数飘忽不定、自增主键跳号严重、批量导入直接锁死。我最早是在做用户积分流水表的时候开始深入用它的,当时天真地以为“只要唯一键冲突就更新”,结果被生产环境的数据教了一整课。
这篇内容我会按自己的实际经验,把这个语法的行为机制、非主键唯一索引的冲突判定逻辑、实操写法、还有真实环境里的排查过程全部捋一遍。适合正在处理幂等写入、防重落库、同步累加这类需求的人看,也包括被“加了唯一索引还是重复”“数据没写进去也不报错”这类问题折磨过的朋友。
1. 为什么大家都在用 ON DUPLICATE KEY UPDATE,又为什么处理不好
1.1 幂等写入场景里的方案之争
做后端的人应该都有体会,最怕的其实不是查不到数据,而是同一份数据被重复落库。比如订单号、支付流水号、设备唯一标识、用户手机号,这些业务字段天然就有“同一语义只能保留一条”的需求。以前最常见的做法是先查再写:SELECT 判断存不存在,存在就 UPDATE,不存在就 INSERT。这法子逻辑上没错,但并发一上来就露馅:两个请求同时查到不存在,然后同时去 INSERT,后提交的那个直接报 Duplicate entry,没人兜底。
后来大家开始用 INSERT IGNORE,这个倒是能吞掉冲突,但问题也明显:它只保证“不报错”,如果那条数据已经存在,它什么也不会做。对“更新已有记录”这种诉求完全无能为力。接着有人换 REPLACE INTO,这个更狠,遇到唯一键冲突直接把旧记录删掉,再插入新的。听着很干净,但代价是删除+插入的事务开销,而且只要表上有外键或者自增主键,关联数据会跟着遭殃,主键 ID 也会变。
INSERT ... ON DUPLICATE KEY UPDATE 之所以成为最终选型,是因为它把“要么插入、要么更新”合并成了单条 SQL,而且这条 SQL 在 MySQL 内部走的是原子操作,并发下天然规避了“先查再写”的竞态窗口。这个语法的核心逻辑用大白话讲就是:你提交一条 INSERT,如果插入的时候触发了“唯一性冲突”,MySQL 就放弃插入,转而去执行后面跟着的 UPDATE 语句。
1.2 这个语法到底做了什么,别被名字骗了
很多人在看这个语法时,会下意识把它想成“先尝试插入,失败后更新”。这个理解方向对,但实现细节不是这么简单的。MySQL 在执行 INSERT 时,会先去存储引擎层面检查你要插入的数据是否与现有记录在某个唯一索引(包括主键索引)上发生了冲突。这个冲突检查不是在 SQL 层拿字段值逐个比对的,而是由 InnoDB 在索引 B+ 树里按索引定位时自然触发的。
一旦检测到冲突,MySQL 会根据语句中的 ON DUPLICATE KEY UPDATE 子句,直接对该索引命中的那条已有记录执行更新,而不是先把语句改成 UPDATE 再重新走一遍执行计划。这一点很重要,因为它意味着:UPDATE 部分的执行是基于“已经定位到的那一行”,不会再重新匹配全表。所以当表里有多个唯一索引时,具体更新哪一条,取决于插入时先撞上了哪个索引。规则不是“哪个字段在 SET 里就更新哪个”,而是“哪个唯一索引先冲突,就按哪个索引定位记录”。
后面我会专门讲多唯一索引的冲突顺序,这里先记住一个结论:它不是一个通用的 UPSERT 语法,它的一切行为都围绕“唯一索引冲突”展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 非主键唯一字段触发冲突时,MySQL 到底怎么选
2.1 冲突判定不是“先到先得”,而是索引优先级
这是我觉得最值得花心思理解的一点。很多开发者以为,只要 INSERT 里带了主键,那冲突就一定发生在主键上。但实际情况是:如果表上同时存在主键和非主键唯一索引,而你插入的数据恰好同时和两条现有记录冲突——一条撞了主键,一条撞了唯一字段——MySQL 会选择更新哪一条?
先说结论:会依据索引的优先级来判断,一般会优先处理主键冲突。具体可以这样验证:假设一张表有三个字段,id 是主键,mobile 是唯一索引。现在表里已经有两条记录,一条 id=1 但手机号是A,另一条 id=2 但手机号是B。如果你插入的数据是 (id=2, mobile=A),那它同时撞了主键(id=2)和唯一索引(mobile=A)。这时候 MySQL 最终会更新哪一条?在大多数版本和默认配置下,它会选择主键命中的那条,也就是 id=2 这条记录,后面对 mobile 的更新也作用在 id=2 上。
别以为这个行为是固定的,它在不同版本、不同索引定义顺序下可能有细微差异,而且在复杂场景下可能不只影响“更新哪一行”,还会影响是否能成功执行。所以最稳妥的做法是:不要让一条 INSERT 语句同时可能命中多个不同的唯一索引。如果业务上就是会遇到这种情况,那说明表设计本身已经埋了雷,应该在业务层做前置判断。
2.2 影响行数 0 / 1 / 2 分别代表什么
这个点要是不弄清楚,用 ORM 或 JDBC 的时候会一头雾水。ON DUPLICATE KEY UPDATE 返回的影响行数有三种情况:
- 返回 1:这是正常插入,表中原来没有冲突记录,执行了 INSERT 操作。
- 返回 2:发生了冲突,并且执行了 UPDATE,原有记录被更新了。为什么是 2?因为 MySQL 内部计了一次“尝试插入”和一次“更新”,加在一起算作影响行数。
- 返回 0:发生了冲突,也走了 UPDATE 逻辑,但最终没有实际修改任何字段值。也就是说,你要更新的值和旧值完全一样,或者 WHERE 条件下没有变化。
第三种情况在实际业务里特别有迷惑性。我之前调试过一个 bug,同事用 MyBatis-Plus 的插入接口,返回 0 表示没写成功,但日志里又没有异常。后来排查才发现,数据其实早就存在,而且 UPDATE 的字段值跟库里一模一样,MySQL 直接把它当成“没有变化”处理了。他以为没插入成功,其实数据一直都在。
写代码的时候如果需要区分“插入”和“更新”,不能用简单的 affectedRows > 0 来判断,而要区分 == 1 还是 == 2,同时考虑返回 0 的情况。很多 ORM 框架的封装并不会把这三个值明确传给你,需要在 SQL 层做转换或包装。
2.3 NULL 的坑:唯一索引不唯一
这个可以说是“非主键唯一字段”系列问题里最经典的隐藏 BUG。MySQL 的 unique index 允许多个 NULL 值存在,也就是说:mobile 字段是唯一索引,但它可以有很多条记录都取 NULL。原因在于 InnoDB 的索引结构认为 NULL 是“未知值”,两个 NULL 之间不等于,也就不构成冲突。
这对 ON DUPLICATE KEY UPDATE 的影响非常大。如果你的唯一字段是可空字段,而且业务上很多记录还没有这个值,那这个唯一约束从设计上就是形同虚设的。比如在做一个会员系统时,只有部分用户绑定了手机号,没绑定手机号的记录 mobile 为 NULL,这些记录可以无限重复。一旦后来有人触发 INSERT 逻辑,MySQL 并不会认为这些 NULL 互相冲突,结果就是同一用户可能产生多条“没有手机号”的记录。
要避免这个问题,要么把字段定义为 NOT NULL,并给一个默认占位值;要么在应用层统一把空值转成空字符串,但需要保证空串确实能代表“没有”且不会和真实数据冲突。坑在于,即使你把空字符串 '' 当成“无手机号”,遇到业务字段里面曾经存在过空字符串与 NULL 混用的老数据,依然会撞出不可预期的行为。我见过的实际案例里,最稳妥的方式就是:唯一索引涉及的业务字段,尽量都定义为 NOT NULL,用确定性的默认值来替代 NULL。
2.4 自增主键不会“跳号”?现实是跳得一塌糊涂
这个坑藏在比较底层,但影响很实际。ON DUPLICATE KEY UPDATE 虽然冲突时不会真正插入一条新行,但 InnoDB 在尝试插入时会先申请自增 ID。你可以把它理解为:MySQL 在检查冲突之前,已经跟自增计数器要了一个新值,但这个值最终没用到,计数器却已经加上去了。于是只要业务里频繁触发冲突更新,自增 ID 会飞速增长。
这对单纯的自增主键来说,一般不影响功能,但如果你的主键会被暴露给前端、会用于分片或者会参与其他业务计算,那跳号就会引起麻烦。比如订单表用自增 ID 做订单号的一部分,用户会明显发现订单号断层严重,甚至有人会怀疑系统有 bug。从运维角度看,自增 ID 跳号还意味着如果表被删除大量数据,ID 并不会回退,后续记录会继续递增,“看起来像”中间空了一大段。
解决办法没有绝对的,要根据业务取舍。如果确实需要连续的 ID,那建议不要用 ON DUPLICATE KEY UPDATE 来做防重,而应该改用事务 + 唯一索引捕获 Duplicate entry 异常,或者直接在应用层做一次 SELECT 再决定。但如果只是要幂等和防重,自增跳号属于可接受的代价。
3. 实操演示:建表、测试、验证全流程
3.1 从零开始复现一个冲突场景
我拿一个比较有代表性的例子来说:用户收藏商品表。业务要求同一用户对同一个商品只能有一条收藏记录,重复收藏时自动更新收藏时间,而不是报错。
建表 SQL 大概这样:
sql复制CREATE TABLE user_favorite (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
favorite_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_product (user_id, product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意这里用到的唯一键是 (user_id, product_id) 联合唯一索引,它不是主键,而且带着明确的业务含义:同一用户收藏同一商品只能有一条记录。这就是非主键唯一字段的典型场景。
插入逻辑写成这样:
sql复制INSERT INTO user_favorite (user_id, product_id, favorite_time)
VALUES (1001, 20001, NOW())
ON DUPLICATE KEY UPDATE
favorite_time = VALUES(favorite_time),
update_time = NOW();
第一次执行这条 SQL,返回 1,表示插入成功。再次执行同一条 SQL,返回 2,表示触发了唯一冲突,并且把 favorite_time 更新成了新的时间。这个行为是无脑可重复的:不管执行多少次,表里始终只有这一条记录,而且收藏时间会不断刷新。
3.2 三个高频业务写法的落地细节
除了上面这个最简单的场景,实际业务里常见的还有这几种写法上的讲究,挨个说。
第一个是累加更新。比如统计表里记录某个维度的点击量,每天全量同步一次,每次同步时把新的次数累加进去:
sql复制INSERT INTO daily_stat (stat_date, biz_type, cnt)
VALUES ('2024-06-01', 'click', 100)
ON DUPLICATE KEY UPDATE
cnt = cnt + 100;
这里依赖了一个关键行为:ON DUPLICATE KEY UPDATE 里的表达式可以直接引用“已有记录”的字段值。换句话说,cnt + 100 里的 cnt 是当前表里已有的数值,不是你要插入的数值。这个特性让累加操作变成了原子操作,不需要先 SELECT 再 UPDATE,也不需要担心并发下丢更新。
第二个是整行覆盖式更新。常见于同步外部系统的数据,比如同步一张配置表或字典表:
sql复制INSERT INTO sys_config (config_key, config_value, description)
VALUES ('order.timeout', '900', '订单超时时间(秒)')
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
description = VALUES(description);
这种写法在执行时,如果有冲突,会拿你 INSERT 部分提供的字段值去更新已有记录。注意 VALUES(col_name) 括号里的字段是 INSERT 部分里出现的字段,而不是表里的字段。它是取“本次语句尝试插入的新值”,这是这个语法里比较容易混淆的概念。
第三个是时间字段的动态更新。比如记录最后一次登录时间:
sql复制INSERT INTO user_login (user_id, last_login_time, login_count)
VALUES (12345, NOW(), 1)
ON DUPLICATE KEY UPDATE
last_login_time = NOW(),
login_count = login_count + 1;
这个写法结合了上面两个特性,既能更新“最后时间”,又能累加“登录次数”。你可以把 ON DUPLICATE KEY UPDATE 句子里更新哪些字段理解成完全由你控制,它不一定和你 INSERT 的字段列表一致。比如你可以插入 A、B、C 三个字段,但冲突后只更新 B 字段。
3.3 批量插入时的一个隐藏性能问题
ON DUPLICATE KEY UPDATE 最常出性能问题的地方,反而是大批量导入。很多人的直觉是:批量插入嘛,包在一个多 VALUES 的 INSERT 语句里就行了。假如业务记录里偶发重复,MySQL 会在发生冲突时去执行 UPDATE,其他不冲突的记录正常插入。看起来没毛病,但这里有一个很现实的锁竞争和资源消耗问题。
批量插入的原始写法类似这样:
sql复制INSERT INTO user_favorite (user_id, product_id, favorite_time)
VALUES
(1001, 20001, NOW()),
(1002, 20002, NOW()),
(1003, 20003, NOW())
ON DUPLICATE KEY UPDATE
favorite_time = VALUES(favorite_time);
如果这 2000 条记录里有一半都跟库里已有数据冲突,那这个语句里的每一行几乎都要触发索引定位和行锁竞争,整体耗时可能比普通的批量插入高出一个数量级。另外,如果存在多唯一索引冲突的前置条件,批量场景下的行为会更难预测,因为 MySQL 需要逐行判断冲突的是哪个索引。
实操中如果要导入几万条或几十万条数据,不建议一次性扔一个大 SQL。实测下来,比较实用的做法是把数据切成 500 到 1000 条一批,分批往里写,而且最好在业务低峰期执行。另一个思路是:如果这批数据本身是“全量覆盖”语义,不要求保留旧状态,可以先执行 DELETE 再 INSERT,放入一个事务里,效率往往比大批量 ON DUPLICATE KEY UPDATE 高。但注意,这种做法会导致主键变化,对外关联的字段如果引用了旧主键,需要谨慎。
3.4 通过 INSERT 捕获异常来兜底的思路
严格来说,这在业务里有时比 ON DUPLICATE KEY UPDATE 更合适。如果你需要非常精确地知道“这次是插入还是更新”,或者需要在插入成功时生成一个 ID、更新时保留旧 ID,那 ON DUPLICATE KEY UPDATE 的表现并不能让你满意——它更新时不会改变主键,但影响行数可能为 2。
另一种方式是:直接执行纯 INSERT,MySQL 报 Duplicate entry 异常后,在应用层捕获异常,再执行 UPDATE。这种做法把“冲突判断”完全交给了数据库异常机制,编程上更繁琐,但语义非常清晰:插入失败就一定说明数据已存在。这种方式还避免了自增 ID 跳号的问题,因为我们没有“尝试插入”的动作,MySQL 也就不会预占自增 ID。当然代价就是多了一次网络交互,性能上比单条 SQL 差一些。
4. 真实环境里的问题排查记录
4.1 给已有重复数据的表加唯一索引
这个场景特别高频,不一定和 ON DUPLICATE KEY UPDATE 直接相关,但它常和“非主键唯一字段”一起出现:业务表已经积累了大量数据,现在想给某个字段加唯一索引,结果一执行 ALTER TABLE 就报 Duplicate entry。这背后的原因是这个字段在存量数据里已经有重复值了,MySQL 不允许你直接建唯一索引。
我遇到过一个真实案例:一张用户临时表里存了重复的 order_no,业务方想把它改成唯一约束,但在生产环境直接执行 DDL 时报错。处理方法可以分几步走。第一步,查重,确认哪些值重复了:
sql复制SELECT order_no, COUNT(*)
FROM temp_order
GROUP BY order_no
HAVING COUNT(*) > 1;
第二步,根据业务规则处理重复数据。有的规则是保留最早的一条,那就按时间字段排序后删除其他记录;有的是保留最大 ID 的一条,那就反过来。在数据量大的场景下,可以先建一张新表,把去重后的数据灌进去,再通过 RENAME TABLE 切换。这比直接在原表上 DELETE 要稳妥得多,因为可以在新表里先建好唯一索引,不影响线上旧表的数据。
第三步,确认没有重复后,再执行 ALTER TABLE ADD UNIQUE KEY。另外需要注意,加索引本身会锁表,数据量大的表建议用工具来做在线 DDL,或者分成凌晨低峰期执行。
4.2 数据没写进去也没报错,问题出在哪
前面提过,影响行数返回 0 时,ORM 层可能返回“没有生效”。很多人会陷入一个误区:既然没报错,那至少插入或更新了一行吧?但实际上它可能什么都没做——因为冲突后要更新的值与库里现有值完全一致,MySQL 直接跳过了写操作。
还有个相关问题是:如果你在唯一索引字段上插入 NULL,因为 NULL 不参与冲突,INSERT 会一直成功,不会触发 UPDATE。这在数据看来就是“写进去了,好像也没报错,但表里出现了多个 NULL”,最后你得在业务上专门处理这些脏数据。
排查的时候,建议先用 SHOW WARNINGS 看语句执行后的警告信息,很多细节不会直接报异常,但会以 warning 的形式存在。其次,确认表上到底有哪些唯一索引,可以通过:
sql复制SHOW INDEX FROM user_favorite;
这个命令会把表上所有索引列出来,包括索引名、字段名、是否唯一。这个检查在排查询问题时非常重要,因为有时候你以为某个字段有唯一约束,实际却被建成了普通索引,那一切冲突都不成立。
4.3 ON DUPLICATE KEY UPDATE 的锁与死锁
这一点值得单独拿出来讲。ON DUPLICATE KEY UPDATE 在并发场景下是会产生死锁的,我最早还真被它锁过一张流水表。死锁的根本原因是:多个事务同时尝试插入或更新一堆重复键,相互之间持有了对方需要的锁,然后进入互相等待。InnoDB 检测到死锁后会随机回滚一个事务,报 Deadlock found when trying to get lock; try restarting transaction。
具体到 ON DUPLICATE KEY UPDATE,冲突时会对那条已有的记录加锁,同时尝试插入的记录也涉及插入意向锁,批量插入时各事务锁的申请顺序不同,就容易出现死锁。要降低死锁概率,比较实用的手段包括:保证同一批数据尽量按同一顺序处理、控制单批数量、把隔离级别调整到已提交读(如果业务允许)、以及在事务操作失败后设置重试机制。
从架构角度看,如果这是高频写路径,还要考虑是不是可以引入消息队列做串行化,避免多个消费者同时去读写同一组唯一键。这是另一个层面的方案了,但经常被忽略。
4.4 配合 ORM 框架使用时容易忽略的返回语义
现在很多项目用 MyBatis-Plus 或 JPA 这类框架,它们对 ON DUPLICATE KEY UPDATE 的支持并不完全统一。MyBatis 中你可以写自定义 SQL,返回受影响行数;但如果用的是 MyBatis-Plus 自带的 saveOrUpdate,它的实现并不一定等价于这个 SQL 语义,可能还是先查一遍再决定插入或更新,需要去翻框架源码确认。
数据没写成功也没有报错,还有一个很常见的原因是:ORM 映射里某些字段为 null,生成了 INSERT INTO ... VALUES (?, ?, ?) 的语句,结果唯一键字段恰好是 NULL,绕过冲突,插入了一条重复记录。这种问题从数据库层面的 ON DUPLICATE KEY UPDATE 角度是看不到的,因为 SQL 本身没有触发任何冲突。排查时需要回看应用日志里真实执行的 SQL 语句,确定字段值是否如预期。
所以我的建议是:不要过度依赖 ORM 对 UPSERT 的能力封装,涉及唯一键冲突的关键业务,直接手写 SQL,并明确拿到影响行数,再根据影响行数做业务分支。这样行为可预期,排查也方便。
4.5 LAST_INSERT_ID 与更新场景下的应用陷阱
还有一个需要留意的点是:如果你希望插入后拿到自增主键 ID,可以用 LAST_INSERT_ID() 函数。但在 ON DUPLICATE KEY UPDATE 的更新场景下,LAST_INSERT_ID() 配合这个语法有个特殊行为:如果你在 UPDATE 分支里显式调用 LAST_INSERT_ID(expr),可以把这个函数的值设置成你期望的值,方便应用层统一读取。
这是个比较偏门的技巧,但也说明这个语法的设计比想象中细。比如:
sql复制INSERT INTO user_favorite (user_id, product_id, favorite_time)
VALUES (1001, 20001, NOW())
ON DUPLICATE KEY UPDATE
favorite_time = VALUES(favorite_time),
id = LAST_INSERT_ID(id),
update_time = NOW();
这样无论最终是插入还是更新,应用层通过 LAST_INSERT_ID() 拿到的结果都是这条记录的主键 ID。在小规模应用里,这个技巧可以减少一次查询,但注意它是有副作用的,把 id 字段重新赋值为自己并不是真正的“写操作”,只是利用了 MySQL 这个特殊函数的副作用来标记 ID。如果业务不复杂,还是老老实实按返回结果处理。
5. 踩过几次坑之后的选型建议
在实际项目里,我见过太多人把 ON DUPLICATE KEY UPDATE 当成万能药,遇到写数据就先拼一个这个语法。但真正稳定的系统,会在使用前先回答几个问题:这条数据是否天然具备业务上的唯一键?这个唯一键是否稳定,不会因为业务规则调整而改变?写入频率高不高,是否会造成严重自增跳号或锁竞争?读多写少还是写多读少?
如果只是想避免重复插入,并且有一个明确的业务唯一键,那 ON DUPLICATE KEY UPDATE 完全够用。如果更新逻辑很复杂,需要同时更新多张表,而且必须知道是插入还是更新,那我更建议手动捕获 Duplicate entry 异常,再在事务里做更新。
如果字段本身允许 NULL,那唯一索引实际上不是一个严格的唯一保证。这个问题最好在设计表结构时就解决,因为是存量数据上补唯一约束的代价很高。如果你正处于技术选型阶段,我建议你把“非主键唯一字段”的语义、可空性、并发写入量都列出来,再决定是否用这个语法。
我个人现在写这种逻辑时,基本都会额外写一个小的压力测试脚本,模拟并发插入同一批唯一键,观察返回行数、死锁日志和自增 ID 增长情况。不要嫌麻烦,因为这个语法在低并发下看起来人畜无害,高并发下才会显露出真实代价。我踩过一次坑之后,就养成了这个习惯,也建议大家把它纳入上线检查清单里。
