可重跑:五个批次、三次重跑与零新增

第一次导入跑到第三批超时,罗清和不知道该不该从头再来。本文用来源加 task_id 的哈希做幂等键,以唯一索引兜底,把 1,240 行拆成 5 批 × 248 行断点续跑;重跑 3 次新增 0 行,单次从 4.5 小时压到 72 分钟。

重跑不是再补一次,而是证明补一次和第一次只差一行日志。

一、第三批超时之后

罗清和的一人任务板跑到第 14 个月,接下第一个从竞品整体搬过来的客户:1,240 行任务、14 列、五个批次,每批 248 行。前两批顺利,第三批跑到第 197 行时页面返回 504,后台日志里只有一行 canceling statement due to statement timeout。他盯着进度条上的「496 / 1,240」,不知道该不该从头再来:从头,前两批会不会变成两遍;接着跑,写到一半的那 197 行又算谁的。

以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。

他先试了「从头再来」。第二次跑到第一批结束,任务列表里就多了 248 条一模一样的标题,只是 id 不同。他手工删了半小时,删到一半又怕误删客户刚建的几条真任务。这次实验的代价不是半小时,而是他之后再也不敢点那个按钮。

重跑的成本不在重复执行,而在无法判断哪些是重复。 如果系统能回答「这一行之前来过没有」,重跑就只是一次普通写入;回答不了,重跑就是一次数据污染。

判断依据不能是标题。客户在导入后改过三个任务名,标题早就和源文件不一致;也不能是创建时间,每次重跑的时间都不同。唯一可能稳定的,是来源系统里那个 task_id,前提是它真的稳定。

于是问题从一个操作问题变成了一个设计问题:每条记录有没有一个不会变的身份。这就是幂等键要解决的事,它不关心这是第几遍,只关心这一行是不是同一行。

后面三节按顺序拆:键怎么设计、批次怎么断点续跑、72 分钟与 75.9 小时怎么算出来。边界留到最后:键对了、字段错了,重跑只会把错误写得更整齐。

二、幂等键:来源、任务号与唯一索引

键的取值是 external_id = hash(source + ':' + task_id),写入目标表时随行保存。它由三段组成:来源标识、来源系统的任务号,以及一个不会随重跑变化的归一化步骤。

为什么要来源。两个竞品都用 T-1024 这种短号,如果只拿 task_id 做键,客户从竞品甲搬来的 T-1024 会与另一个客户从竞品乙搬来的 T-1024 撞在一起;哪怕在同一个工作区内,先后导入两个来源也会互相覆盖。

为什么不用标题或日期。标题会改,日期可能被填成导入时间;把任何一个可变字段放进键,重跑就会生成新键,等于没有幂等。键里同样不能放批次号或重试次数,那是最常见的自毁写法:第二批的同一行会得到第二个键。

归一化要做在三处:去首尾空格、统一小写、Unicode NFC。T-1024 与 t-1024 在肉眼看来是同一条,在哈希里是两条。哈希本身用 SHA-256 截前 32 位足够,碰撞不靠应用层「先查再写」解决,而靠数据库唯一索引兜底。

组成部分少了它会怎样具体失败样例
来源 source不同来源的同号任务互相覆盖竞品甲的 T-1024 顶掉竞品乙的 T-1024
任务号 task_id标题一改,重跑就多一条客户把「登录页」改成「登录与注册」后再跑
归一化空格与大小写制造两个键T-1024 与 t-1024 各写一行
唯一索引两个 worker 同时先查后写重跑与定时补数撞在同一秒

唯一索引建在 (workspace_id, external_id) 上,写入统一走 INSERT ... ON CONFLICT DO NOTHING。应用层的「先 select 再 insert」在单线程里看起来对,两个 worker 并行时会同时查到「不存在」,然后各插一条;唯一索引是最后一道,也是唯一可靠的一道。

DO NOTHING 与 DO UPDATE 必须选一个并写进契约。DO UPDATE 让重跑收敛到源文件的最新值,但会覆盖客户在目标系统里改过的字段;DO NOTHING 保住客户的编辑,代价是源端修正不会自动同步。本组选 DO NOTHING,源端修正是另一条显式的修复流程,不混在重跑里。

键还要能被看见。目标表保留 external_id 与 import_batch 两列,出问题时能回答「这一行是哪一批、从哪个来源来的」。没有这两列,重跑之后连排查都只能靠标题猜。

键的格式要写进导入契约,而不是只活在代码里。客户下次补传文件时,如果来源系统升级、task_id 多了前缀,键会整体变化;这时要么在归一化里兼容旧前缀,要么显式升一个来源版本,不能靠「看起来一样」蒙混过去。

三、五个批次、断点续跑与失败清单

1,240 行按 248 行切成 5 批,每批一个事务。批次边界不按时间切,按行号切:第 n 批固定是 (n−1)×248+1 到 n×248,续跑时不需要猜「上次跑到哪」。

批次状态落在 import_jobs 表,字段只有四个:batch_no、status、started_at、error。状态只有 pending、running、done、failed 四个值。超时那次,第 1、2 批是 done,第 3 批是 failed 且事务已回滚,第 4、5 批还是 pending。

续跑从第一个非 done 的批次开始,而不是从第一行开始。第 3 批整体回滚,所以那 197 行不需要逐行判断「写没写进去」;这也是把批次做成事务的原因,把「部分成功」这个状态从系统里消掉。

批次行号范围首次结果重跑结果
11–248提交新增 0
2249–496提交新增 0
3497–744超时回滚,续跑后提交新增 0
4745–992提交新增 0
5993–1,240提交,12 行失败重试成功 9、转人工 3
合计5 × 248 = 1,240—新增 0

三次重跑分别发生在修完一个空值校验、换了一台机器、以及客户补传一版文件之后。三次结束时,SELECT count(*) 的新增值都是 0。这里要区分两种「新增」:重复行新增是 0;失败行重试成功的 9 行是原本就该写进去的,不算新增。

失败清单是另一件交付物。12 行失败里,9 行是日期列出现 31/04/2025 这种不存在的日期,补一条校验后重试成功;剩下 3 行的日期在源系统里本来就是空的,转人工确认。清单三列:源文件行号、字段名、原因,客户能对着直接改。

失败清单不要只写「导入失败 12 行」。行号要对得上客户手里的 CSV(含表头偏移),字段名要用客户的列名而不是内部字段名,原因要能指导动作,例如「due_date 无法解析:31/04/2025 不是有效日期」。

重跑的验证不能只看「这次成功了」。要跑三次,并且第三次在第二批与第四批之间人为中断一次,确认续跑仍从正确的批次开始;只跑一次成功的导入,证明不了可重跑。

验收时看数据库,不看界面。界面上的任务数可能因为分页、筛选或缓存看起来没变,唯一可信的是按 external_id 分组后的 count(*);每组恰好一行,才算这次重跑真的没有多写。

四、七十二分钟与七十五点九小时

自动化的 72 分钟拆成四段:准备 20、执行 20、校验 20、沟通 12。准备是拿样例、对表头、确认必填列;执行是五批写完;校验是行数、空值、重复三项对账;沟通是发一封结果邮件和失败清单。

人工代导入是 270 分钟 = 沟通 30 + 清洗 90 + 执行 120 + 复核 30,合 4.5 小时。两边的差距主要不在执行,而在清洗:90 分钟里大部分时间在猜日期格式和负责人邮箱。

对应环节人工(分钟)自动化(分钟)
沟通3012
清洗 / 准备9020
执行12020
复核 / 校验3020
合计27072

一年 23 次迁移:自动化 23 × 1.2 = 27.6 h,人工 23 × 4.5 = 103.5 h,省 75.9 h = 73.3%。节省来自沟通与清洗两段的压缩,执行本身也从 120 分钟降到 20 分钟;准备与校验各留 20 分钟,没有被压到零。

复算:

5 批 × 248 = 1,240
重跑 3 次,新增 0 行
失败 12 行:重试成功 9、转人工 3
自动化 72 分钟 = 准备 20 + 执行 20 + 校验 20 + 沟通 12
年工时 23 × 1.2 = 27.6 h;对比人工 103.5 h,省 75.9 h = 73.3%

一次性投入也要算。导入器 40 h + 维护 6 h/季 × 4 = 24 h/年,首年 64 h,净省 103.5 − 64 = 39.5 h = 2,370 美元。第二年起不再有那 40 h,但维护时间不会消失。

边界在这里:幂等不等于正确。键对了、状态映射错了,重跑只会把同一个错误值再确认一遍;更糟的是 DO NOTHING 会让第二次、第三次都保留第一次写错的版本,看起来「重跑通过」,实际错得很稳定。发现字段错误时,要走按 external_id 定位的修复脚本或整批重放,而不是再点一次导入。

所以校验必须在写入前做,不能指望重跑兜底。行数、空值、状态分布三项对账放在执行之后、沟通之前;三项不过就不发「导入完成」的邮件。幂等保证的是不重复,正确性靠映射与校验。

五、行动清单与复算验收

把上面的键、批次与失败清单收进同一条流水线,再用八条检查确认它经得起重跑。

  • 给每条导入记录生成 external_id:来源 + task_id,归一化后再哈希
  • 在目标表建唯一索引,写入走 ON CONFLICT,不靠先查后写
  • 把 1,240 行按 248 行一批切成 5 批,每批单独事务
  • 在 import_jobs 记录 batch_no 与状态,续跑从失败批次开始
  • 失败行落一张可下载的清单,写清行号、字段、原因
  • 重跑 3 次验证新增为 0,而不是只看第一次成功
  • 用同一份 14 列样例做回归,确认键不随标题或时间变化
  • 把 72 分钟拆成四段计时,找出下一轮该压缩的那一段

验收标准:拿同一份 1,240 行文件连跑三次,第二次与第三次的新增行数都是 0,且失败清单能定位到具体行与字段。

使用边界与实操验收

边界一:本篇不做 schema 迁移。 加列、回填、切读属于架构组的范围,参见数据模型与迁移:三十四张表与一次只能动一处的改动 ;本篇只讨论导入写入这一层的可重跑性。

边界二:幂等键不能替代去重。 去重回答「源文件里本来就有两条一样的怎么办」,参见去重与合并:八十六行重复、两种判据与一次误合并 ;幂等只保证同一条记录不会因为重跑被写两遍。

边界三:DO NOTHING 的代价要提前说。 源端修正不会自动同步到目标,客户在目标里的编辑也不会被源文件覆盖;两条路径必须写进说明,否则「重跑一下」会被误解成「同步一下」。

边界四:键的稳定性依赖来源。 来源方复用 task_id、或改掉 ID 生成规则时,旧键会失效;来源标识要版本化,换规则时新建一个来源前缀,不要悄悄沿用。

边界五:失败清单含用户数据。 清单的留存、下载权限与到期删除按隐私组口径处理,不长期保留原始行;对外只给行号、字段与原因,不附带整行内容。备份与恢复由资产组负责,本篇不讨论导入前后的备份策略。

关键参考与核验范围

以上链接于 2026-10-26 实测可公开访问;未核验版本、价格与配额。核验日期:2026-10-26。

范围声明:本文的批次大小、键格式与工时拆分均为教学假设,落地时以实际数据量、数据库版本与团队契约为准;不构成对任何具体产品导入能力的承诺。

系列导航

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Indie」更多文章

  1. 开源项目验收:十项能力、四维评分与三种不该开源的项目
  2. 治理、fork 与退出:17 位贡献者、82% 的提交与一次改协议的代价
  3. 双许可与企业版:九家询价、两家成交与 4,800 美元