AI 写的批量删除清空了流水:事故复盘与上线检查清单
一次对账流水被整体清空的事故,起因是新字段默认值为 null,AI 生成的批量写操作没有加条件,又直接在生产环境执行。文章拆解事故链条,说明为什么“代码能跑通”不等于安全,并给出批量写防护、软删除陷阱、备份恢复校验和上线检查清单等可落地的做法。
一条“逻辑没问题,通过”的批量写代码,把整张对账流水表写成了空值。事后回看,代码本身只有几行,真正让事故从小 bug 变成数据灾难的,是缺失的测试、审查和备份校验。这类事故和写代码的是人还是 AI 关系不大,但 AI 参与后,出问题的速度和规模被明显放大了。
事故是怎么发生的
事情的经过大致是这样:为了让对账支持分批处理,表里新加了一个批次字段,默认值留空。随后用 AI 生成了一段处理逻辑,把符合条件的订单批量写入这个新字段。代码在本地跑了一遍,主流程通畅,于是直接发布到生产。
问题在于,批量写没有带上足够严格的过滤条件,而存量数据的批次字段全都是空值。结果不是“删了一单”,而是整张表的对账记录被卷进了同一次操作,历史数据全部被覆盖成空,对账自然“全不见了”。
修复阶段又踩了第二个坑:从备份恢复表数据后发现金额对不上。排查了几个小时才意识到,备份是某个时间点的快照,而在下载备份之后,业务侧又撤销了两笔单据。快照加上后续增量没有对齐,账面当然对不上。
三个失误叠加,才变成事故
把链条拆开看,单独任何一环被守住,都不会有这次事故:
- 数据结构变更加上批量写,没有考虑存量数据。新字段默认值空,代码假定“所有记录都已经有值”,这个假设在测试库里成立,在生产库不成立。
- 批量操作没有条件和范围约束。没有
where限制、没有limit分批、没有先查询影响行数,一次调用覆盖了全表。 - 生产环境成了第一个测试环境。没有用样本数据跑一遍,没有代码审查,没有灰度,执行前也没有确认会影响多少行。
为什么“跑通了”不等于安全
生成代码时,模型看到的上下文通常只有你贴出的片段和表结构,它不知道线上有几百万行历史数据,不知道哪些字段在旧记录里是空值,也不知道这张表昨天刚被业务手动改过。它给的是一段在理想数据上成立的逻辑。
这也不是 AI 独有的问题。手写代码同样会漏条件,区别在于人在写 where 时往往会犹豫一下:这张表有多大、条件够不够、删错了能不能恢复。而生成代码的过程太快,这段犹豫被跳过了。所以更准确的说法是:工具放大了流程里原有的漏洞。
代码层面的几道防线
批量写之前先确认影响范围
凡是 update / delete,先写查询、再写操作。这一步在多数框架里只要几行代码:
// 危险:条件宽松,一旦命中全表就是灾难
Order::where('status', 'paid')->update(['batch_id' => $batchId]);
// 更稳的做法:先确认影响行数,再限定主键范围分批处理
$ids = Order::where('status', 'paid')
->whereNull('batch_id')
->where('created_at', '<', $cutoff)
->limit(500)
->pluck('id');
if ($ids->count() > $expected) {
throw new \RuntimeException('影响行数超出预期,终止执行');
}
DB::transaction(function () use ($ids, $batchId) {
Order::whereIn('id', $ids)->update(['batch_id' => $batchId]);
});
关键点有三个:加上非空或时间范围等约束条件、用 limit 分批、把“影响行数”当成一个需要校验的返回值,而不是忽略它。
新增字段要处理历史数据
给已有表加字段时,把两件事写进迁移脚本:字段的默认值是什么,存量行怎么回填。如果业务上不允许为空,就在回填完成后再加非空约束,而不是让代码去猜。回填本身也要分批执行,避免一次锁表。
软删除不是加了字段就生效
很多项目给表加了删除标记字段,就以为删除是安全的。但软删除通常依赖模型层的配置,如果模型没有启用对应特性,批量删除仍然会走物理删除。更隐蔽的是,某些批量操作会绕过模型事件和观察者,导致审计日志、级联清理统统不触发。
不要假设“应该会触发”。上线前用一条测试数据实际验证一遍,或者干脆在数据库层加约束和审计表,把安全边界放在不容易被绕过的地方。
收敛生产环境的账号权限
日常开发连接生产库的账号,不应该具备 delete 和全表 update 的权限。危险操作交给受限账号或运维流程执行,是最省事的一道保险。
上线前检查清单
| 检查项 | 具体做法 |
|---|---|
| 数据样本验证 | 从生产抽样并脱敏,灌入测试库跑完整 CRUD |
| 影响范围预演 | 在只读副本上执行一次查询,统计将影响的行数 |
| 批量写约束 | 必须有 where 条件、有 limit、有行数上限断言 |
| 事务与回滚 | 写入包在事务里,准备好回滚脚本 |
| 代码审查 | 重点看迁移脚本、批量写、删除逻辑,不看样式 |
| 执行窗口 | 避开业务高峰,执行时有人盯着监控 |
| 执行后校验 | 立刻核对行数、金额汇总等关键指标 |
| 备份可用性 | 确认备份存在,且知道怎么恢复、恢复到哪个时间点 |
数据恢复为什么会对不上
备份恢复失败往往不是因为备份坏了,而是因为时间点没对齐。可行的做法:
- 恢复前先记录当前数据库的日志位点或时间戳,冻结相关表的写入;
- 用快照恢复主体数据,再回放快照之后的增量变更;
- 校验不只看行数,还要看金额汇总、状态分布这类业务指标;
- 恢复完成后重新跑一遍完整校验,不要拿旧备份直接覆盖生产。
如果业务在恢复期间仍在写入,先停写再恢复,比恢复后补救便宜得多。
关于“没时间写测试”
讨论里明显有两种态度。一种认为必须补测试,没有覆盖就没法放心用 AI 提速;另一种认为小团队根本没这个时间,逻辑跑通就行,出事再说。
现实一点的分寸是:完整测试套件成本确实高,但有三件事几乎没有成本,不能省——执行前确认影响行数、把危险操作放进事务、确保备份能恢复。如果连这三件都做不到,那么用 AI 提速带来的收益,迟早会被一次事故抹平。
另外,软删除是否生效、批量操作会不会触发模型事件,不同框架和版本的行为并不一致,务必以所用版本的官方文档和一次实际测试为准,不要凭印象判断。
小结
这次事故的教训可以压缩成三句话:AI 不知道你有存量数据,凡是涉及批量写和删除的生成代码都要人工审查;生产环境不能当测试环境,执行前先算清楚会影响多少行;备份只是恢复的起点,增量对齐和恢复后校验才是完整方案。工具越快,流程里的窟窿就越贵。