AI 写的数据迁移脚本跑一半断了,重跑就出重复数据怎么办
数据截至 2026-07。文中的 SQL 与退出码属于常见数据库、常见运行环境下的通用行为,具体语法方言和信号约定以你所用数据库、运行时的官方文档为准。
**迁移脚本翻车的时候,绝大多数人第一反应是去骂 AI 把代码写错了,其实真正的原因是这段脚本从头到尾就没被当成一个可能中途死掉的进程来设计。**你让模型写一段”把老表数据搬到新表”的代码,它会非常配合地给你一个从头读到尾、边读边写的循环,逻辑本身挑不出毛病。问题在于生产环境里这个循环一定会被打断——网络抖一下、连接池被回收、发布把容器滚了、有人手抖 Ctrl+C。断了之后你只有两个选择:从头再来(数据重复),或者凭记忆猜一个位置接着跑(数据漏掉)。这两条路都通向同一个结局,就是没人敢在会议上说”迁移完成了”。
所以这篇不讲怎么让 AI 把 SQL 写得更漂亮,讲的是一个工程环节:迁移脚本这件事,AI 能替你做到哪一步,哪一步必须你自己定。 站内另外两篇的分工是这样的:数据库迁移不可逆的操作怎么防 说的是 DDL 层面那些执行完就回不了头的动作(删列、改类型、加约束)该怎么拦;数据误删之后怎么恢复 说的是已经出事了怎么捞。本篇夹在这两者中间,管的是数据搬运过程本身的可靠性——脚本还没执行完的那段时间里,怎么保证它随时能停、能接着跑、能证明搬对了。
一、先分清:是脚本错了,还是运行方式错了
出问题的时候先别改代码。迁移类故障的现象高度雷同(数量对不上),但成因分岔很早,改错方向会越弄越乱。
判别的入口是一个问题:新表的数据是比预期多,还是比预期少? 多,说明同一条源记录被写了不止一次,方向指向重跑和幂等;少,说明有一段源数据根本没被读到或者被写失败吞掉了,方向指向游标、分页和错误处理。两个方向的排查手段完全不同。
第二个入口是:这个差值是固定的还是每次都变? 判断办法是清空新表重跑一遍看差值是否复现,但有个前置动作不能省:重跑之前先把当前的差集和重复集捞出来,落到一张单独的表里存着。 清空重跑会把现场洗掉,证据没了就只能靠猜。存好之后再跑:如果差值稳定复现,那多半是逻辑边界问题(过滤条件、时区、空值、分页边界);如果差值每次都不一样,那多半是并发或者中断相关——源表在迁移期间还在被业务写入,或者脚本被打断的位置不固定。
第三个入口是:差的那些记录有共同特征吗? 把差集捞出来看几十条,看它们的主键区间、创建时间、某个状态字段是否扎堆。扎堆意味着有明确的边界条件在作祟,散开则更像随机失败。这一步很多人跳过,直接开始重跑,结果把唯一能定位问题的证据洗掉了。
捞差集本身不需要 AI,一条反连接就够:
SELECT s.id
FROM source_table s
LEFT JOIN target_table t ON t.source_id = s.id
WHERE t.source_id IS NULL
LIMIT 200;
反过来查重复也是一样的套路:
SELECT source_id, COUNT(*) AS c
FROM target_table
GROUP BY source_id
HAVING COUNT(*) > 1
LIMIT 200;
这两条按 MySQL、PostgreSQL 的写法给的,SQL Server 要把 LIMIT 换成 TOP,Oracle 换成 FETCH FIRST n ROWS ONLY,语义不变。跨库迁移时还要留意一点:源库和目标库不在一个实例上时,这类反连接跑不了,需要先把源表主键导到目标端的临时表再比对,或者两边各自导出主键列表在外面做差集。
这两条查询应该在你写迁移脚本之前就准备好,而不是出事之后临时拼。它们是这个环节的验收工具,不是应急工具。
二、现象判别表
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 目标表条数多于源表 | 中断后从头重跑,且写入没有幂等键 | 按业务唯一键分组查 COUNT > 1,再取几组重复行看它们在目标表的自增主键:分成明显两段、每段内部各自连续,就是重跑写了两遍;主键交错散落则更像多进程并发写 | 停掉脚本,先加唯一索引或改成 upsert,再按唯一键清理重复,最后重跑 |
| 目标表条数少于源表,差值稳定 | 过滤条件或分页边界写漏了;ORDER BY 字段不唯一导致翻页跳记录 | 反连接捞差集,看主键区间是否连续成段,检查分页排序字段有没有重复值 | 改成基于主键游标的顺序扫描,不用 OFFSET 翻页 |
| 目标表条数少于源表,差值每次不同 | 单条写入失败被 try/except 吞掉,或源表在迁移期间仍有新增 | 在脚本里分别累计”读到的行数”和”写入成功的行数”,两个数对不上就是异常被吞了;再按迁移开始时间点统计源表的新增量,看差值能不能被新增量解释 | 失败记录单独落表不要静默跳过;源表加时间窗口截断,增量部分单独补 |
| 脚本卡住不动,没报错也没进度 | 长事务把批次全包在一个事务里,或者被锁等待 | 查数据库的活动会话和锁等待视图,看事务开始时间 | 拆小批次、每批独立提交,给语句加超时 |
| 中途 OOM 或进程被杀 | 一次性把全表读进内存 | 看内存曲线和退出状态:进程被 SIGKILL 终止时,shell 和容器运行时一般把退出码报成 137(128+9),内核日志里也会有对应的 oom 记录,容器编排环境则看 Pod 的终止原因是不是 OOMKilled | 改成流式游标读取,限制单批行数 |
| 字段值搬过去变成乱码或问号 | 源库和目标库字符集不一致,或连接串没指定编码 | 取一条含中文的记录,在两端分别用十六进制看字节 | 统一连接编码后重搬受影响记录 |
| 金额、时间字段对不上 | 浮点精度或时区转换 | 抽样比对小数位和时间偏移是否有固定规律 | 金额用定点类型搬运,时间统一存 UTC 再转换 |
这张表的用法是:先定位到行,再决定动作。不要跳过验证列——很多人看到”条数少”就直接判定成漏读,改完分页逻辑发现根本没用,实际是异常被吞了。
三、三件套:断点、幂等、核对,AI 只能包办其中一件半
把要求说清楚,AI 写批处理循环、写游标推进、写 upsert 语句都没问题,代码质量通常也够用。但有三个决定必须你来做,它猜不出来:
唯一键是什么。 幂等的前提是能判断”这条我是不是已经搬过了”。这个判断依据来自业务语义,不来自表结构。源表的自增 ID 在目标表里可能已经不唯一(比如多个源库合并),订单号在退款场景下可能重复出现,用户手机号可能被注销后复用。你必须自己指定一组字段作为幂等键,并且在目标表上建唯一索引让数据库替你兜底。只写代码判断不建索引,并发下照样重复。
断点存在哪里、什么时候更新。 常见错误是把进度写在内存变量里,或者写在本地文件里而任务跑在会被重建的容器中。断点应该和数据写在同一个存储里,并且在同一个事务里提交——先提交数据再更新进度,中间挂掉就会重复搬一批;先更新进度再写数据,中间挂掉就会漏一批。放在一个事务里,两者的状态永远一致。如果目标端不支持跨表事务,那就退而求其次:先更新数据再更新进度,靠幂等键吃掉重复。
批次大小和节流。 这是拿业务影响换速度的决策,AI 给不出你的库能扛多少。它只会给一个看起来合理的默认值。你需要根据源库当时的负载、从库延迟、业务低峰时段自己定,并且留一个能在运行中调整的开关。
剩下的一件半——循环骨架、错误重试、日志打点、核对查询的编写——交给 AI 是划算的。给它的约束要写死:不允许一次性 SELECT 全表、每批必须独立提交、失败记录必须落到单独的表而不是打印一行日志、每批结束打印已处理主键上界。这类约束比”写得优雅一点”有用得多——你把边界钉死,它才不会自作主张换写法。
一个能续跑的骨架大概长这样:
last_id = load_checkpoint(target_conn) # 从进度表读,不从内存读
while True:
rows = fetch_batch(source_conn, last_id, batch_size) # WHERE id > last_id ORDER BY id LIMIT n
if not rows:
break
with target_conn.begin(): # 数据和进度同一事务
upsert_rows(target_conn, rows) # 依赖唯一索引做幂等
save_checkpoint(target_conn, rows[-1].id) # 必须走同一个连接
last_id = rows[-1].id
time.sleep(throttle_seconds)
关键点不在代码行数,在于 fetch_batch 必须按主键严格递增取,不用 OFFSET;upsert_rows 必须依赖目标表的唯一约束而不是先查后插(先查后插在并发下有竞态);save_checkpoint 必须和数据走同一个连接、同一个事务——这里最常见的翻车是进度表用了另一个连接或另一个 session,代码看着在一个 with 块里,实际提交时机完全独立,中断之后进度和数据依然对不上。幂等这件事本身的坑不止迁移场景有,重试与幂等怎么做 讲得更细。
四、上线前的验收动作:不做完这几步就不算跑完
脚本跑完打印一句”迁移完成”什么都不能证明。下面这几个动作是要有输出、有记录的。
总量比对。 源表在截断时间点之前的记录数,和目标表对应的记录数,必须一致。注意源表还在被写入,所以比对条件里要带上你的时间窗口截断条件,两边用同一个条件。
抽样逐字段比对。 随机抽几百条,把每个字段拿出来比。这一步专门抓类型转换问题:小数被截断、时间偏了几小时、布尔变成了字符串、长文本被截断。总量对得上但字段搬错,是最容易带到生产的一类事故。
边界样本定向比对。 专门挑最早的一条、最晚的一条、金额最大的、字段为空的、含 emoji 和生僻字的、状态最少见的那几条。这些是随机抽样大概率漏掉、而线上一定会遇到的记录。
幂等自检。 把整个脚本从头再跑一遍,跑完总量必须不变。这一条是所有验收里性价比最高的——它一次性验证了唯一约束、upsert 逻辑和断点恢复三件事。不敢重跑,说明你的脚本本来就不敢上。
中断演练。 跑到一半手动 kill,然后重启,观察它是不是从断点接上、总量最终是否正确。演练要在预发环境做,做一次就够,但必须做过。
这几步的输出建议存成文件,跟迁移脚本一起提交。真出事的时候,“我们验过什么”和”我们没验什么”是完全不同的处境。AI 生成的代码到底能不能进生产,判断标准也在这里,AI 代码能上生产吗 那篇讲的是同一套思路的通用版本。
五、什么时候该停手:止损点和回滚点
迁移这类任务有个特点,越修越乱的速度非常快。给自己定几条硬线,到了就停。
目标表已经被业务读了,就不要再原地修数据。 一旦新表接了线上流量,你在上面做批量 DELETE 或 UPDATE 去”修一修”,等于在跑着的车上换轮胎。正确动作是先把读流量切回老表,再处理数据。切流回退的成本永远低于脏数据扩散的成本。
同一个错误改了三次还没定位,就停下来做差集分析。 反复微调过滤条件、反复调批次大小,这是在碰运气。停下来把差集捞出来看特征,十分钟能解决的问题不要用三小时的重跑去试。
清空重来的代价还能接受时,优先清空重来。 目标表是新建的、没有外部依赖、数据量在可接受的重跑时间内,那么”truncate 后带着修好的脚本重跑一遍”比”在存量脏数据上做增量修复”干净得多,也更容易证明结果正确。存量修复的心智负担会一直跟着你到复盘会。
源表被改动过,立刻停。 迁移脚本原则上只读源表。如果你发现脚本里有对源表的写操作(哪怕只是打个已迁移标记),并且出了问题,先停机再评估——这时候的处境已经从”迁移失败”变成”数据受损”,处置方式要换到恢复的路子上去。
回滚点要提前定义,而不是出事时现想。 至少写清楚三件事:目标表能不能直接 truncate(有没有别的东西已经写进去了)、进度表怎么重置、老表在什么时间点之前的数据是可信的。这三句话写在脚本注释顶部,比任何监控都管用。
**换条路的判断依据:**如果你连续两轮都无法解释数量差异的来源,说明源数据本身的复杂度超出了你当前的理解——这时候该做的不是继续写脚本,是先花时间把源表的业务语义搞清楚(软删除标记、历史脏数据、多租户混存、曾经的手工订正)。搞不清语义,脚本再健壮也只是把不确定性搬到新表去。
六、避坑清单
用 LIMIT/OFFSET 翻页。 为什么会踩:这是最直觉的分页写法,AI 也常给这个。但源表在迁移期间只要有插入或删除,后面页的边界就会漂移,导致重复和遗漏同时发生;而且 OFFSET 越大扫描越慢。怎么避:改成 WHERE id > last_id ORDER BY id LIMIT n 的游标式扫描,排序字段必须唯一。这个坑和 分页重复与漏数据 是同一个成因。
先 SELECT 判断存在再 INSERT。 为什么会踩:读起来最符合直觉,单线程测试也确实不重复。但并发下两个进程可能同时判断为不存在,然后各插一条。怎么避:靠目标表的唯一索引 + upsert,让数据库做仲裁,代码层的判断只当优化不当保证。
把异常吞掉只打一行日志。 为什么会踩:为了让脚本”不要因为一条脏数据就整个挂掉”,加个宽泛的异常捕获很自然。但日志刷过去就没人看了,最后表现为数量莫名其妙少一点。怎么避:失败记录整条写进一张 failed 表,带上原始主键和错误信息,脚本结束时打印失败总数,非零就明确判定为未完成。
在开发库上验证完就上生产。 为什么会踩:开发库数据量小、数据干净,跑得又快又对。生产库有十年积累的历史脏数据、有当年手工改过的记录、有编码不统一的老数据。怎么避:用生产数据的脱敏副本演练,至少要覆盖到最早那批记录,并且确认脱敏副本不会反向流回生产。
把整批包在一个大事务里。 为什么会踩:看起来更安全——要么全成功要么全回滚。实际上会造成长事务、锁堆积、回滚段膨胀,中途失败还得等一段很久的回滚,期间业务受影响。怎么避:小批次独立提交,靠幂等键而不是靠大事务保证正确性。
进度只存在内存或本地文件。 为什么会踩:本地跑的时候完全没问题。放到容器或 CI 里,实例一重建进度就没了。怎么避:进度写进数据库的进度表,和数据同事务提交。
认为迁移期间源表是静止的。 为什么会踩:迁移方案里默认了业务停写,但实际执行时没有真的停,或者停写窗口比迁移时间短。怎么避:把时间窗口写进查询条件,明确”我搬的是某个时间点之前的数据”,窗口之后的增量走单独的补数流程,两段分开验收。
日志把整行数据打出来。 为什么会踩:为了排查方便,出错时把整条记录打进日志。结果手机号、身份证、地址全进了日志系统,成了新的合规问题。怎么避:日志只打主键和错误类型,需要看具体内容时去 failed 表查,并给这张表设置和业务表同级的访问权限。
顺手让脚本改一改源表结构。 为什么会踩:迁移过程中发现源表少个索引,顺手加上加快查询。但 DDL 在大表上可能锁表,影响线上。怎么避:结构变更单独走变更流程,不夹在数据搬运脚本里。
结语:上线前的自检清单
回到开头那句话:迁移脚本敢不敢上,不取决于代码写得多干净,取决于它中断之后你还有没有确定的下一步。让 AI 写循环、写重试、写核对查询,都很划算;但唯一键、断点位置、批次节奏这三个决定是业务判断,模型没有你的上下文,写出来的只是一个默认值。
上线前对一遍这七条,有一条答不上来就先别跑:
- 幂等键是哪几个字段,目标表上建唯一索引了吗;
- 进度存在哪里,和数据写入是不是同一个事务;
- 整个脚本重跑一遍,总量会不会变(跑过了吗);
- 中断演练做过吗,从断点接上正确吗;
- 失败记录落到哪张表,脚本结束时会不会明确报出失败数;
- 差集查询和重复查询准备好了吗,是出事前写的还是出事后写的;
- 回滚点写在哪里,目标表现在还能不能直接清空。