🏢 公司C档 · NaN分

300行脚本,让全平台余额翻倍

··约1分钟阅读

📋 总体概括

一段精心编写的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,是把回填纳入变更管理的制度:回填脚本走代码评审,回填执行走调度平台,回填结果走自动对账。三道闸,一道都不能省。

📈 平台化的账本:回填烧掉的不只是算力

回填的真正成本,从来不在计算资源,而在信任。

从成本视角算一笔账。一次回填的显性成本是计算资源和人力;隐性成本包括:回填期间的写入放大对在线服务的性能挤压、错误回填后的逆向修复(你还得再写一个幂等的『冲正』回填)、以及最贵的——用户对账目的信任。余额被错误调增的用户,看到的不只是数字,是这家公司『连账都记不对』的印象。

把回填工程化的关键环节串起来看,每一环都是用事故换来的经验:

这个旅程里每一步的『低分』,本都可以被前置的工程设计拉高:幂等键让重跑无风险,对账让暴露时间从三天缩到三分钟。

对数据平台团队,我的判断很直接:回填能力是数据平台成熟度的试金石。 平时看一个团队的数据基建,看报表、看大盘都看不出深浅;一场口径变更引发的百万级回填,才能看出它的真实水位——有没有批管理、有没有断点续跑、有没有对账闭环。

回填工程化的四道关卡
痛点 1
增量操作没有记忆,重跑即重复生效
→
解法
幂等键 + 已处理标记,或改写为赋值语义
痛点 2
失败后凭感觉全量重跑
→
解法
批清单 + 断点续跑,只补失败批次
痛点 3
没有预演直接上线
→
解法
dry-run 输出变更摘要,抽样人工复核
痛点 4
错误三天后才被客服发现
→
解法
回填后自动对账,差异超阈值即告警

还有一个正在被低估的趋势:随着 AI 数据管线兴起,训练数据集的『回填』——历史数据重新标注、重新清洗、重新入库——正在把回填场景的规模再抬一个量级。今天在账户余额上交的学费,明天可能要在大模型语料上再交一遍,除非幂等和对账成为默认配置。

🔋 写在最后:把『一次性』当作最危险的两个字

回看这起事故,全程没有一个环节是『犯错』意义上的失误:脚本写得认真,重跑决策合理,运维反应迅速。唯一的缺口,是这段代码假设自己只会跑一次,而现实世界里,长任务必然被重跑。

这值得每一个数据从业者刻进肌肉记忆:增量写操作必须幂等,幂等脚本必须可重入,可重入任务必须可对账。 三句话,就是回填工程的全部底线。

2025年之后,数据量只会更大,回填只会更频繁,AI 时代的数据管线对正确性的要求只会更苛刻。下一次死锁出现时,你希望面对的是一个重跑无害的系统,还是又一次三天后才被客服发现的余额翻倍?答案,在你写下第一行回填代码的那天,就已经决定了。

本文由本站 AI 辅助聚合生成,原始来源如下:

🔎 本文基于以下资讯(素材溯源 · 信息来源)

📰 相关阅读推荐(与本文相关的其他资讯)