重跑一次脚本,客户余额翻倍
📋 总体概括
一段300行的补数脚本,在120万行处撞上死锁,操作员按下重跑,结果前120万个账户的调账被应用了两次,三天后才被客服发现。本文拆解这起事故的技术病灶——增量写不可重放——并给出写法、断点、对账三道护栏,以及从人肉脚本走向平台化补数的产业判断。
📄 正文
一段补数脚本写得不算粗糙,却在第 120 万行撞上死锁后,把前面所有账户的余额调整又应用了一遍。这不是什么惊天动地的系统故障,而是一次再普通不过的运维操作:进程死了,操作员做了最合理的事——再跑一遍。三天后,客服才发现部分客户的账户余额,竟然超过了他们的历史消费总额。
这类事故值得每一个数据团队停下来看一眼。因为它的病根不在死锁,而在一个被无数团队反复忽视的基本功:幂等性。
🔁 补数脚本,跑了两遍
最危险的故障,往往来自最无害的操作。
事故的过程简单到可以复述完:某团队需要给一批账户做余额调整,有人写了一段补数脚本。脚本跑到中段时碰上数据库死锁,进程直接挂掉。值班的操作员做了所有工程师都会做的事——重新执行。于是,已处理过的那些账户的每一笔调整,都被原封不动地又应用了一次。
数字本身触目惊心:上百万个账户、连续两天被执行同一笔调账、三天无人察觉。最后是客服侧先出了信号——有客户的信用余额,高过了他们一辈子在这个平台花掉的钱。
这类事故几乎从不出现在最复杂的实时链路上,反而集中在这些“一次性的小脚本”上。原因也不难理解:核心链路有评审、有监控、有演练;而补数脚本往往是某个人在某个下午赶工写出来的,没登记、没评审、没对账。它安全跑完,无人知晓;它跑错,代价是账本。
产业逻辑很直白:数据基础设施的可靠性水位,取决于最没有护栏的那条路径。你花大价钱建设的高可用架构,可能被一段人肉脚本一笔勾销。
🧩 病根不在死锁,在“增量写”
死锁只是导火索,真正的炸药是代码本身。
看一眼这个脚本的核心逻辑(改写自事故描述):
`python
for account in accounts_needing_backfill():
conn.execute(
"UPDATE accounts SET balance_cents = balance_cents + %s WHERE id = %s",
(credit_for(account), account.id),
)
`
注意那条 UPDATE:balance_cents = balance_cents + %s。这是一条增量写,不是赋值写。它对历史毫无记忆——数据库不知道这笔调整是否已经执行过。跑一次是调账,跑两次就是第二笔调账。这个脚本恰好只安全一次。
而这正是幂等性(idempotency)的定义:同样的操作执行一次和执行 N 次,结果必须一致。赋值写天然幂等——SET balance = 100 跑一百遍结果都是 100;增量写天然不幂等——SET balance = balance + 100 每跑一遍都在改写历史。
为什么这件事在产业层面如此重要?因为在分布式世界里,重试不是异常,而是常态。网络抖动、连接超时、进程被杀、人工干预、消息重复投递——任何一次重试,对不幂等的任务来说都是一次“再执行一遍”。Kafka、Flink 这类系统投入巨大工程成本去做“精确一次”语义,本质上都是在为同一个问题付费:让重复执行变得无害。
事故脚本则把这笔成本归了零:没有重试安全设计,重跑即灾难。
📊 四象限:哪些任务经不起重跑
不是所有任务都要做到精确一次,但所有涉钱任务都要。
把常见的数据任务放到“重试安全性 × 业务影响”的坐标系里看,你会立刻明白资源该往哪儿倾斜:
事故脚本落在左上角的“潜伏雷区”:重试安全性极低,业务影响却极高。这类组合是所有数据事故的高发带——它平时不动声色,一旦触发就是资损级别。
产业逻辑在此分岔出两种应对:一是把高影响任务全部改造到右侧(幂等化、精确一次语义);二是至少做到分级——对涉账、涉交易、涉用户资产的任务实行最高标准的评审和测试,对临时取数脚本则不必过度投入。怕的不是技术不齐,而是把高影响任务当低影响任务对待。很多团队的管理颗粒度,恰恰做不到这一点:因为脚本“就跑一次”,所以什么防护都省了。而事故剧本的每一次重演,都是从这个“就跑一次”开始的。
🛡️ 四道护栏:写法、断点、对账、登记
幂等不是高级技术,是四条纪律。
回到这起事故,它至少暴露了四个可修复的断点,对应四组“痛点→解法”:
展开说两句最关键的写法问题。补数场景的幂等化,业界常见路数有三种:一是覆盖式写入——与其增量加一笔,不如先算出目标值直接赋值;二是 UPSERT——按主键插入或更新,天然可重放;三是标记表——每处理一个账户就在事务里记一笔“已处理”,重跑时自动跳过。三种做法成本都不高,但前提是写脚本的人知道要这么做。
断点续跑解决的是操作员困境:进程死在中段,操作员重跑不是因为他鲁莽,而是因为系统没有给他“从断点继续”的选项。当正确的操作不存在时,人会选错误的操作——这起事故给出了教科书式的验证。
而对账是最便宜也最容易被跳过的一环。“部分客户余额超过终身消费”这种异常,一条简单的对账规则就能在几分钟内抓出来,而不是等三天后客服转来工单。
🏗️ 补数不该是人肉脚本
把补数从“个人技能”升级为“平台能力”,才是根治之道。
这起事故的深层病灶,是补数这个动作游离在整个数据基础设施之外。成熟的Airflow、Dagster、dbt 生态早已证明:补数(backfill)完全可以是编排平台的一等公民——声明式的重试策略、任务级的状态管理、自动跳过已完成的分区、完整的执行留痕。换言之,把“这段脚本要不要幂等”“重跑会不会出事”这类判断,从写脚本的人身上卸下来,交给平台的结构性保障。
一个务实的改造路线大致是这样的:
对比一下改造前后的差异,一目了然:
| 维度 | 人肉脚本模式 | 平台化补数模式 |
|---|---|---|
| 失败恢复 | 手动重跑,全量再来 | 断点续跑,自动跳过已完成批次 |
| 写法约束 | 依赖个人习惯 | 规范强制幂等模板 |
| 异常发现 | 等客服投诉 | 对账规则主动告警 |
| 责任追溯 | 无留痕 | 全链路执行记录 |
团队间可靠性的差距,很少体现在架构图上,而是体现在“出了事敢不敢按重跑”这个细节里。敢按重跑的平台,说明幂等和状态管理做进了地基;不敢按的,说明每一次故障恢复都在赌运气。
这起补数脚本引发的账目事故,本质上是给全行业提了个醒:数据基础设施的最后一公里,不在那些昂贵的分布式组件里,而在每一次“再跑一遍”的手感里。
小结
一次死锁、一次重跑、三天静默,上百万个账户的调整被重复应用了一遍。这起事故没有任何“高深”的成分,却完整呈现了数据工程最朴素的定律:不幂等的任务,重试就是灾难。护栏也并不昂贵——幂等写法、断点续跑、对账告警、平台登记,四件事没有一件需要前沿技术。随着数据管道规模持续膨胀、AI 训练数据加工链路加入战局,“安全地重来”会从加分项变成底线能力。下一次按下重跑按钮之前,先问问你的脚本:它记得自己跑过没有?
本文由本站 AI 辅助聚合生成,原始来源如下: