300行脚本重跑一次,客户余额全翻了倍
📋 总体概括
一个精心编写的300行回填脚本,在120万行处撞上死锁、重启重跑,把第一批账户的余额调整重复执行了一遍,三天后才被客服发现。本文从这起典型事故出发,拆解幂等性缺失的工程根因,给出从事后补救到平台化契约的可靠性建设路径。
📄 正文
写脚本的人很认真,跑脚本的人很果断,出事只差一次重试。
最近数据圈流传着一个极具代表性的事故:一段300行的回填脚本,在处理到第120万行时撞上死锁、进程崩溃,运维做了最符合直觉的动作——重新跑一遍。结果前120万个账户的余额调整被执行了第二次,客户账户里出现了超过其历史消费总额的信用余额,三天后才被支持团队察觉。这不是段子,这是每天在无数公司数据管道里潜伏的常态。本文的判断很直接:数据可靠性不缺意识,缺的是把幂等性从'个人修养'变成'平台契约'的工程路径。
🧨 一句 UPDATE,三天后才被发现
金句先行:事故的复杂度,往往和代码量成反比。
先复盘这个 bug 的形状。脚本的核心逻辑只有一句话:对每个需要回填的账户,执行 UPDATE accounts SET balance_cents = balance_cents + %s WHERE id = %s。
问题就藏在 + 这个操作符里。这是一个增量操作,不是赋值操作——它没有任何关于'我是否跑过'的记忆。第一次执行到第120万行时遇到死锁崩溃,重跑不是'恢复现场',而是对前120万个账户再发一轮调整。用原作者的话说:这个脚本恰好只安全一次(safe exactly once)。
整条事故链可以画成这样:
三个细节值得咀嚼。第一,脚本本身'写得很认真'——这不是能力问题,是设计范式问题。第二,重跑是运维的标准动作,没有人会为重跑做额外审批。第三,发现它的不是监控系统,而是客服。数据团队的告警体系盯住了管道是否在跑,却没盯住数据是否被正确地跑了一遍。
据多位数据平台负责人私下交流,这类'增量写+裸重试'的脚本在各公司的财务、积分、库存系统里大量存在,只是大部分还没有撞上死锁而已。不是你们更严谨,是你们运气更好。
♻️ 幂等性:写在教科书里,死在生产里
金句先行:所有可靠性问题,本质都是状态问题。
这个 bug 的学术名字叫'缺失幂等性'(non-idempotency):同一操作执行一次和执行多次,结果不同。解决方案在教科书里写得明明白白——把增量改成带条件的赋值,比如加一个 WHERE not_backfilled = true 的标记位,或者用带幂等键(idempotency key)的写入。
但工程世界的吊诡之处在于:人人都知道幂等,人人都在赶工时跳过它。回填脚本天然带着'一次性'的心理暗示——'就跑这么一回,何必上工程手段?'而恰恰是一次性任务,最缺乏测试覆盖、最缺乏监控、最缺乏重放保护。
把主流的幂等防御手段放进一个坐标系,会发现一个残酷的现实:越可靠的方案,工程成本越高,于是大量的生产系统实际停在左下角。
这个事故脚本所在的象限一目了然:右上角的'幂等键+对账'是唯一能同时防住首次错误和重放错误的组合,但它需要平台能力支撑。指望每个写脚本的人都自发做对,等于把公司的账户余额押在个人修养上。
⚠️ '跑一遍就行'的直觉,为什么总是错的
金句先行:崩溃恢复的直觉,来自单机时代;数据的一致性,活在分布式时代。
为什么操作员会条件反射地重跑?因为在他过往的经验里,'进程挂了→重新执行'是成立的。传统批处理世界里,很多任务确实是幂等的:重新导出一份报表、重新生成一个文件,跑十遍结果都一样。
但数据管道早已不是那个世界。一个典型的现代回填任务横跨多个系统:上游从 PostgreSQL 读数,中间可能经过 Kafka 或调度器如 Airflow 的重试机制,下游写回业务库。任何一环的重试语义如果没有对齐,'至少一次'(at-least-once)投递就会在增量写操作上被放大成'多次生效'。
值得强调的是,主流流处理框架如 Flink 早在设计层面就给出了答案:checkpoint + 端到端 exactly-once 语义,靠的是两阶段提交和幂等 sink。换句话说,问题从来不是业界不懂,而是'临时脚本'这条灰色地带永远游离在框架保护之外。
据接近多家支付和金融科技公司的人士透露,这些公司对数据回填普遍设了三道闸:脚本必须带幂等键、必须先在影子表演练、生产执行需要双人复核。重灾区反而是中长尾公司——数据团队三五个人,回填靠'老王手熟'。
🛡️ 把可靠性做成工程,而不是道德
金句先行:靠自觉的系统,最终都会测试人性。
那正确的工程路径是什么?核心思想只有一条:把'是否幂等'从事后追责变成事前契约。下面这张表给出常见写入模式的风险与对策对照:
| 写入模式 | 重跑后果 | 幂等化改造 | 适配场景 |
|---|---|---|---|
| 增量 UPDATE(+) | 重复累加 | 加已处理标记位 WHERE 条件 | 回填、批量调整 |
| 直接 INSERT | 重复记录 | 唯一键 + ON CONFLICT 忽略 | 明细写入 |
| 赋值式 UPDATE(=) | 结果收敛(天然幂等) | 尽量改写为快照式赋值 | 状态同步 |
| 消息投递 | 重复消费 | 幂等键去重表 | 事件管道 |
| 资金/积分调整 | 直接资损 | 双写流水 + 对账兜底 | 一切财务操作 |
注意最后一行的关键判断:对账不是可选项,是兜底项。即使幂等化做得再好,也需要一个独立的对账任务定期校验'调整总额 = 应调整总额'。这次事故里,如果存在一个'回填前后余额快照 diff'的检查,异常会在数小时内暴露,而不是三天。
更深一层的产业逻辑是:数据可靠性正在从'工程师技能'演变为'平台能力'。dbt 把模型重跑设计成可重放的物化策略,云数仓普遍提供时间旅行(time travel)回滚,这些都是把幂等下沉到平台层的努力。趋势很清晰——未来写脚本的人不该为幂等负责,平台应该让'默认不幂等'这件事写不出来。
📊 从脚本文化到契约文化:平台化的分水岭
金句先行:小团队靠英雄,大团队靠契约。
把这起事故放到更大的坐标系里看,它其实标志着数据基础设施成熟度的一个分水岭。数据平台的建设通常经历三个阶段:脚本驱动(一两个人写一次性任务)、流程驱动(有调度、有审批、有演练)、契约驱动(幂等、对账、回滚是平台的强制接口)。
路线图本身不稀奇,稀奇的是大多数团队的排期里根本没有第一行。回填永远以'应急'的名义绕过所有流程——这次事故里那位操作员重跑脚本时,大概率没有任何流程拦他,因为这类脚本根本不在任何流程里注册。
对数据平台团队,我的建议收敛为三条:第一,财务与积分类数据的所有批量写操作,强制走平台封装的幂等接口,不再允许裸 SQL 回填;第二,为每类关键数据建立'期望值断言',跑前跑后对总账,偏差即告警;第三,把'这个任务重跑一遍安全吗'写进上线路径的必答题,答不上来不许上线。
小结
300 行脚本、一次死锁、一次重跑,让 120 万个账户的余额凭空多了一笔——这个案例的价值不在于讽刺谁,而在于它精确地暴露了数据行业的结构性短板:我们为管道建了调度、监控、血缘,却把'数据是否被正确地写了一遍'留给了个人自觉。幂等性不是高级话题,它是数据基础设施的地基。可以预见,随着数据平台向契约化演进,'默认幂等'会像今天的类型检查一样成为常态;而在此之前,每一次'再跑一遍吧',都可能是下一次事故的开场白。
本文由本站 AI 辅助聚合生成,原始来源如下: