一段脚本重跑两次,余额悄悄翻倍了
📋 总体概括
一段300行回填脚本在120万行处遭遇死锁,操作员重跑后导致前段账户余额被重复加值,三天后才被客服发现。本文拆解这起典型事故背后的幂等性缺失问题:增量操作无记忆、重试机制天然放大危害、回填脚本游离于工程规范之外,并给出把'重跑'当第一设计目标的系统性解法。
📄 正文
三天,120万行,一次死锁后一次“理所当然”的重跑,把一批用户的账户余额直接翻倍。没有黑客,没有宕机,只有一段300行的回填脚本,少写了一个设计目标:幂等。我的判断很直接——数据管道的自动化程度越高,“不可重跑”的代码就越是一颗定时炸弹,而绝大多数团队根本没有拆弹流程。
🔥 三天潜伏:一次死锁引发的翻倍事故
先把事故本身讲清楚。
有人认认真真写了一段300行的回填脚本,逐行给账户加一笔调整额。跑到第120万行时撞上了死锁,进程死了。操作员做了所有人都视作“标准动作”的事:再跑一遍。于是前120万个账户,每一笔调整都被应用了第二次。
没有人立刻察觉。三天后,是客服先发问:为什么有些客户的信用余额,比他们的终身消费额还高?
问题出在那一行SQL的“形状”上:
`sql
UPDATE accounts SET balance_cents = balance_cents + %s WHERE id = %s
`
这是一个累加,不是一个赋值。它没有记忆,不记得自己是否跑过。跑两遍不叫重试,叫再发一笔钱。这段脚本只在恰好运行一次时是安全的。
事故的根因不在操作员“手贱”,而在系统对重跑没有任何防御。人一定会重跑——死锁重试、任务失败重跑、调度器补数,全是运维世界的默认动作。一个“只安全一次”的操作,等于把风险抵押给了“永远不出意外”,而数据工程里没有“永远不出意外”这回事。
这不是孤例的想象。2012年骑士资本(Knight Capital)的著名事故就是同款剧本:代码部署只覆盖了8台服务器中的7台,一套早已废弃的下单逻辑被意外激活,且订单发送没有幂等保护——同样的订单被重复发出,45分钟内亏损约4.4亿美元,公司几乎一夜倾覆,最终被SEC处以1200万美元罚单。事后复盘指向的正是同一件事:重跑/重复执行不是异常路径,而是必然发生的事件,系统必须为其设计防御。
🧩 幂等不是工程美德,是底线配置
金句先放这儿:在数据世界里,“再跑一次”不是异常路径,是主路径。
幂等性的定义朴素到近乎无聊:同一个操作执行一次和执行N次,最终结果一样。把常见操作按重跑风险拆开看:
| 操作模式 | 重跑一次的后果 | 幂等性 | 修复思路 |
|---|---|---|---|
| 累加/递增(balance + x) | 重复加值,金额翻倍 | ❌ 无 | 改为确定性赋值,或引入去重键 |
| 追加写入日志/消息 | 数据重复 | ❌ 无 | 消费端去重、唯一约束 |
| 状态覆写(SET status = x) | 结果不变 | ✅ 有 | 优先用覆写表达变更 |
| UPSERT(存在则更新) | 结果不变 | ✅ 有 | 用主键/业务键兜底 |
| 全量重建物化结果 | 结果不变 | ✅ 有 | 覆盖式写 + 原子切换 |
赋值是安全的,累加是危险的——就这么一条朴素原则,足以挡掉大量事故。行业里最成功的几个产品,本质都是在把“重跑安全”从工程师的个人自觉,做成产品的默认值:Kafka 自 0.11 版本(2017年)引入事务和幂等 Producer,把 exactly-once 语义做成消息系统的内建能力;dbt 的模型默认物化为可全量重建的表,dbt run 跑多少遍结果都收敛一致;PostgreSQL 的 INSERT ... ON CONFLICT DO UPDATE(UPSERT)干脆把幂等写做进了一条SQL语法里。
推论是:重试机制越是被内建到基础设施里——死锁自动重试、网络抖动自动重传、调度器失败自动补跑——非幂等操作的杀伤力就越是被动放大。你写的每一行不可重跑的代码,都在等着被这套“善意”的基础设施执行两遍。
⚙️ 为什么回填脚本成了事故高发区
场景你一定熟悉:线上口径变了、历史分区要补数、迁移要回填,于是某位工程师本地写个脚本,检查一遍逻辑,直奔生产。
在多数组织里,回填脚本是个“三不”地带:不进正式 code review、不上预发环境、不做干跑校验——因为它是“一次性的”,“写得很仔细了”。但写得认真,救不了设计上的缺陷。
回到本案例,三个细节都指向流程缺位:脚本300行,说明不是随手糊的;死锁发生在第120万行,说明前119万行“看起来都成功了”;事故靠客服工单暴露,三天无人发现,说明账务侧根本没有自动对账——“余额大于终身消费”这种肉眼可见的不变量,本该是一行告警的事。Gartner 多年前那句“劣质数据每年让美国企业平均损失约1500万美元”至今仍被反复引用,而其中最贵的一类损失,恰恰来自这种“看起来都成功了”的静默错误。
把常见的回填任务按“是否幂等”和“业务影响”放到一个坐标系里,风险集中在哪一目了然:
平台团队给管道配了调度、监控、血缘,却几乎从不给“回填”这个最高频的危险动作配框架。左上角那些又危险又高影响的任务,恰恰是账务、订单、积分这类核心资产——它们之所以还在雷区,不是因为难做幂等,而是因为组织默认“一次性脚本可以走捷径”。
🛡️ 把重跑当第一设计目标
💡 这是我搭管道这些年最硬的心得:设计任何写操作时,第一个要回答的问题不是“怎么跑对”,而是“跑错了重跑会怎样”。
对照本案例,一套完整的防御打法是这样的:
展开说三条最关键的:
第一,能覆写不累加。把“加x”改成“设为 f(x)”——只要 f 是确定性的,跑一百遍结果都一样。做不到时,给每笔变更带上 run_id + 账户 ID 的唯一键,重复执行自动跳过。这就是“操作账本”的思路。
第二,失败要能续跑,不是重来。120万行处死了就从头跑,等于把幂等性赌在脚本自己身上。分批+检查点,让重跑只处理未完成的部分,风险敞口缩小几个量级。
第三,不变量监控不是可选项。“信用余额 ≤ 终身消费”就是一条天然的会计恒等式。一条 SQL 定时对账,三天潜伏期会变成三分钟。数据平台建设到今天,血缘和延迟监控已经普及,但业务不变量对账仍是大多数平台的空白——这恰恰是回报最高的一块。
更深一层的判断是:幂等性不该依赖每个人的自觉,而该是平台能力。就像当年的 ACID 事务——它成为底线,不是因为每个程序员道德高尚,而是数据库把它做成了产品。回填框架、干跑工具、对账服务,就是数据平台时代的“事务”。
小结
一段300行的脚本,翻倍了一批余额,也翻出了一个行业的欠账:我们为数据管道建了调度、建了监控、建了血缘,唯独没为“重跑”这个必然事件建防御。随着 AI 把数据管道的重试、补数甚至脚本生成进一步自动化,“跑两次”的频率只会更高。幂等性正在从工程美德,变成数据平台的准入门槛:谁先把它做成默认值,谁的数据资产才配得上“可信”两个字。
本文由本站 AI 辅助聚合生成,原始来源如下: