去重不是把看起来一样的行删掉,而是替每一行确认它唯一该在的位置。
一、第三次导出把同一批任务又送了一遍
唐亦然收到客户消息时,对方已经自己导了三遍。第一遍只导「最近三个月」,发现归档项目不在;第二遍改成「全部项目」,忘了清空日期范围;第三遍把两个条件都设对,才拿到完整的一份。三个文件都在,客户一并传过来,问能不能只留一份。
以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。
导入器不看文件名。第一次运行写了 1,240 行里的前一批,第二次又写一遍,第三次再写一遍。等他打开任务列表,同一个「修复登录超时」出现三份,评论各挂各的,附件散在三个任务下面。
客户的第一反应是「删掉两条就行」。这句话听起来简单,做起来有四层麻烦:删哪两条、被删行上的评论怎么办、附件归谁、删错了能不能回来。
更麻烦的是「看起来一样」不等于「真的一样」。有些任务是复制出来另开一条的,标题相同、日期差一天,业务上本来并行;有些是同一个任务在另一次导出里改了标题,日期相同、标题差两个字。前一种不能合,后一种必须合,只看标题分不出来。
他去数了一遍:1,240 行里 86 行是重复,占 6.9%。这个比例不算高,但 86 行背后是 86 次「要不要合」的判断,任何一次判错,客户看到的都是「你把我的数据弄乱了」。
更现实的压力是时间。客户在等一个能用的工作区,他不能花两天逐条问「这两条是不是同一个任务」。能在半天里做完的,是把判断分成「机器能定的」和「必须人看的」两堆,前者自动走,后者批量确认。
去重的第一原则是不删。 重复行保留原样,只打上「已并入某行」的标记;合并是建立指针,不是执行删除。这样即使判错,拆开就能回到原状。
第二原则是判据分级。 能确定的分高,不能确定的分低;分高的自动走,分低的进队列等人看一眼。两种判据混在一起自动执行,误合并就是这样产生的。
第三原则是留下账。 每条合并记录保留行 ID、被合并行 ID、字段来源与执行时间。没有这条记录,后面所有「能不能撤销」的讨论都不成立。
二、八十六等于五十二加三十四
两种判据来自两个不同的字段。外部 ID 是导入时按「来源 + task_id」生成的哈希,同一个任务在三次导出里哈希相同;标题加日期是退而求其次的组合,用于那些没有外部 ID 的行,比如客户手工整理的两条。
外部 ID 命中 52 行,标题加日期命中 34 行,合计 86 行。两者不是并列关系,而是优先级关系:先跑外部 ID,命中的直接合并;剩下的再跑标题加日期,命中的进队列。
| 判据 | 命中 | 置信度 | 处理动作 |
|---|---|---|---|
| 外部 ID 精确命中 | 52 | 高 | 直接合并,不打扰客户 |
| 标题 + 日期推定 | 34 | 中 | 进待确认队列,批量勾选后合并 |
| 合计 | 86 | — | 合并后 1,240 行变 1,154 行 |
为什么外部 ID 可以自动执行?因为它是导入时自己生成的,同一来源同一 task_id 必然同值,不存在「碰巧一样」。唯一要防的是生成规则变更:如果某次导入换了哈希输入,老行的外部 ID 就对不上,重复会被漏掉,而不是误判。
为什么标题加日期只能进队列?因为两个字段都不可靠。标题会被改,日期会被时区偏移一天,客户还可能真的建了两条同名任务。34 条候选里只要有一条是「真重复但不是同一任务」,自动合并就会删掉一条有效数据。
队列要能批量确认。34 条一条条问,客户会烦;正确做法是按「标题完全一致」「标题相近」「仅日期相同」三档排好,前两档默认勾选,第三档默认不勾,让客户只处理真正需要判断的部分。
还有一条辅助判据:附件指纹。同一个文件在三次导出里文件名相同、大小相同,基本可以认定是同一份;但它只能辅助,不能单独作为合并依据,因为客户可能上传了两份同名不同内容的版本。
比较标题之前先做一次 Unicode 归一化,是这组判据里最容易被跳过的一步。全角括号、组合字符与不可见空格会让两个视觉相同的标题在字节层面不相等;归一化之后再比,34 条候选里能少一批假阴性。
判据的置信度要写进报告,不只写结果。「合并了 86 行」是一句结论;「52 行由外部 ID 确定、34 行由标题加日期推定」才是客户能复核的账。
三、合并一次要回答四个问题
判定重复只是第一步。两条行合成一条,要回答四个问题:保留哪条、字段取谁、评论怎么拼、附件归谁。四个问题各有默认规则,也各有例外,关键是每个决定都留下记录。
| 问题 | 默认规则 | 例外 | 记录字段 |
|---|---|---|---|
| 保留哪条 | 最早创建、外部 ID 稳定者优先 | 早期行缺必填字段时保留较新一条 | kept_id |
| 字段取谁 | 非空优先,其次取最新修改 | 状态与负责人一律取最新 | field_source |
| 评论怎么拼 | 按时间升序拼进同一线程 | 正文完全相同的副本只留一条 | comment_count |
| 附件归谁 | 全部挂到保留行 | 同名同大小视为同一文件 | attachment_ids |
保留哪条:默认保留最早创建的那条,因为外部 ID 最稳定、下游引用最多。例外是早期行缺必填字段,这时保留较新的一条,但要把早期行的创建时间写进备注,别让客户以为任务凭空晚建了。
字段取谁:先取非空,再取最新修改。这两条规则会打架——一条行的负责人为空但修改时间新,另一条负责人有值但旧。默认取有值的那条,因为空值不携带信息;状态与负责人例外,取最新,因为它们反映当前事实。
评论怎么拼:按时间升序拼进一个线程,保留原作者与时间戳。正文完全相同的评论只留一条,这是唯一允许的「删」——删的是重复副本,不是内容。
附件归谁:全部挂到保留行,并在附件记录上写原属行。同名同大小的视为同一文件只留一份,其余按原名保留。附件不做内容去重,因为不同版本可能同名。
还有一个顺序问题:先合并评论与附件,再合并字段。反过来做的话,字段一旦被覆盖,后面判断「这条评论原本属于谁」就失去了依据;顺序错了,撤销时也拼不回原样。
四个问题的答案必须写进合并记录,而不是留在执行脚本里。 一条记录至少包含:保留行 ID、被合并行 ID 列表、每个字段的来源、评论条数、附件 ID 列表、执行时间与操作人。有这条记录,撤销才有依据;没有它,「合并」在技术上就是一次不可逆的覆盖。
四、八十六行的账与三行的代价
把账摊开:1,240 行进来,86 行判为重复,合并后 1,154 行。86 行里 52 行是高置信度、34 行是推定;推定的 34 行里有 3 行判错,占 8.8%。
3 行的代价不是 3 行数据。每一次错合并,意味着一批评论挂到了另一条任务上、附件指向了错误对象、状态被另一条的值覆盖。客户看到的是「我的任务乱了」,不是「你错了三行」。
撤销怎么设计:合并前对涉及的行做快照,合并时写 kept_id 与 merged_ids 映射,撤销时按映射反向拆——评论与附件按原属行放回,字段恢复成快照里的值。整个过程是一次反向批次,不是手工改数据。
撤销要演练过才算有。第一次演练时他发现,评论能拆回去,附件却因为「同名只留一份」丢了一个版本;改成保留附件 ID 列表后,第二次演练才完整。没演练过的回滚,只是一句承诺。
还有一笔成本要算进去:合并后的抽查。抽查 60 行、错 3 行,5.0% 的错误率是抽样成本,不是质量问题;它必须像清洗和执行一样排进工时,而不是等客户发现再补。
复算:
1,240 行;重复 52(外部 ID)+ 34(标题+日期)= 86 = 6.9%
合并后 1,240 − 86 = 1,154 行
模糊判据误合并 3 行 = 3 ÷ 34 = 8.8%
抽查 60 行错 3 行 = 5.0%
两个比例的分母不同,报告里要分开写:8.8% 是模糊判据内部的错误率,5.0% 是合并后抽查的整体错误率。只写一个数,客户无法判断风险落在哪一段流程。
6.9% 的重复率也不该被当成小事。1,240 行里 86 行重复,看起来只占一小截;但如果这 86 行都带着评论和附件,合并的工作量远大于行数比例。去重的成本从来不按行数线性增长。
最后是口径:1,154 行是合并后的行数,不是「删掉了 86 行」。客户的工作区里仍然是 1,154 条任务,只是其中 86 条带着「已并入」标记。这个区别决定了客户会不会信任这套流程。
五、行动清单与复算验收
这八条按「先分级、再合并、最后演练撤销」的顺序做,一轮能在半天内跑完。
- 先统计候选:按外部 ID 与标题加日期各跑一遍,得到 52 与 34 两组。
- 把模糊命中的 34 条放进待确认队列,不自动合并。
- 为每条合并写 kept_id 与 merged_ids,保留可回溯映射。
- 字段冲突按「非空优先、其次最新修改」定序,状态与负责人取最新。
- 评论按时间升序拼接,附件全部挂到保留行并记录原属行。
- 合并后抽查 60 行,记录错误数与错误类型。
- 演练一次撤销:从 kept_id 反向拆回 merged_ids,确认评论与附件都能回去。
- 把误合并率与模糊命中率分开写进导入报告,不要只报一个总数。
清单的价值不在八条本身,而在它们能压成一句判断:每一条被合并的行,都能说清凭什么判重、保留了哪条、拆回去会回到哪里。
验收标准:任取一条合并记录,都能指出它凭哪条判据被判为重复、保留的是哪一行、执行撤销后评论与附件分别回到哪一行;说不清的那条不许合并。
使用边界与实操验收
边界一:去重不做硬删除。 所有重复行以软合并处理,原行保留标记与映射;真正的删除只发生在客户明确提出时,并按隐私组的删除流程执行。本文的方法不能替代删除权实现。
边界二:模糊判据不自动执行。 34 条候选全部进队列,宁可让客户多点几下,也不让机器替客户决定两条任务是不是同一条。自动化的边界在这里,不在技术能力。
边界三:不覆盖跨表与跨系统去重。 本文只讲同一 schema 下 1,240 行的行级合并,不涉及评论表、附件表的独立去重,也不涉及 CDC 或双向同步的冲突消解。
边界四:与架构组划界。 schema 变更、字段类型迁移归架构组;本文假设表结构已经确定,只处理同一结构内的重复行。
边界五:可回滚的前提是快照与映射都在。 没有快照、没有 merged_ids 记录,所谓「可撤销」只是一句设计意图,不能写进对客户的承诺。
关键参考与核验范围
- Unicode TR15:规范化形式 :用于说明标题比较前为何要先做归一化,全角半角与组合字符会让「看起来一样」的标题不相等。
- PostgreSQL INSERT ON CONFLICT :用于说明唯一索引兜底重复写入的写法,具体行为以官方文档为准。
- SQLite UPSERT :用于小规模场景下同一语义的实现对照。
- RFC 4180:CSV 格式 :用于说明引号与换行的处理,解析一错,重复行判定从第一步就偏。
- W3C CSV on the Web 入门 :用于说明表头、列类型与单元格值的解释方式。
以上链接于 2026-10-26 实测可公开访问;未核验版本、价格与配额。核验日期:2026-10-26。
本文讨论的是同一 schema 内、单表 1,240 行的行级去重与合并,不涉及跨系统同步、CDC 与双向写冲突消解;两种判据的阈值与队列规模按本组基线设定,换产品需重新取样。数据库行为以各项目官方文档为准,本文不构成对任何具体版本的实现承诺。
系列导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。