🏢 公司C档 · NaN分

一段脚本重跑两次,余额悄悄翻倍了

··约1分钟阅读

📋 总体概括

一段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万美元”至今仍被反复引用,而其中最贵的一类损失,恰恰来自这种“看起来都成功了”的静默错误。

把常见的回填任务按“是否幂等”和“业务影响”放到一个坐标系里,风险集中在哪一目了然:

平台团队给管道配了调度、监控、血缘,却几乎从不给“回填”这个最高频的危险动作配框架。左上角那些又危险又高影响的任务,恰恰是账务、订单、积分这类核心资产——它们之所以还在雷区,不是因为难做幂等,而是因为组织默认“一次性脚本可以走捷径”。

🛡️ 把重跑当第一设计目标

💡 这是我搭管道这些年最硬的心得:设计任何写操作时,第一个要回答的问题不是“怎么跑对”,而是“跑错了重跑会怎样”。

对照本案例,一套完整的防御打法是这样的:

回填事故的工程解法
痛点 1
增量操作不可重跑
→
解法
能用确定性赋值就不用累加,必须累加时引入去重键
痛点 2
失败后从头重跑
→
解法
分批执行加检查点,断点续跑而非整段重来
痛点 3
影响面靠猜
→
解法
先干跑统计影响行数,与预期对上再真执行
痛点 4
异常三天没人发现
→
解法
建立业务不变量自动对账,余额守恒类规则即时告警
痛点 5
脚本游离于规范之外
→
解法
回填脚本进评审、进预演,纳入平台统一框架

展开说三条最关键的:

第一,能覆写不累加。把“加x”改成“设为 f(x)”——只要 f 是确定性的,跑一百遍结果都一样。做不到时,给每笔变更带上 run_id + 账户 ID 的唯一键,重复执行自动跳过。这就是“操作账本”的思路。

第二,失败要能续跑,不是重来。120万行处死了就从头跑,等于把幂等性赌在脚本自己身上。分批+检查点,让重跑只处理未完成的部分,风险敞口缩小几个量级。

第三,不变量监控不是可选项。“信用余额 ≤ 终身消费”就是一条天然的会计恒等式。一条 SQL 定时对账,三天潜伏期会变成三分钟。数据平台建设到今天,血缘和延迟监控已经普及,但业务不变量对账仍是大多数平台的空白——这恰恰是回报最高的一块。

更深一层的判断是:幂等性不该依赖每个人的自觉,而该是平台能力。就像当年的 ACID 事务——它成为底线,不是因为每个程序员道德高尚,而是数据库把它做成了产品。回填框架、干跑工具、对账服务,就是数据平台时代的“事务”。

小结

一段300行的脚本,翻倍了一批余额,也翻出了一个行业的欠账:我们为数据管道建了调度、建了监控、建了血缘,唯独没为“重跑”这个必然事件建防御。随着 AI 把数据管道的重试、补数甚至脚本生成进一步自动化,“跑两次”的频率只会更高。幂等性正在从工程美德,变成数据平台的准入门槛:谁先把它做成默认值,谁的数据资产才配得上“可信”两个字。

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

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

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