MySQL 插入冲突了怎么办?两种处理方式入门笔记
从一个报错说起
给表加了唯一索引之后,数据库就开始帮你挡重复数据。但挡归挡,问题是它挡人的方式很直接——INSERT 执行到一半撞上冲突,直接抛错:
ERROR 1062 (23000): Duplicate entry 'test@example.com' for key 'uk_email'刚入门时我的第一反应是:插入前先查一遍。SELECT一下这条 email 存不存在,不存在再INSERT。单线程跑没问题,但只要两个请求并发进来,就可能同时通过 SELECT 检查、再同时 INSERT——还是炸。先查后插本质上是把检查和写入拆成了两步,中间的空隙就是隐患。
其实 MySQL 自己就提供了两种"撞上冲突怎么办"的语法,区别只在于:冲突发生的那一刻,你希望数据库做什么?
- 什么都不做,跳过这一行 →
INSERT IGNORE - 更新一下已有的那行 →
ON DUPLICATE KEY UPDATE
下面用一个例子把两种都过一遍。
准备:一张会冲突的表
CREATETABLEusers(idBIGINTAUTO_INCREMENTPRIMARYKEY,emailVARCHAR(64)NOTNULL,nameVARCHAR(64)NOTNULL,last_loginDATETIME,UNIQUEKEYuk_email(email));email 上建了唯一索引(顺带一提:MySQL 的唯一索引允许多个 NULL 共存,所以这里 email 要 NOT NULL,否则"空值"能无限重复插入)。
INSERT IGNORE:撞了就跳过
机制:正常尝试插入,如果因为唯一索引冲突失败,就静默跳过这一行——不报错、不插入,只是受影响行数为 0。
-- 如果 email 'test@example.com' 已存在,这条语句静默跳过,无任何报错INSERTIGNOREINTOusers(email,name)VALUES('test@example.com','John');关键点:必须检查受影响行数。这是判断"到底插没插进去"的唯一方式。用 Java 写就是看update()的返回值:
introws=jdbcTemplate.update("INSERT IGNORE INTO users (email, name) VALUES (?, ?)","test@example.com","John");if(rows==0){// 冲突了:email 已存在。记日志或走别的分支log.warn("email 已存在,跳过插入");}平时写 CRUD 很少有人看executeUpdate()的返回值,用了INSERT IGNORE之后它就是关键情报:1 表示插入成功,0 表示撞了冲突。
一个容易踩的坑:IGNORE忽略的不只是重复键错误。字段超长被截断、类型不匹配这类本来会报错的情况,也会被降级成警告静默放行。所以它比名字听起来更"宽容",数据质量要求高的场景要想清楚再用。
适用场景:幂等写入——批量导入去重、初始化数据、日志记录。这类场景的特点是"重复了就算了,我不需要知道细节"。
ON DUPLICATE KEY UPDATE:撞了就更新(最推荐)
机制:先尝试插入,如果冲突,就转去 UPDATE 已有的那一行。更新哪些字段完全由你指定——所以这种方式也叫 Upsert(存在就更新,不存在就插入)。
-- email 存在则更新 name 和 last_login;不存在则插入新记录INSERTINTOusers(email,name,last_login)VALUES('test@example.com','John',NOW())ONDUPLICATEKEYUPDATEname=VALUES(name),last_login=VALUES(last_login);VALUES(name)是个便捷函数,代表这条 INSERT 语句里 name 列的值,避免把同一个值写两遍。
受影响行数:1 表示执行了插入,2 表示执行了更新。有个细节要知道——如果更新后的值和原来一模一样,受影响行数是 0。所以拿返回值判断操作类型时,别漏了这种情况。
一个VALUES()覆盖不了的场景——自增计数:
-- 每次执行浏览量 +1:不存在则插入并置 1,存在则 +1INSERTINTOarticle_stats(article_id,view_count)VALUES(1001,1)ONDUPLICATEKEYUPDATEview_count=view_count+1;view_count = view_count + 1引用的是表里已有的旧值,这是VALUES()做不到的,计数器类需求只能这么写。
想要"完全覆盖旧数据"怎么办?不需要什么特殊语法——把所有字段都写进 UPDATE 子句就行:
INSERTINTOusers(email,name,last_login)VALUES('test@example.com','Jane',NOW())ONDUPLICATEKEYUPDATEname=VALUES(name),last_login=VALUES(last_login);这样旧行除了唯一键和主键之外的字段全部被新值覆盖,效果就是"替换",而且主键 id 不变、其他表的外键引用不受影响。比物理删除再插入的做法可控得多。
版本提醒:MySQL 8.0.20 起官方把VALUES(col)标记为废弃,推荐用行别名写法:
INSERTINTOusers(email,name,last_login)VALUES('test@example.com','John',NOW())ASnewONDUPLICATEKEYUPDATEname=new.name,last_login=new.last_login;两种写法目前都能跑,老项目里VALUES()还随处可见,看新教程时别被两套写法搞懵。
适用场景:用户登录刷新时间、库存计数、配置项更新、覆盖式写入——凡是"有则更新、无则插入"的需求,首选它。
两种方案对比
| 方案 | 核心机制 | 冲突时行为 | 受影响行数 | 典型场景 | 关键注意事项 |
|---|---|---|---|---|---|
| INSERT IGNORE | 尝试插入,静默跳过冲突行 | 不插入、不报错 | 0(冲突)/ 1(成功) | 幂等写入、批量导入去重 | 只能靠行数判断结果;其他错误也会被静默 |
| ON DUPLICATE KEY UPDATE | 尝试插入,更新冲突行指定字段 | 更新指定字段,保留其他字段 | 1(插入)/ 2(更新) | Upsert:刷新时间、计数器、覆盖写入 | 需显式指定更新字段,灵活性最高 |
怎么选:决策指南
拿业务需求对着这张决策树走一遍,基本就能定:
插入撞上唯一索引冲突,你希望数据库做什么? │ ├─ 冲突了就算了,什么都不用做 │ └─ INSERT IGNORE(批量导入、初始化数据去重) │ └─ 冲突了要更新数据(部分字段或全部覆盖) └─ ON DUPLICATE KEY UPDATE(绝大多数 Upsert 场景,首选)两条核心原则:
- 首选
ON DUPLICATE KEY UPDATE:精确控制更新哪些字段,从"只更新一个时间戳"到"覆盖所有字段"都能胜任,覆盖绝大多数业务场景。 - 需要"保证不重复"而不需要更新时,用
INSERT IGNORE:它最简单,但代价是静默——记得检查受影响行数,别让冲突悄悄溜过去。
写在最后
整理完这两种语法,有几点体会:
- 受影响行数是个宝。写 CRUD 的时候
update()返回值从来没人看,但这两种语法全靠它反馈结果。数据库给了你返回值,就看你要不要用。 - 唯一索引才是真正挡重复的那道门。两种语法都靠唯一索引触发——没有唯一索引,
INSERT IGNORE只是普通插入,ON DUPLICATE KEY UPDATE的 UPDATE 分支永远不会走到。先想清楚"什么算重复"(哪个字段建唯一索引),再想"冲突了怎么办"(选哪种语法),顺序不能反。 - 先查后插不是不行,是要加锁或者靠唯一索引兜底。并发场景下,"检查+写入"必须是一个原子操作才算安全。这两种语法本质上是把冲突处理下沉到数据库层,让存储引擎替你做原子性保证——这也是我不再执着于先 SELECT 再 INSERT 的原因。
