300行脚本,让全平台余额翻倍
📋 总体概括
一段精心编写的300行回填脚本在120万行处撞上死锁,运维重跑后,120万个账户的调整被执行了两次,三天后才被发现。本文拆解这起典型回填事故的技术根源与产业逻辑:为什么事故总发生在临时脚本上,以及从『跑一次』到『可重跑』的工程化分水岭。
📄 正文
一段精心编写的300行回填脚本,在第120万行处撞上死锁。运维做了看似最合理的事——重跑。结果,前120万个账户的调整被执行了两次,直到三天后客服发现:有些客户的余额,居然超过了他们的终身消费总额。
这不是段子,是数据行业每天都在重演的剧本。我们为在线交易系统建了精密的容错与对账体系,却把数据回填交给一段『只能安全跑一次』的裸脚本。这一次,余额翻倍;下一次,可能是库存清零、积分蒸发,或者一份被重复计入的财务报表。
🔁 死锁不是事故,重跑才是
每一个数据事故的起点,都是一句『这有什么问题』。
先把事故复盘一遍。某团队需要对存量账户做一笔历史调整——比如补发一笔补偿金额。有人认真写了300行脚本,逐账户执行更新。跑到第120万行,数据库死锁,进程死了。
运维的操作无可指摘:这不是我代码的错,是数据库的瞬时抖动,重跑就是了。
于是整个流程变成了这样:
问题在于,这段脚本的业务语义是『给每个账户加一笔钱』。重跑不是重试,是第二次发钱。
更扎心的是发现路径:不是监控告警,不是对账系统报错,而是三天后客服接到用户咨询——『我的余额怎么比我花过的钱还多?』
据多位接触过类似事故的从业者私下说,这类问题的暴露窗口普遍以『天』计,而不是以『分钟』计。原因很简单:多加一笔钱对系统来说完全合法,没有任何报错。数据错误的可怕之处在于,它不会崩溃,只会安静地记账。
这起事故里有两个失误叠加:第一,脚本本身不幂等;第二,重跑决策没有意识到前一次已部分生效。死锁只是扳机,真正上膛的是那段没有记忆的代码。
💡 那行SQL没有记忆
增量操作和赋值操作,是两种完全不同的物种。
看这段核心代码(简化后其实就一行):
`sql
UPDATE accounts
SET balance_cents = balance_cents + %s
WHERE id = %s;
`
这是一个自增操作,不是赋值操作。它对『自己是否执行过』毫无记忆——数据库里没有任何状态记录『这笔调整已经 applied 过了』。所以跑两次的效果,就是加两次。
原作者可能在心里把它理解成了赋值:『把账户余额设置为 应有余额』。如果是赋值语义,跑一百遍结果都一样,天然幂等。但增量语义下,重复执行就是重复生效。用行话说,这段代码是 safe exactly once——只在恰好执行一次的前提下安全,而分布式环境里恰恰不保证 exactly once。
这就是数据管道工程的第一性原理:消息传递层只能做到 at-least-once,exactly-once 必须靠业务侧的幂等设计来兜底。 这个结论在流处理领域早已是共识——Kafka 的消费语义、Flink 的 checkpoint 机制,最终都要靠下游幂等写入(比如按主键 upsert)来保证端到端正确性。PostgreSQL 这样的 OLTP 数据库同样如此:ON CONFLICT DO UPDATE 之所以被高频使用,就是因为它把『重复执行』从危险变成了无害。
业内私下流传一句话:很多工程师不是从文档学会幂等的,是从赔付单学会的。
回到产业视角,这条原理的推论很冷酷:任何『可能被重跑』的写操作,都必须自带身份标识。回填脚本给每个账户的调整打一个唯一的 backfill_id,配合已处理标记表,重跑十次结果也只有一次生效。成本几乎为零,但前提是——你在写脚本第一天就想到了。
🏗️ 事故总发生在『临时脚本』上
回填是数据平台的日常,却是工程投入的洼地。
为什么这类事故反复出现?因为回填这个场景太特殊了:它无处不在,又处处不被当作正经工程对待。
历史数据迁移要回填,口径变更要回填,修线上脏数据要回填,甚至加个索引回填历史行也要回填。一个跑了五年的数据平台,回填任务的累计次数可能远超日常 ETL。但工程资源的分配恰恰相反:在线链路有评审、有灰度、有监控、有值班;而回填脚本往往是——一个人,一个下午,300行,跑一次就删。
『一次性任务』这五个字,是数据工程里最贵的幻觉。一次性意味着没有测试、没有评审、没有对账、没有回滚预案,也就意味着所有的正确性都押注在『它只跑一次、且一次成功』这个脆弱假设上。而现实是:长任务必然遇到死锁、超时、限流、发布冻结,重跑几乎是必然事件。
把业内主流的回填方式放到坐标系里看,分化非常清晰:
左下角是大多数团队的真实状态:裸脚本加手工修复,技术门槛低,但在涉及资金、库存、积分等高价值数据时,风险敞口巨大。右上角的幂等回填框架——任务清单(manifest)、断点续跑、执行前校验、执行后对账一体化——才是高价值数据回填应有的形态。这中间的鸿沟,不是技术鸿沟,是认知鸿沟:团队是否承认,回填和线上变更一样,是一等公民。
一个可操作的判断标准:如果你的回填脚本在跑之前,没人能回答『跑到一半挂了怎么办』,那它就不该上线。
⚠️ 从『跑一次』到『可重跑』的分水岭
可重跑性,应该像测试覆盖率一样成为回填的验收标准。
工程上的解法其实非常成熟,问题只在于执行率。把关键手段列成一张表:
| 技术手段 | 原理 | 适用场景 | 事故覆盖 |
|---|---|---|---|
| 幂等键 / 已处理标记 | 每笔调整带唯一ID,执行前查重 | 资金、积分类增量回填 | 防重复生效 |
| 赋值替代增量 | 计算目标值后整体 SET,而非自增 | 可重算的字段修复 | 防重复生效 |
| 批清单 + 断点续跑 | 按批次记录进度,从失败批次恢复 | 大规模历史数据迁移 | 防盲目全量重跑 |
| Dry-run 预演 | 先跑一遍只读版本,输出变更摘要 | 所有回填上线前 | 防口径错误 |
| 自动对账 | 回填后比对前后快照,差异超阈值告警 | 高价值数据 | 缩短发现窗口 |
注意最后一列。这次事故里最痛的不是重跑本身,而是三天才发现。幂等设计防的是『错』,对账防的是『错了不知道』——两者缺一不可。
工具链其实早就准备好了。Airflow 提供了原生 backfill 机制,任务按日期分区、可指定区间重跑;dbt 的模型天然以『重算取代追加』为设计哲学,full-refresh 的语义就是幂等的。选型之争以外,更重要的信号是:主流数据工具的设计者,已经默认『一切批任务都会被重跑』,并把这个假设焊进了产品。 这就是行业从『跑一次』迈向『可重跑』的分水岭。
反过来,那些还在让运维在午夜手工执行裸 SQL 的团队,缺的不是 Airflow,是把回填纳入变更管理的制度:回填脚本走代码评审,回填执行走调度平台,回填结果走自动对账。三道闸,一道都不能省。
📈 平台化的账本:回填烧掉的不只是算力
回填的真正成本,从来不在计算资源,而在信任。
从成本视角算一笔账。一次回填的显性成本是计算资源和人力;隐性成本包括:回填期间的写入放大对在线服务的性能挤压、错误回填后的逆向修复(你还得再写一个幂等的『冲正』回填)、以及最贵的——用户对账目的信任。余额被错误调增的用户,看到的不只是数字,是这家公司『连账都记不对』的印象。
把回填工程化的关键环节串起来看,每一环都是用事故换来的经验:
这个旅程里每一步的『低分』,本都可以被前置的工程设计拉高:幂等键让重跑无风险,对账让暴露时间从三天缩到三分钟。
对数据平台团队,我的判断很直接:回填能力是数据平台成熟度的试金石。 平时看一个团队的数据基建,看报表、看大盘都看不出深浅;一场口径变更引发的百万级回填,才能看出它的真实水位——有没有批管理、有没有断点续跑、有没有对账闭环。
还有一个正在被低估的趋势:随着 AI 数据管线兴起,训练数据集的『回填』——历史数据重新标注、重新清洗、重新入库——正在把回填场景的规模再抬一个量级。今天在账户余额上交的学费,明天可能要在大模型语料上再交一遍,除非幂等和对账成为默认配置。
🔋 写在最后:把『一次性』当作最危险的两个字
回看这起事故,全程没有一个环节是『犯错』意义上的失误:脚本写得认真,重跑决策合理,运维反应迅速。唯一的缺口,是这段代码假设自己只会跑一次,而现实世界里,长任务必然被重跑。
这值得每一个数据从业者刻进肌肉记忆:增量写操作必须幂等,幂等脚本必须可重入,可重入任务必须可对账。 三句话,就是回填工程的全部底线。
2025年之后,数据量只会更大,回填只会更频繁,AI 时代的数据管线对正确性的要求只会更苛刻。下一次死锁出现时,你希望面对的是一个重跑无害的系统,还是又一次三天后才被客服发现的余额翻倍?答案,在你写下第一行回填代码的那天,就已经决定了。
本文由本站 AI 辅助聚合生成,原始来源如下: