数据库写入去重:唯一索引与并发幂等实践
凌晨两点被拉进一个故障群,起因是某张订单流水表里,同一个业务单号躺了三条一模一样的记录,下游对账跑出来的金额直接翻了三倍。翻代码,写入逻辑是教科书式的三步走:先select查一下有没有,没有就insert进去。单元测试跑一万次都是绿的,线上跑一周就炸了。这个坑我在不同团队见过太多次,几乎每个写后端的人都写过类似代码,也几乎每个人都在某个深夜为它买过单。
用insert语句向表里插数据,同时又不想插入重复数据,这件事听起来简单,实际上是数据库写入里最容易想当然的一个环节。它不是一句insert语法的问题,而是“唯一性约束应该放在哪一层”的架构问题。这篇文章面向所有需要往数据库写数据的人——不管你是刚接手 CRUD 业务的新人,还是写了几年接口的老手,只要你的表里存在“同一条业务数据不能出现两次”的需求,下面这些内容都能直接拿去用。我会把四种主流写法的取舍、唯一索引的坑、批量插入的做法、并发下会遇到的死锁和影响行数陷阱,一条一条拆开讲,附上可以直接复制的 SQL 和代码。
1. 为什么"先查后插"这个写法迟早会出事
1.1 一次重复数据的完整排查过程
那次故障的定位过程不算复杂,但足够说明问题。业务侧反馈同一条记录出现多条之后,我先去查了表的索引定义,发现除了自增主键,没有任何唯一约束。也就是说,数据库层面完全没有拦截重复的能力,全靠应用代码自觉。
接着看代码,写入路径不止一处:正常下单走一个 service,补单走另一个定时任务,还有一个历史数据迁移的脚本。三个入口里,只有主入口写了“先查再插”,定时任务为了图快直接insert,迁移脚本用了批量插入。三条路径,两处漏网,重复数据自然就冒出来了。
更关键的是,即使是那个“写了检查”的入口,逻辑也是坏的。伪代码大概是这样:
row = db.query("select id from order_flow where biz_no = %s", biz_no) if not row: db.execute("insert into order_flow(biz_no, amount, ...) values(...)")两次数据库往返之间没有任何锁,两个请求同时进来,都查到空,都执行插入,结果就是两条。这在低并发下几乎不可能复现,一旦上了量,概率就变成一个必然事件。我当时算过一笔账:假设单条写入的“查+插”窗口是 5 毫秒,QPS 到 200 的时候,同一秒内有请求重叠的概率已经非常高了,赶上整点结算这种脉冲流量,几乎是必现。
这件事给我的教训很直接:唯一性校验只要是“先查后插”的两步操作,在并发场景下就是错的。不是写得不够仔细,是模型本身有缺陷。想让它正确,只能靠一个原子的、由数据库保证的操作来兜底,这个东西就是唯一索引。
1.2 把唯一性交给应用代码的三重代价
很多人坚持在代码里做检查,理由通常是“报错信息友好”“业务逻辑复杂,数据库管不了”。这些理由部分成立,但代价往往被低估了。我把这几年踩到的代价归成三类。
第一类是并发漏洞。如上所述,“查”和“插”之间存在时间窗口,任何不加锁的实现都有竞态。你可能会说加个select ... for update不就行了?可以,但那是另一个坑:行锁只在记录存在时生效,记录不存在时锁的是间隙,高并发下极易死锁,而且平白多了一次加锁开销。用行锁去解决“记录不存在”的问题,方向本身就是反的。
第二类是性能损耗。每次写入多一次查询往返,网络 RTT 加上索引查找,粗算下来写入链路的耗时至少翻一倍。批量场景更夸张,1000 条数据逐条查一遍,光往返就是 1000 次。我做过一次对比,同样的批量导入,逐条查重的方式比走唯一索引 + 单条 INSERT 语句的方式慢了将近 8 倍,数据量越大差距越明显。
第三类是维护成本。唯一性规则散落在各个写入入口,只要新增一条写入路径,就得记得把检查逻辑抄一遍。人是有惰性的,抄漏一次就是一次数据事故。而唯一索引只写在建表语句里一次,所有入口、所有语言、所有 ORM 自动生效,谁也绕不过去。
我的判断标准只有一个:如果一条业务规则是“不可重复”,那它就必须落在唯一索引上。应用层的检查只能作为体验优化(比如提前给用户提示),绝不能当作正确性保障。
2. 去重的根基:唯一索引到底怎么建才算建对
2.1 唯一索引和主键的分工
先说清楚一个容易混淆的点。主键保证的是“这一行能被唯一定位”,天生带唯一约束,但它只对主键列生效。业务上的唯一性通常是另一组字段,比如订单号、业务流水号、外部渠道号,这些跟自增主键没有关系,必须单独建唯一索引。
建的时候有几个实操细节值得注意。第一,唯一索引的命名要有辨识度,我习惯用uk_字段1_字段2这种格式,出问题时看报错信息里的索引名就能立刻定位到是哪条规则冲突了。第二,唯一索引尽量不要选超长字符串字段,如果业务上确实是长 URL 或者长哈希,可以用前缀索引,但唯一索引加前缀长度是有风险的——UNIQUE KEY uk_url (url(32))只比较前 32 个字符,两个不同但前缀相同的值会被判成重复,插入直接失败。这个坑很隐蔽,报错信息里只会告诉你“Duplicate entry”,你看着两个明显不同的值会一脸懵。
第三,组合唯一索引的字段顺序会影响索引的可用性。uk (a, b)和uk (b, a)在唯一性语义上完全等价,但在“能不能被其他查询复用”上区别很大。最左前缀原则同样适用于唯一索引,所以字段顺序应该按照“区分度高的在前、常用查询条件在前”来排。
2.2 组合唯一索引里最阴的 NULL 陷阱
这是我在生产环境见过最多、也最容易被忽略的问题。在主流关系型数据库的唯一索引实现里,NULL 值不参与唯一性比较。也就是说,如果唯一索引的某一列允许为 NULL,那么插多少条 NULL 进去都不会冲突。
举个具体的例子。假设有一张表用来记录用户对某个商品的评价,唯一索引建在(user_id, sku_id)上,本意是“一个用户对一个商品只能评价一次”。但如果在设计时把sku_id定成了允许 NULL(比如某些场景下商品还没确定),那么用户可以对同一个商品插入无数条sku_id = NULL的记录,唯一索引完全拦不住。
判断方法很简单,建完索引后跑一句:
select column_name, is_nullable from information_schema.columns where table_name = 'your_table' and column_name in ('col_a', 'col_b');只要唯一索引涉及的任何一列is_nullable = YES,这条唯一约束就是“漏的”。解决办法有两个:一是把参与唯一性的字段全部改成NOT NULL,用空字符串或者 0 做默认值替代;二是在业务层保证写入前一定给值,同时接受这个索引不是严格唯一的事实。我倾向第一种,虽然要改表结构,但一劳永逸。
还有个小变种:某些数据库支持“NULLS NOT DISTINCT”之类的语义(不同产品叫法不一样,实现细节也不同),如果你用的是较新版本,可以查一下当前版本是否支持,能省掉改表结构的麻烦。但跨版本兼容性要自己验证,别想当然。
2.3 上索引之前必须先做的存量数据清洗
直接对有重复数据的表加唯一索引,ALTER TABLE会失败,报错信息通常是 “Duplicate entry”。所以顺序必须是:先查重,再清洗,最后建索引。
查重的 SQL 很固定:
select biz_no, count(*) as cnt from order_flow group by biz_no having count(*) > 1 order by cnt desc limit 50;跑出来之后,别急着删。我的做法是先看清楚重复记录的分布特征:是不是集中在某个时间段?是不是来自某个特定入口?create_time和update_time的差异能帮上大忙。如果是同一秒内产生的,基本可以确认是并发写入导致;如果间隔好几天,那多半是不同入口重复调用了。
清洗策略上,我一般保留id最小的那条(或者update_time最新的那条,看业务语义),其余的先备份到一张临时表再删:
create table order_flow_bak_20240101 as select * from order_flow where id not in ( select min_id from ( select min(id) as min_id from order_flow group by biz_no ) t );这里有个细节要注意:上面这个子查询在数据量大时会很慢,因为group by要走全表。更快的写法是用join自己关联自己,或者分批处理,每次只处理 1 万条。另外删除前务必确认没有其他表通过外键引用这些即将被删的行,否则会触发级联删除,损失比重复数据大得多。
提示:清洗脚本一定要在事务里跑,先在从库或者预发环境验证一遍影响行数,确认和
having count(*) > 1统计出来的数量对得上,再上生产。我见过一次因为group by字段写错,把正常数据删掉的案例。
3. 四种主流 insert 去重写法的横向对比
3.1 INSERT IGNORE:最省事,也最容易被误用
句式最简单:
insert ignore into order_flow (biz_no, amount, create_time) values ('B20240101001', 199.00, now());行为是:如果插入时触发了唯一键冲突,就跳过这一条,不报错,返回影响行数 0;插入成功返回 1。批量插入时也是逐条判断,冲突的那条跳过,其余照常插入。
优点显而易见:写法短,不用写where,不用关心冲突后的处理逻辑,非常适合“这批数据能插多少插多少”的场景,比如日志采集、埋点上报、增量同步。
但它的问题是“ignore”这个词本身——它忽略的不只是唯一键冲突。实测下来,INSERT IGNORE会把一大类错误降级成警告:字段超长被截断、类型转换失败(比如把'abc'往 int 列里塞会变成 0)、非空约束违反(用默认值填充)、外键约束失败的部分情况。这些在正常insert里都会直接报错,被 ignore 之后悄无声息地进了库,等业务发现数据不对,已经过去好几天了。
我的一般原则是:只有在你百分百确定整条语句除了唯一键冲突之外不可能出别的错时,才用它。更稳妥的做法是用ON DUPLICATE KEY UPDATE,或者干脆老老实实捕获异常。
另外一个容易忽略的点是性能。INSERT IGNORE遇到冲突时虽然不报错,但冲突检测本身要付出代价,在高并发且冲突率很高的场景下,反而比“先不管、报错再重试”的方式更慢,因为它把冲突处理放在了数据库内部,你失去了批量和退避的机会。
3.2 ON DUPLICATE KEY UPDATE:能更新才算真正的幂等
这是我最常用的写法,因为它能表达“冲突了该怎么办”,而不只是“冲突了就跳过”。
insert into user_sign_record (user_id, sign_date, sign_count, update_time) values (1001, '2024-01-01', 1, now()) on duplicate key update sign_count = sign_count + 1, update_time = now();这条语句的含义是:如果(user_id, sign_date)不存在就插入,存在就把签到次数加一。整个过程只有一次往返,数据库内部保证原子性,这才是真正的幂等写入。
不过它有几个必须知道的坑。
坑一:它会同时消耗自增值。即使最终走了更新分支,自增主键的序列也已经被推进了。如果这张表冲突率很高,自增 ID 会以肉眼可见的速度膨胀,几十万条数据的主键能跑到几千万。对 int 类型的自增列来说,这是个真实的溢出风险。解决办法是改用bigint,或者干脆不用自增主键做业务主键。
坑二:影响行数的语义很奇怪。插入返回 1,更新返回 2,更新了但值没变返回 0。如果你的代码里用if (affectedRows == 1)来判断是新插入的,那就会把“更新但值没变”误判成失败。这个细节我在 ORM 里踩过不止一次,下面第 5 节会专门讲。
坑三:并发时可能死锁。多个事务同时执行同一条ON DUPLICATE KEY UPDATE,如果涉及不同的唯一键,可能互相等待对方持有的间隙锁。这个问题在批量插入里尤其明显。
3.3 INSERT ... SELECT ... WHERE NOT EXISTS:灵活但代价高
当去重条件不能简单用唯一索引表达时(比如跨表判断、或者需要更复杂的业务条件),这种写法就派上用场:
insert into order_flow (biz_no, amount, create_time) select 'B20240101001', 199.00, now() from dual where not exists ( select 1 from order_flow where biz_no = 'B20240101001' );它的优点是表达能力强,where里可以塞任意条件。缺点也很明显:它是两条语句的合体,在默认的隔离级别下,“检查”和“插入”之间依然存在窗口,如果没有唯一索引兜底,它同样拦不住并发重复。很多资料说它安全,其实是忽略了并发场景。
还有一点是锁。在可重复读隔离级别下,select ... where not exists这种“当前读”会在扫描的索引区间上加间隙锁,如果表上没有合适的索引,扫全表就锁全表,高并发下就是排队等锁。我见过一条这样的语句把整张表锁住的案例,最后靠杀掉会话才恢复。
所以这种写法的定位很清楚:适合低频、单条、并发压力小的场景,比如后台管理系统的表单提交。高并发的主链路,还是老老实实用唯一索引 +ON DUPLICATE KEY UPDATE。
3.4 REPLACE INTO:删除重建的隐藏费用
REPLACE INTO看起来是最优雅的:存在就替换,不存在就插入。实现方式其实是“先 DELETE 再 INSERT”,这个内部机制带来了一系列副作用。
第一,它会触发 DELETE 触发器,任何挂在删除事件上的逻辑(比如审计日志、级联清理)都会被执行。第二,它会级联删除子表记录,如果子表有外键指向它,那些子表数据会被一起清掉,然后因为新插入的行 ID 变了,子表数据也找不回来。第三,自增主键会变,如果业务上有其他地方引用了这个 ID(比如缓存的 key、消息队列里的 ID),就全部失效了。第四,二级索引需要重建,写入开销比ON DUPLICATE KEY UPDATE高。
我的结论是:REPLACE INTO只适合“这条记录就是整体覆盖,被引用关系完全不存在”的场景,比如配置表、字典表这种被其他表引用的可能性极低的表。业务主表一律不用。
3.5 四种写法怎么选:一张表说清楚
| 写法 | 冲突时行为 | 是否删旧行 | 自增 ID 消耗 | 对触发器/外键的影响 | 推荐场景 |
|---|---|---|---|---|---|
INSERT IGNORE | 跳过该条,不报错 | 否 | 冲突时会消耗 | 无 | 增量同步、日志采集,且能保证无其他错误 |
ON DUPLICATE KEY UPDATE | 按指定逻辑更新 | 否 | 冲突时会消耗 | 只触发更新触发器 | 幂等写入主链路,绝大多数场景首选 |
INSERT ... WHERE NOT EXISTS | 不插入 | 否 | 不消耗 | 无 | 低频、后台操作,且无并发压力 |
REPLACE INTO | 先删后插 | 是 | 每次都消耗 | 触发删除和插入触发器,级联删除 | 无被引用关系的配置类表 |
跨数据库产品的话,语法差异比较大。较新的 PostgreSQL 版本支持ON CONFLICT DO NOTHING和ON CONFLICT DO UPDATE,语义和ON DUPLICATE KEY UPDATE接近,但写法不同;标准 SQL 里有MERGE INTO,表达力最强但语法啰嗦,不同产品实现还不完全一致。如果你的项目要跨库迁移,这层差异最好封装在 DAO 层里,别让它散到业务代码中。
4. 手把手落地:从建表到批量插入的完整流程
4.1 把约束写进建表语句
先给出一张示例表的完整定义,这里的每个细节都有讲究:
create table user_sign_record ( id bigint unsigned not null auto_increment, user_id bigint not null comment '用户ID', sign_date date not null comment '签到日期', sign_count int not null default 1 comment '当日签到次数', create_time datetime not null default current_timestamp, update_time datetime not null default current_timestamp on update current_timestamp, primary key (id), unique key uk_user_date (user_id, sign_date), key idx_sign_date (sign_date) ) engine=InnoDB default charset=utf8mb4 comment='用户签到记录';几个关键决策我解释一下。主键用bigint unsigned而不是int,就是为了防止上面提到的自增膨胀导致溢出,这是血的教训。user_id和sign_date都声明为NOT NULL,避免 NULL 破坏唯一性,前面第 2.2 节已经讲过原因。唯一索引命名为uk_user_date,字段顺序是user_id在前,因为按用户查询是主要访问路径,这个顺序能同时服务唯一约束和查询。sign_date上单独加了一个普通索引,是为了支撑按日期统计的报表查询。
还有一个隐含决策:唯一索引不要包含update_time这种会变的字段。如果唯一索引里含了可变列,那么每次更新这些列都可能触发唯一冲突,后患无穷。
4.2 单条幂等写入的实现
最直接的写法就是ON DUPLICATE KEY UPDATE。以 Java + MyBatis 为例,Mapper 里这样写:
<insert id="upsertSign" parameterType="map"> insert into user_sign_record (user_id, sign_date, sign_count) values (#{userId}, #{signDate}, 1) on duplicate key update sign_count = sign_count + 1, update_time = now() </insert>调用方不用关心是新插还是更新,一次调用搞定。如果业务上需要区分“首次签到”和“重复签到”给不同的响应,就得靠影响行数或者事后查询,这部分第 5.2 节细说。
如果是 Python 项目,用参数化查询,别拼字符串:
cursor.execute( """ insert into user_sign_record (user_id, sign_date, sign_count) values (%s, %s, 1) on duplicate key update sign_count = sign_count + 1, update_time = now() """, (user_id, sign_date), )如果确实只能用“先查后插”(比如冲突后的处理逻辑特别复杂,SQL 写不下),那就必须在应用层加锁,或者接受偶尔失败、用重试兜底。这里的关键是捕获到唯一键冲突异常不能当成错误往上抛,应该当作“这条已经存在”的正常信号来处理:
try { jdbcTemplate.update("insert into order_flow (biz_no, amount) values (?, ?)", bizNo, amount); } catch (DuplicateKeyException e) { log.info("记录已存在,跳过 bizNo={}", bizNo); }这段代码比“先查后插”可靠得多,因为它只依赖一次原子操作,冲突由数据库判断,不存在窗口期。
4.3 批量插入的三种写法和实测感受
批量场景最考验设计。做法大致三种。
第一种:多值 INSERT + ON DUPLICATE KEY UPDATE。
insert into order_flow (biz_no, amount, create_time) values ('B001', 100.00, now()), ('B002', 200.00, now()), ('B003', 300.00, now()) on duplicate key update amount = values(amount), update_time = now();注意values(col)这个函数在新版本里被标记为不推荐,更好的写法是给参数起别名,不同版本语法略有差异,升级前记得查一下当前版本的支持情况。
第二种:批量 INSERT IGNORE。语法最简单,但前面说过的问题都在,适合纯增量同步。用的时候建议在应用层先把明显不合法的数据过滤掉,减少被“静默忽略”的概率。
第三种:应用层先本地去重,再批量写。这批数据里如果有重复的biz_no,先用内存里的Set去一遍重,再发给数据库。这么做的原因很实际:如果一批 500 条里有 50 条是批内重复的,不先去重的话,数据库要处理 50 次冲突检测,白白浪费性能;先去重后只发 450 条,数据库的压力和网络包的大小都降下来了。
我实测过一组数据,5000 条订单,冲突率 20% 左右。纯逐条INSERT IGNORE耗时大概 4.2 秒;改成 500 条一批的多值ON DUPLICATE KEY UPDATE,加上应用层本地去重,耗时降到 0.6 秒左右。差距主要来自往返次数和冲突检测次数。批大小也不是越大越好,我一般控制在 200 到 500 之间,太大容易撞上max_allowed_packet限制,事务也变长,锁持有时间增加,得不偿失。
注意:批量写入如果放在一个大事务里,锁会一直持有到提交。批量导入这种场景,我一般会关掉自动提交、每批单独提交,或者干脆用“关闭唯一性检查→导入→重建索引”的方式处理全量初始化数据。后者的代价是导入期间没有唯一性保护,所以只适合能停写的初始化阶段。
4.4 应用层的兜底策略
即使数据库有唯一索引,应用层也不能完全躺平。我一般会准备两层兜底。
第一层是异常捕获 + 有意义的日志。捕获到唯一键冲突时,把冲突的索引名、冲突的键值都打进日志,方便排查。很多 ORM 抛出的异常里只带了一句“Duplicate entry”,不带具体字段,这会给线上排障带来麻烦。有必要的话,可以在异常处理里补一次查询,把冲突的记录查出来打日志。
第二层是有限次重试。有些冲突是因为网络抖动导致的部分提交,或者因为读写分离的主从延迟导致的误判(从库还没同步到刚插入的数据,应用查不到就以为不存在)。这种场景下的重试是有意义的,但一定要有次数上限和退避策略,否则会把数据库打得更惨。我的经验是重试 2 到 3 次,间隔 50 到 100 毫秒。
第三层,如果业务对幂等要求极高(比如支付回调),可以在写入前用分布式锁或者幂等表做一层保护,但这些都是“加分项”,不是“必需品”。唯一索引是必需品,其他都是优化。
5. 并发场景下必须盯住的几个细节
5.1 死锁和间隙锁:并不是加了唯一索引就万事大吉
唯一索引解决的是“数据正确性”,解决不了“并发性能”。ON DUPLICATE KEY UPDATE在高并发下是可能死锁的,尤其是批量插入、且批内数据涉及不同唯一键的时候。
原因在于插入时需要做唯一性检查,检查过程会对相关索引区间加锁。两个事务 A 和 B 分别插(1, '2024-01-01')和(2, '2024-01-02'),看起来毫不相干,但如果它们在同一个索引区间内,就可能互相持有对方需要的锁。批量语句里既有(1, ...)又有(2, ...)的时候,不同事务的执行顺序不一致,就形成了环。
应对办法有几个。一是让批内数据按唯一索引字段排序后再写,这样所有事务的加锁顺序一致,能大幅降低死锁概率。二是减小批大小,缩短单次锁持有时间。三是捕获死锁后重试,很多数据库的死锁错误是可以通过重试解决的,因为其中一个事务已经被回滚了。四是检查隔离级别,如果不是必须,考虑降低隔离级别减少间隙锁,但要评估对业务一致性的影响。
还有一个隐蔽的坑是唯一索引冲突导致的锁等待超时。当两个事务在同一个唯一键上竞争时,后到的那个会等待前一个提交,这个等待时间受innodb_lock_wait_timeout控制。如果前一个事务里还有别的慢操作,等待时间就会拉长,最终超时。所以写入事务里别塞太多不相干的操作。
5.2 影响行数的语义差异会坑到 ORM
这个细节值得单独讲,因为它太容易出 bug。ON DUPLICATE KEY UPDATE的返回含义是这样的:
| 场景 | 返回影响行数 |
|---|---|
| 新插入一条 | 1 |
| 冲突并实际更新了数据 | 2 |
| 冲突但更新的值和原来完全一样 | 0 |
也就是说,返回 0 既可能是“什么都没做”,也可能是“做了但结果没变”。如果你的代码写的是if (affectedRows > 0) { 认为成功 },那么在“值没变”的情况下就会误判为失败,导致上层重试或者报错,反而制造了问题。
我一般在写这种语句时,让update分支至少更新一个必然会变的字段,比如update_time = now()或者sign_count = sign_count + 1,这样就不可能出现“值没变”的情况,影响行数也就稳定了。如果业务上不允许这样做(比如不想让时间戳变化),那就别依赖影响行数做判断,改成写之前查一次,或者用数据库的RETURNING之类的特性(不是所有产品都支持)。
INSERT IGNORE的影响行数是 1 或 0,语义清晰,这点上反而比ON DUPLICATE KEY UPDATE好用。REPLACE INTO返回 1 或 2(删了一行再插一行算 2),这个 2 不代表你更新了两条记录,别被绕进去。INSERT ... WHERE NOT EXISTS返回 0 或 1,也没问题。
5.3 自增主键跳跃和分片下的唯一性
自增主键跳跃的问题前面提过,这里再说两个延伸场景。
一是自增 ID 消耗导致的容量规划失误。假设一张表预估每天 10 万条数据,按自增 ID 规划了int类型(上限约 21 亿),理论上能撑 5 年多。但如果这张表的冲突率是 50%(比如幂等写入场景),实际自增消耗是每天 20 万,寿命直接砍半。更极端的情况是有人用“插入失败就重试”的循环刷数据,自增 ID 会以几十倍的速度狂奔。所以幂等场景的表,主键一定要用bigint,别省这点空间。
二是分库分表下的唯一性。如果你的表已经水平拆分,唯一索引只能在单个分片内保证唯一,跨分片是拦不住的。这时候有两种做法:用中间件做全局唯一约束(代价较高),或者用分片键参与唯一索引,把同一个业务对象的写入路由到同一个分片。第二种更常见,前提是分片键选得对,比如用user_id做分片键,唯一索引设计成(user_id, biz_no),同一用户的写入必然落在同一分片,唯一性就能保证。
还有一个容易被忽略的情况:唯一索引的长度限制。某些数据库对索引总长度有上限(和字符集有关),utf8mb4编码下每字节占用更多,组合索引的字段加起来太长会建不上。如果确实需要长字段做唯一约束,可以考虑存哈希值(比如对长字符串取摘要后存成定长字段),在哈希列上建唯一索引,代价是需要接受极低概率的哈希碰撞,以及对原值做二次校验。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 现象 | 大概率原因 | 排查动作 | 处理办法 |
|---|---|---|---|
| 明明建了唯一索引,还是出现重复 | 索引包含可空列,NULL 值不参与比较 | 查information_schema.columns看is_nullable | 字段改NOT NULL,或调整索引设计 |
ALTER TABLE加唯一索引失败 | 存量数据已有重复 | group by ... having count(*) > 1查重 | 先清洗数据,再建索引 |
INSERT IGNORE之后数据异常但没报错 | 截断、类型错误被静默忽略 | 查数据库警告日志 | 改用严格模式或ON DUPLICATE KEY UPDATE |
| 批量写入偶尔超时或死锁 | 批内顺序不一致,或批太大 | 查看死锁日志中的事务持有锁信息 | 批内排序、减小批大小、加死锁重试 |
| ORM 判断插入成功但实际没写 | 影响行数语义误解(返回 0 的情况) | 打印实际影响行数 | 让更新分支必然改值,或不依赖行数判断 |
| 冲突率高的表自增 ID 暴涨 | 幂等写入消耗自增值 | 对比写入量和 ID 增长量 | 主键改bigint,或改用业务主键 |
| 从库上查不到刚写入的数据 | 主从延迟 | 对比主从延迟监控 | 写后读走主库,或加重试 |
6.2 几个文档里不会写的实操心得
先看业务语义,再选技术方案。“重复”这两个字在不同业务里含义完全不同。有的业务里重复意味着“完全相同的记录出现两次”,有的业务里意味着“同一业务主键出现两次但其他字段可以不同”。前者适合用全字段唯一索引,后者适合用业务主键唯一索引 + 更新逻辑。选错了索引,后面所有的写法都是错的。
唯一索引的报错信息要自己加工。数据库原生的报错通常是Duplicate entry 'B001' for key 'uk_biz_no',直接抛给前端就是一堆看不懂的东西。我一般在全局异常处理里解析索引名,映射成用户能理解的文案,比如“该订单已提交过,请勿重复操作”。这个映射表要跟着索引变更同步维护,否则会误导用户。
别在唯一索引里放会频繁更新且区分度低的字段。比如把status放进唯一索引,本意是“同一业务单号在同一状态下只能有一条”,结果每次状态流转都要先删后插,索引维护成本陡增。这种需求更适合用应用层的状态机来控制。
测试环境要造并发。“先查后插”的问题在单线程测试里永远复现不了。我一般在预发环境用压测工具对同一个业务键发 100 个并发请求,看看最终会写进去几条。这个方法简单粗暴,但非常有效,能在上线前把问题暴露出来。
存量数据清洗一定要留后路。备份表命名带上日期,删之前先select count(*)确认数量,删完立刻核对待处理记录数是否归零。有条件的话,把整个清洗过程做成可回滚的脚本,出问题能一键恢复。
监控要盯冲突率。如果冲突率突然从 1% 涨到 30%,那绝对不是“业务正常波动”,而是上游有重复调用或者重试风暴。我在项目里会给唯一键冲突打点,按索引名做维度统计,这个指标救过我好几次——有一次是上游的重试机制没有做退避,导致同一个请求被打了十几次。
6.3 我的实际体会
说一个我自己的教训。早年我很迷信“代码里做检查更灵活”,写过一堆“先查后插 + 分布式锁”的方案,觉得这样可控、能加日志、能定制提示。后来发现,锁的粒度、锁的释放时机、锁失效后的重试,每一个环节都是新的 bug 来源,而这些复杂度换来的,只是一个本来由唯一索引一行 DDL 就能解决的问题。
现在我落地的顺序非常固定:先把唯一性规则用唯一索引钉死在表结构里,再用ON DUPLICATE KEY UPDATE或者捕获异常的方式处理冲突,最后才考虑要不要在应用层做优化。这个顺序颠倒过来,问题只会越来越多。至于“插入前需要做复杂业务校验”这类需求,那是业务逻辑层的事,跟“这条记录能不能重复存在”是两回事,不要混在一起。
如果你手上正好有一张正在被重复数据困扰的表,我的建议是今天就去做三件事:查一遍所有应该有唯一约束但没有的字段、跑一遍重复数据统计、把“先查后插”的写入入口列出来。这三件事做完,你大概就知道自己的系统里还埋着多少颗雷了。