news 2026/11/3 13:01:08

数据库 INSERT 防重复:唯一约束、并发幂等与存量清理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数据库 INSERT 防重复:唯一约束、并发幂等与存量清理

数据库里最容易被低估的一类事故,就是重复数据。它不像主键冲突那样当场报错让你知道出事了,而是悄悄躺在表里,等你某天做统计发现订单金额翻倍、用户数虚高、报表对不上账,才回头去翻是哪段代码写漏了判断。标题说的是"使用 insert 语句避免向表中插入重复数据",但真正要解决的问题远比一句 SQL 复杂:你到底想避免的是完全相同的整行重复,还是业务意义上的重复?是在写入前拦,还是写完再兜底?并发场景下你的判断还成立吗?这些问题的答案不同,写法完全不一样。

我这些年处理过的重复数据问题,粗分有两类。一类是源头没管住,定时任务重跑一遍、消息重投一次、用户把提交按钮点了三下,数据就多了一份;另一类是历史遗留,早年没有约束、没有唯一索引,几年积累下来一堆脏数据,清完之后还得防止再次发生。这两种情况的处理思路差别很大,前者重点在写入路径上的判断与约束,后者重点在清理策略和补约束的顺序。下面按我实际操作的顺序,把这件事从表结构设计到SQL写法、再到并发与批量场景,一层层拆开讲。

1. 先分清"重复"的三种含义,否则约束一定建错

动手写任何一句 insert 之前,我会先逼自己回答一个问题:这张表里,什么样的两行算重复?这个定义如果含糊,后面唯一索引建错、判断条件写错,全是白费功夫。实际工作中,"重复"至少有三层不同的含义,混在一起谈必然出问题。

1.1 整行完全一致:最低级的重复,也是最好处理的

整行所有字段值完全相同,这种重复通常来自重复执行、重复提交、数据导入时源文件本身有重复行。它的特征是没有任何信息量,多出来的那一行不携带任何新内容,纯属冗余。这种情况判断简单,但代价是字段多的时候比对开销大,而且一旦表里有自增主键或者创建时间这类每次都不同的字段,整行就永远不会"完全一致",判断直接失效。

所以处理整行重复时,我一般不看整行,而是先确认哪些字段是"业务主键"——也就是真正决定这一行身份的那几个字段。比如一张记录某类配置项的表,配置名加配置所属分组就足以定位唯一一行,其余字段都是描述性的。找到这组字段,后面的问题就变成"业务唯一键重复"了。

1.2 业务唯一键重复:真正需要严防的一类

这是实际项目里出现频率最高、危害也最大的一类。两行数据在物理层面可能只有时间戳不同,但在业务语义上它们指的是同一件事:同一个用户对同一个商品下了两次相同的单、同一条流水被记了两遍、同一个手机号注册了两个账号。这类重复不会因为字段有差异就被放过,它必须由业务规则来定义。

判断业务唯一键的方法是问:"如果这两行同时存在,业务上会不会产生错误结果?"如果答案是会,那这组字段就该被约束住。举个具体例子,一张签到记录表,用户ID加日期构成业务唯一键,同一个人同一天只能有一条签到,多一条就意味着积分多发一次,这就是实打实的资损风险。再比如对账流水表,交易单号唯一,重复入库会导致对账金额虚增。

1.3 近似重复:最麻烦的一类,SQL 约束解决不了

还有一种重复要单独拎出来说,就是近似重复。比如同一个客户被录入了两次,一次叫"某某科技有限公司",一次叫"某某科技有限责任公司";同一个地址写成"XX路 18 号"和"XX路18号"。这类重复在字段层面并不相等,唯一索引拦不住,需要在写入前做规范化,或者用相似度匹配做定期巡检。

我的处理原则是:能在写入前规范化的,就在应用层做清洗(去空格、统一全半角、统一大小写、地址标准化),把近似重复转化成完全重复,再交给数据库约束去拦;确实无法标准化的(比如人名、公司名的简称差异),就别指望唯一索引,改用定期跑相似度任务做人工复核。硬把这类问题塞给数据库约束,最后只会导致正常数据被误拦,或者为了绕过约束而给字段加各种奇怪的拼写。

把这三类分清楚之后,你就知道该建什么约束、该在哪个环节拦了。下面所有的讨论都聚焦在第二类,也就是业务唯一键重复,因为它最常见、也最值得用数据库层面的机制来保证。

2. 表结构上的第一道防线:唯一约束到底该怎么建

很多人一提到防重复就想到"插入前先 select 查一下",这是最容易想到但最不可靠的做法。真正该先做的,是把唯一性写进表结构里。数据库的唯一索引和唯一约束是最后一道防线,它不依赖应用代码写得对不对,也不怕并发,是唯一能百分之百保证数据不重复的手段。

2.1 唯一索引与唯一约束的差别,别被名词绕晕

在多数关系型数据库里,唯一约束(UNIQUE CONSTRAINT)和唯一索引(UNIQUE INDEX)在实现上基本等价,建唯一约束时数据库会自动创建一个唯一索引。它们的关键差别在于语义和管理方式:约束是表定义的一部分,表达"这条规则不能被违反";索引更多是性能结构,虽然它同样能阻止重复。我的习惯是,业务规则层面的唯一性用约束来声明,纯粹为了加速查询而需要的唯一性用索引,这样看表结构的人一眼能明白哪些字段是业务主键。

建约束的时机也有讲究。如果表里已经有大量重复数据,直接建唯一索引会失败。正确的顺序是:先查重、清理、再建约束,而且这三步之间要尽量缩短窗口,最好在低峰期做,并对写入做短暂的控制,否则清理完到建约束之间的这段时间,新数据又可能插进来。我的做法是清理前先记下重复键的快照和数量,清理后立刻建约束,建完再核对一遍快照里的键是否都已唯一。

-- 查重:找出业务唯一键出现多次的组合 SELECT user_id, sign_date, COUNT(*) AS cnt FROM user_sign_record GROUP BY user_id, sign_date HAVING COUNT(*) > 1 ORDER BY cnt DESC; -- 清理:保留每组里主键最小的一条,其余删除 DELETE FROM user_sign_record WHERE id NOT IN ( SELECT keep_id FROM ( SELECT MIN(id) AS keep_id FROM user_sign_record GROUP BY user_id, sign_date ) AS t ); -- 建约束:业务唯一键 ALTER TABLE user_sign_record ADD CONSTRAINT uk_user_date UNIQUE (user_id, sign_date);

这段 SQL 里的删除写法需要多说一句。用NOT IN配合子查询在部分数据库版本里性能较差甚至行为异常(尤其是子查询里出现 NULL 时,NOT IN会返回空结果,导致一条都删不掉),更稳的写法是用JOIN或者NOT EXISTS。另外,删除前务必备份,我踩过一次坑:分组键选错,把本该保留的不同记录当重复删了,好在有备份。

-- 更稳妥的删除写法:JOIN + 派生表 DELETE a FROM user_sign_record a JOIN ( SELECT user_id, sign_date, MIN(id) AS keep_id FROM user_sign_record GROUP BY user_id, sign_date ) b ON a.user_id = b.user_id AND a.sign_date = b.sign_date AND a.id <> b.keep_id;

2.2 多列唯一键的字段顺序不是随便排的

建多列唯一索引时,字段顺序会影响索引的可用性。唯一性判断本身和顺序无关,但如果你还指望这个索引顺带加速查询,顺序就必须按"最常用、选择性最高"的字段排在前面。比如一张订单明细表,唯一键是订单号加商品行号,日常查询几乎都是按订单号过滤,那订单号就必须排第一。

顺序还有另一个影响:覆盖范围。多列唯一索引 (a, b) 同时也能加速只按 a 查询的场景,但不能加速只按 b 查询的场景。所以如果你的业务里经常单独按某一列查,考虑给它单独建索引,而不是把它塞进唯一键的后面指望它起作用。

场景建议做法说明
只需防重复,查询很少按业务语义排列字段顺序不影响查重
防重复且常按首列查询高选择性列排第一可复用索引
防重复且常单独查后续列后续列单独建普通索引唯一索引无法覆盖
字段很长(如 URL)用前缀索引或加哈希列避免索引超长

最后一行值得展开。有些字段本身很长,直接建唯一索引会导致索引体积过大甚至超过限制。常见的做法是额外加一列,存该字段的哈希值(比如对规范化后的内容做固定长度的摘要),在哈希列上建唯一索引,原始字段正常存。注意哈希存在理论碰撞概率,所以实践里通常哈希列唯一索引加上业务校验双保险,而不是只靠哈希。

2.3 约束建好之后,插入报错怎么处理才算优雅

唯一约束建好之后,重复插入就会直接报错。这时候很多人犯的第二个错,是把报错当成异常往上抛,结果用户点了一次提交,第二次收到一个 500。约束是兜底,不是交互手段,正确做法是在写入逻辑里主动处理这个错误,把它翻译成"已存在,跳过"或"已存在,更新"的语义。

不同数据库的错误码不一样,直接匹配错误文本很脆弱,版本一升级提示语变了就失效。我一般封装一层统一处理,把底层的唯一冲突映射成应用层的固定错误类型,业务代码只判断这个类型。下面这种封装思路在各语言里都通用:

class DuplicateKeyError(Exception): pass def insert_or_ignore(conn, sql, params): try: with conn.cursor() as cur: cur.execute(sql, params) conn.commit() except Exception as e: conn.rollback() if is_unique_violation(e): # 各数据库按错误码判断 raise DuplicateKeyError("记录已存在") from e raise

这样做的好处是业务层可以精确地决定:签到重复就提示"今天已经签过了",而不是报一个看不懂的数据库错误。同时约束仍然是最后防线,任何绕过业务逻辑的写入路径(比如内部脚本、数据修复工具)都会被它拦住。

3. 插入语句的几种写法:从先查后插到原子操作

约束是防线,但很多场景你希望在插入时不产生错误、直接优雅地处理重复,这时候就要用到不同数据库提供的插入语法。这些语法看起来只是写法差别,其实背后对应的加锁行为和并发表现完全不同。

3.1 先查后插的致命缺陷:并发下的经典竞态

"先 select 判断不存在,再 insert"是绝大多数人写的第一版代码,逻辑上很直观,但它有一个无法回避的问题:查和插之间不是原子的。两个请求同时进来,都查到"不存在",然后都执行插入,最后两条都成功——约束拦住了会报错,没约束就直接产生两条重复数据。

这个窗口期可能很短,但在高并发下必然会被命中,尤其是接口被脚本刷、消息被重投、定时任务重叠执行的时候。我见过最典型的一个案例:某个每日结算任务,因为上一次执行超时没结束,第二次被调度器又拉起来,两个实例同时跑,先查后插的判断在两边都通过,结果一整天的结算记录全变成双份。这类问题的根因不是代码写得丑,而是查和写之间的时间差本身就是风险。

所以先查后插不是不能用,但它的适用场景非常有限:单线程、无并发、或者写入频率极低的配置类数据。任何有并发写入可能的表,都不应该只依赖它。

3.2 INSERT IGNORE / ON CONFLICT DO NOTHING:跳过而不报错

各主流数据库都提供了"冲突就跳过"的语法。它的语义是:当插入因为唯一约束冲突时,不报错,直接忽略这一行。

-- 冲突时静默跳过 INSERT INTO user_sign_record (user_id, sign_date, points) VALUES (1001, '2025-06-01', 10) ON CONFLICT (user_id, sign_date) DO NOTHING;

这里有几个必须注意的点,都是实际踩出来的:

第一,DO NOTHING只在指定了冲突目标时才是精确的。如果用不指定冲突列的形式(某些数据库里INSERT IGNORE就是不指定目标),那么它不仅会忽略唯一冲突,还会忽略其他类型的错误,比如字段超长被截断、非空字段被写入默认值、类型转换失败。这类"静默降级"极其危险,因为数据被悄悄改掉了你都不知道。我强烈建议:能用指定冲突目标的形式就用指定形式,不要图省事。

第二,跳过的返回值需要判断。在很多驱动里,跳过时受影响行数是 0,正常插入是 1。业务代码必须根据这个判断后续要不要做别的动作(比如跳过时不发积分、不触发通知)。如果不管返回值直接往下走,会出现"数据没插进去但后续流程照跑"的诡异问题。

第三,DO NOTHING不会告诉你冲突的是哪一条已有记录。如果你需要拿到已有记录的 ID 做后续关联,就得用下一节的写法。

3.3 ON CONFLICT DO UPDATE / ON DUPLICATE KEY UPDATE:冲突时改为更新

当业务语义是"存在就更新、不存在就插入"时,用冲突转更新的写法最合适。它的典型应用是幂等写入、计数器累加、状态覆盖。

-- 冲突时更新已有行 INSERT INTO user_sign_record (user_id, sign_date, points, updated_at) VALUES (1001, '2025-06-01', 10, NOW()) ON CONFLICT (user_id, sign_date) DO UPDATE SET points = EXCLUDED.points, updated_at = EXCLUDED.updated_at;

EXCLUDED代表"本次试图插入的那一行",用它可以把新值引用进来。这个写法有几个容易出问题的细节:

一是更新子句里的表达式如果引用原表字段,会有歧义。比如SET points = points + 10,这里的points到底指原行还是新行,不同数据库、不同写法下解释不同,稳妥的是显式写表名限定原行、EXCLUDED限定新行。

二是这个写法会触发行级锁,冲突发生时已有行被锁住,并发写入同一个键的请求会排队。这在计数器场景下是好事(保证累加正确),但在高频写入同一键的场景下可能成为瓶颈,甚至引发锁等待超时。如果同一个键每秒要被写几百次,就要考虑是不是该把这个键拆开(比如分片累加后再合并)。

三是更新会带来副作用:触发器、更新时间、审计字段都会被改动。如果这一行的更新时间有业务含义,要注意区分"真正被修改"和"只是重复写入"。

3.4 先插入再补救:插入失败后转为查询

还有一种写法是"乐观插入":直接 insert,捕获唯一冲突,然后改为查询已有记录。这种写法在需要拿到已有记录完整信息的场景下很常用,比如"注册或登录"的合并接口。

def ensure_user_profile(conn, user_id, nickname): try: with conn.cursor() as cur: cur.execute( "INSERT INTO user_profile (user_id, nickname) VALUES (%s, %s)", (user_id, nickname) ) conn.commit() return "created" except Exception as e: conn.rollback() if not is_unique_violation(e): raise # 已存在,转为查询 with conn.cursor() as cur: cur.execute( "SELECT user_id, nickname FROM user_profile WHERE user_id = %s", (user_id,) ) row = cur.fetchone() return "exists"

这种模式的好处是正常路径只有一次数据库往返,冲突路径才多一次查询;代价是冲突频繁时会产生大量失败的插入尝试,而失败的插入也会消耗序列、写入日志、可能留下间隙锁。所以它适合"冲突率低"的场景,冲突率高的时候不如直接查。

我把上面几种写法整理成一张对照表,选型时直接对照看:

写法冲突时行为额外往返能否拿到已有行适用场景
先查后插判断后插入2 次能无并发、低频写入
冲突跳过不报错,跳过1 次不能幂等写入、日志上报
冲突转更新更新已有行1 次部分数据库可返回状态覆盖、计数器
乐观插入+查询报错后转查询正常 1 次,冲突 2 次能冲突率低的注册类场景

4. 并发与批量场景下,那些单条语句解决不了的问题

单条插入的防重复,靠约束加合适的语法基本能覆盖。真正难的是批量写入和并发写入,这两个场景会把单条语句里被掩盖的问题全部放大。

4.1 批量插入的重复:源内重复和库内重复是两回事

批量插入的重复分两种,必须分开处理。一种是"批次内部自己就有重复",比如导入的 CSV 里同一个用户出现了两行;另一种是"批次与库里已有数据重复"。第一种在插入前就该去重,第二种才交给冲突处理。

批次内部去重看起来简单,其实有坑。如果你在应用层用集合去重,要注意对象的相等判断逻辑:按什么字段判等、大小写是否敏感、空值怎么比较。如果交给 SQL,可以用分组或者窗口函数先筛。我倾向于在应用层做完去重再入库,因为这样能顺便记录"源数据里有多少条重复",方便排查上游问题。

至于批次与库内重复,批量插入时如果整批用一条多值 insert 语句,一旦其中一行冲突,整条语句通常会失败,导致一批全部回滚。这时候有两个思路:

一个思路是拆小批,每批几十到几百条,冲突行单独处理。这样失败影响面小,重试成本低。另一个思路是使用支持"冲突跳过/更新"的批量语法,在一条语句里对所有行做冲突处理。

-- 批量插入并处理冲突(冲突则更新) INSERT INTO product_stock_daily (product_id, stat_date, sold_count, updated_at) VALUES (2001, '2025-06-01', 35, NOW()), (2002, '2025-06-01', 12, NOW()), (2003, '2025-06-01', 48, NOW()) ON CONFLICT (product_id, stat_date) DO UPDATE SET sold_count = EXCLUDED.sold_count, updated_at = EXCLUDED.updated_at;

这里有个很重要的细节:批量插入的多行之间如果本身存在重复键,在一条语句内做冲突更新时,同一行的行为取决于数据库实现,有的会报"无法影响同一行两次",有的会静默取最后一条。所以批量前一定要先在批次内去重,不要指望数据库帮你摆平。

4.2 失败重试与幂等:谁该负责去重

很多人把防重复完全寄托在数据库上,其实真正的第一责任人应该是最靠近数据源的那一层。消息队列重投、接口超时重试、调度器重叠执行,这些才是重复数据的源头。如果每个入口都能保证幂等(同一个请求重复执行结果一致),数据库约束就只是最后的安全网,而不是主力。

我的做法是给每个写入请求带一个业务幂等键(比如请求流水号、消息ID),在写入时用它作为唯一约束的一部分,重复请求自然被拦下。这样即使上层重试十次,数据也只有一条。注意幂等键要有合理的过期或归档策略,否则这张表会无限膨胀。

还有一种情况:写入过程包含多个步骤(写主表、写明细、发通知),中途失败重试会导致部分步骤重复执行。这时候单纯的唯一约束只能防住某一张表的重复,跨表的一致性需要靠事务或者状态机来控制。我的经验是把"是否已处理"做成显式状态,写入前检查状态、处理完更新状态,重复进入时直接命中已处理状态短路返回。

4.3 分布式环境下的唯一性:数据库约束的边界在哪

单库单表里唯一约束是可靠的,但一旦涉及分库分表,情况就变了。唯一约束只在单个分片内生效,两个分片之间无法互相约束。如果业务唯一键的分片规则和唯一键本身不一致(比如按用户ID分片,但唯一键是订单号),那跨分片的重复就拦不住。

常见的解决思路有三种:一是让分片键包含唯一键的一部分,保证同一个唯一键落在同一分片,约束才能生效;二是引入一个独立的去重层(比如以唯一键为键的缓存或存储,写入前先占位);三是靠全局ID生成机制,从源头保证ID不重复,然后用ID做主键约束。

第三种最彻底但约束最强,要求所有写入都走统一的ID生成;第二种灵活但多了一层依赖,占位服务和数据库之间还要处理一致性;第一种成本最低,但对分片设计有前置要求。我一般优先考虑第一种,实在不行再上第二种,并且一定要给占位记录设置合理的清理周期,避免去重表本身成为故障点。

无论用哪种方案,都要清楚分片环境下的唯一性是有边界的。不要在分片表上建了唯一索引就以为万事大吉,先确认分片规则能不能保证同一唯一键落同一片。

5. 把重复数据拦在门外之后,存量脏数据怎么清

前面讲的都是防止新增重复。现实中更头疼的是表里已经躺着大量重复数据,清的时候还得保证业务不中断、清理过程可回滚。这部分我把它单独拎出来,因为清理和预防的思路完全不同。

5.1 清理前先做一次"重复画像",别急着删

看到重复就写 delete 是最危险的做法。我习惯先做一次重复画像,搞清楚四件事:重复涉及多少行、重复的键有多少组、每组最多重复几次、重复行的其他字段是否有差异。这四个数据决定了清理策略。

如果同一组里的重复行其他字段完全一致,直接保留一条删其余,风险很低。如果其他字段有差异,就必须判断哪条是"对的":是按时间取最新、按状态取有效、还是按某个来源优先。这一步没有通用答案,必须结合业务问清楚。我见过一次清理把"最新的有效记录"删掉、留下了一条作废记录的,原因就是清理脚本默认保留了主键最小的一条,而主键最小恰恰是最早创建的作废数据。

-- 重复画像:每组重复的分布 SELECT cnt AS dup_times, COUNT(*) AS group_count FROM ( SELECT user_id, sign_date, COUNT(*) AS cnt FROM user_sign_record GROUP BY user_id, sign_date HAVING COUNT(*) > 1 ) t GROUP BY cnt ORDER BY dup_times;

这张查询能告诉你:重复两次的有多少组、三次的有多少组,从而估算清理量和判断是否有异常批量写入的痕迹。如果某一组重复了几百次,那大概率不是零星重试,而是某个循环逻辑除了问题,需要先定位源头再清数据。

5.2 分批清理与灰度验证,避免一把梭

大批量删除有两个风险:锁表时间过长影响线上业务,以及删错了没法回退。我的做法永远是分批:先按主键范围或者按时间范围切块,每批控制在几千行,删完核对一次数量,确认无误再删下一批。

-- 分批清理示例:每批删除 2000 行重复数据 DELETE FROM user_sign_record WHERE id IN ( SELECT dup_id FROM ( SELECT a.id AS dup_id FROM user_sign_record a JOIN ( SELECT user_id, sign_date, MIN(id) AS keep_id FROM user_sign_record GROUP BY user_id, sign_date HAVING COUNT(*) > 1 ) b ON a.user_id = b.user_id AND a.sign_date = b.sign_date AND a.id <> b.keep_id LIMIT 2000 ) t );

每批之间要有短暂停顿,让主从同步追上来,也避免持续高压。删除过程中持续观察慢查询和主从延迟,一旦超过阈值就停下来。另外,删除之前我会把待删行的完整数据导出留档,至少保留到业务确认无异常之后。这个习惯救过我一次:清理完第二天业务反馈某用户积分少了,靠归档数据把那条记录恢复了回去。

5.3 清理之后补约束,顺序错了等于白干

存量清理和建约束必须是一个连贯动作,中间不能留太长的空窗。正确的顺序是:停写或者限流写、查重、归档、分批删、建唯一约束、恢复写入、核对。如果先建约束再清理,约束根本建不上;如果清理完隔几天才建约束,这几天新产生的重复会让你的清理成果归零。

还有一个细节:建约束期间如果有写入,可能因为新数据恰好与清理后留下的数据冲突而导致建约束失败。所以低峰期操作、操作前短暂停止写入、或者先在一个只读副本上演练一遍完整流程,都是必要的稳妥措施。演练这一步很多人省掉,结果正式操作时因为一个没预料到的 NULL 值或者超长字段卡住,只能回滚重来。

清理和建约束建议写成一份可重复执行的脚本,并在测试环境用生产数据的脱敏副本完整跑一遍。重复执行不出错、中途失败可续跑,比"一次成功"更重要。

6. 几个真实场景里的写法取舍

讲完原理和通用做法,我把几个高频场景的完整写法摆出来,都是可以直接对照改的。每个场景我都会说明为什么这么选,因为场景之间的差异往往就在一两个条件上。

6.1 签到/打卡类:一天一次,重复就跳过

这类场景的语义很明确:同一个人同一天只允许一条。写法上优先用冲突跳过,因为重复签到不需要更新任何东西,跳过即可。

INSERT INTO user_sign_record (user_id, sign_date, points, created_at) VALUES (1001, CURRENT_DATE, 10, NOW()) ON CONFLICT (user_id, sign_date) DO NOTHING;

判断是否签到成功的依据是受影响行数,等于 1 表示今天首次签到。这里有个容易忽略的点:CURRENT_DATE取的是数据库服务器时区,如果应用和数据库时区不一致,跨零点前后会出现"明明没签到却提示已签到"的问题。稳妥做法是由应用层算好日期再传进来,或者明确统一时区。

6.2 计数器/统计累加类:冲突时累加而不是覆盖

每天的销量统计、访问计数这类场景,语义是"累加"。用冲突转更新时,累加表达式一定要写清楚是基于原值还是新值。

INSERT INTO product_stat (product_id, stat_date, view_count) VALUES (2001, CURRENT_DATE, 1) ON CONFLICT (product_id, stat_date) DO UPDATE SET view_count = product_stat.view_count + 1;

注意这里product_stat.view_count显式限定了原表,表示在已有值上加一。如果写成EXCLUDED.view_count就变成覆盖成 1,完全错了。这种错误很隐蔽,因为语句能跑通、结果也像那么回事,只有对比两天数据才会发现计数没涨。

6.3 数据同步/导入类:先规范化再入库

从外部系统同步数据时,重复来源最多:上游重复推送、网络重试、本地重复导入。这类场景我的处理链路是:先在应用层做字段规范化(去空格、统一编码),再用业务键去重(批内去重),最后入库用冲突更新保证幂等。

规范化这一步不能省。实际数据里" 1001"和"1001"(前面带空格)是两个不同的字符串,唯一索引会认为它们不同,但业务上就是同一个用户。如果不在入库前处理掉,约束形同虚设,而且这种脏数据清理起来特别费劲。我的做法是在入库前统一 trim、转小写(对邮箱、编码类字段)、去除不可见字符,把能标准化的都在源头标准化。

6.4 状态流转类:重复请求要幂等而不是报错

订单状态、任务状态这类流转场景,重复请求很常见(前端重试、消息重投)。这里的正确做法不是简单跳过,而是让重复执行不改变最终状态。通常用带条件的更新来实现:只有当前状态符合预期时才更新,否则直接返回。

-- 仅当当前状态为待支付时才置为已支付,重复执行不影响结果 UPDATE order_main SET status = 'PAID', paid_at = NOW() WHERE order_no = 'ORD20250601001' AND status = 'UNPAID';

受影响行数为 0 表示状态已经不是待支付(可能已处理或已取消),业务层据此短路返回即可。这个模式比唯一约束更贴合状态流转的语义,因为重复请求不是"插入了重复行",而是"重复触发了同一个状态变更"。

这几个场景放在一起看,会发现一个共同点:真正决定写法的是业务语义,而不是 SQL 语法本身。同样是防重复,签到要跳过、计数要累加、状态要幂等,选错了写法虽然不报错,但结果一定是错的。这也是我为什么在前面花那么多篇幅讲"先分清重复的三种含义"——定义清楚了,写法和约束自然就对了。

最后分享一个我在实际项目里固定下来的习惯:每张有业务唯一键的表,建表脚本里必须显式写上唯一约束,并且在代码评审清单里加一条"新增写入路径是否处理了唯一冲突"。这两条落地之后,重复数据问题基本从"事后救火"变成了"事前可控",剩下的就只有历史存量需要慢慢清了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 13:01:04

鸿蒙API9到API20字体绘制模块升级实践

1. 项目背景与挑战 最近接手了一个从鸿蒙API9升级到API20的字体绘制模块改造任务&#xff0c;这个项目来自梅科尔工作室的实际开发案例。作为鸿蒙开发者&#xff0c;我们都清楚API版本跨度这么大意味着什么——几乎是从鸿蒙2.0时代直接跨越到4.0时代的技术栈更新。 在API9时代…

作者头像 李华
网站建设 2026/11/3 12:59:58

RFID技术在生产制造中的高效应用与优化策略

1. RFID技术在生产制造中的核心价值 在现代化生产制造环境中&#xff0c;资产与物料的精准管控一直是企业运营的痛点。传统条形码管理方式需要人工逐个扫描&#xff0c;效率低下且容易出错。而RFID&#xff08;射频识别&#xff09;技术通过无线射频信号自动识别目标对象&#…

作者头像 李华
网站建设 2026/11/3 12:56:55

C++中没有elseif:从语法本质到工程重构指南

1. 项目概述&#xff1a;C里根本没有else/elseif&#xff0c;但为什么人人都在问&#xff1f;“C&#xff1a;else/elseif使用”——这个标题一出现&#xff0c;我就知道&#xff0c;又一位刚从其他语言转过来的朋友踩进了语法认知的坑里。我带过不少初学C的某高校学生、某公司…

作者头像 李华
网站建设 2026/11/3 12:56:49

从零构建AI工程:Python/TypeScript/Rust协同实践

1. 为什么“从零构建AI工程”不是写个Hello World那么简单 “AI Engineering from Scratch”这个标题&#xff0c;乍看像极了那些教你怎么用几行Python调用Hugging Face模型的入门教程——但其实它背后藏着一个被严重低估的真相&#xff1a; 真正的AI工程&#xff0c;90%的工作…

作者头像 李华
网站建设 2026/11/3 12:55:46

重点人员动态管控系统:技术架构与实战解析

1. 重点人员动态管控系统的行业背景与核心价值 重点人员动态管控系统是近年来公共安全领域数字化转型的重要实践成果。这类系统最早可追溯至2010年前后部分一线城市的试点项目&#xff0c;当时主要依托人工台账和基础数据库进行管理。随着大数据、物联网和人工智能技术的成熟&a…

作者头像 李华
网站建设 2026/11/3 12:54:49

ISE综合后Timing Report深度解析:从时序估算到优化实践

简介&#xff1a;面向FPGA与数字电路设计初学者及进阶工程师的ISE静态时序分析学习资料&#xff0c;聚焦综合后Timing Report的解读与优化思路&#xff0c;帮助读者理解时序瓶颈定位、关键路径判断与频率提升方法。资源包共1个docx文档&#xff0c;约16KB&#xff0c;内容围绕时…

作者头像 李华