脚本重试一次,余额翻了一倍
📋 总体概括
一段300行的回填脚本在120万行处撞上死锁,操作员重跑一次,120万个账户余额被二次加值,三天后才被客服发现。本文从事故复盘出发,拆解增量操作与幂等性的产业级命题:重试是数据系统的默认假设,幂等不是最佳实践,而是管道的底线设计。
📄 正文
一段 300 行的回填脚本,跑到第 120 万行撞上死锁,进程死了。操作员做了所有人都觉得『理所当然』的事:再跑一遍。三天后,客服发现一批用户的账户余额,比他们的终身消费还高。
这不是段子,这是数据行业每天都在重演的经典剧本。核心判断只有一句:重试是数据系统的默认假设,非幂等管道在重试面前,等于数据损坏。
🐍 三天才发现的事故,坏得并不冤
先复盘这个事故本身。
脚本是有人认真写的,300 行,不算是拍脑袋的产物。它在第 120 万行撞上数据库死锁,进程退出。值班的人看到报错,选择重跑——这是任何运维手册都会教你的标准动作。于是前面已经处理完的 120 万个账户, adjustment 被又应用了一次。
最耐人寻味的是那个『三天』。三天里,管道没有任何报错,任务显示成功,监控一片绿色。直到客服收到用户提问:为什么我的余额比我花过的钱还多?
换句话说,发现问题的不是数据系统,是业务系统。数据管道自己,从头到尾都认为一切正常。
圈内流传一个近乎残酷的玩笑:数据工程师分两种,一种是被非幂等脚本咬过的,另一种是还没被咬的。这次事故里没有笨蛋——写脚本的人认真,重跑的人按规矩办事,但管道缺了最关键的一层保险。
产业逻辑在这里很清楚:数据事故的损失曲线,往往不在出错那一刻,而在发现出错那一刻。 没有对账机制、没有执行留痕的管道,错误会静默地复利式放大。余额翻倍还算是『幸运』——至少金额对不上会被用户察觉;如果是内部统计报表被重复计算,可能要到季度复盘才东窗事发。
⚠️ 增量不是赋值:一行 SQL 暴露的通病
看事故的核心代码形态,其实就一行:
`sql
UPDATE accounts SET balance = balance + ? WHERE id = ?
`
问题全在这一个 + 号上。这是增量操作,不是赋值操作——它对『自己有没有跑过』毫无记忆。跑一遍是加值,跑两遍就是两次加值。用素材里的话说,这个脚本是『恰好安全一次』(safe exactly once):它在无故障世界里是正确的,但重试一发生,正确性就归零。
把事故链条画出来,每一步都『合理』,合起来却是灾难:
注意中间那格:任务显示成功。这正是最危险的地方——重跑后的任务状态是绿色的,错误只存在于数据本身。
为什么这事值得上升到产业层面?因为在现代数据平台里,重试不是一个『异常处理』,而是架构的默认行为。Airflow 的 task 失败自动重试、Flink 的 checkpoint 恢复、Spark 的 stage 重算、消息队列的 at-least-once 投递——整套基础设施都在假设:任何一段计算都可能被重复执行。上游每多一层自动重试的『智能』,下游每一段非幂等逻辑就多一分被引爆的概率。
据多位做过账务与计费系统的一线工程师的说法,几乎所有账务系统的第一版回填脚本,都是不幂等的。区别只在于,有人在被咬之前改掉了,有人在被咬之后。
📊 回填策略四象限:你站在哪个格子
既然『裸增量脚本』是原罪,实践中正确的回填姿势有哪些?按技术门槛和业务价值摆一摆:
逐格说说。
快照覆盖写(右上,领跑者):不做增量修补,直接全量重算并覆盖目标表。重跑一遍和跑 N 遍结果一致,天然幂等。代价是重算成本,适合逻辑复杂、数据量可控的核心表。
CDC 流式同步(右上偏门槛,领跑者):基于变更日志回放,重放位置由位点(offset/binlog 位点)管理,而不是靠人脑记住『跑到第几行』。门槛高,但把『断点在哪』从操作员手里收归系统。
批次 ID 幂等脚本(中间,潜力股):给每次回填打上 run_id,写入前先检查该批次是否已应用,或用 upsert 把增量改成赋值。改造成本低,是存量脚本最现实的救赎路径。
裸 SQL 手工补数(左下):事故剧本的标准开局。门槛最低、见效最快、也最不可重入。任何一个还在用这种方式补生产数据的团队,本质上是在和概率对赌。
还有一个反面样本值得点名:右下角的『过度设计的微批框架』——技术堆得很高,却没有解决语义幂等,重试照样翻车。高门槛不等于高价值,幂等是语义问题,不是工程炫技问题。
🧰 把『跑一遍对』改成『跑 N 遍都对』
落到工程清单,幂等管道的常见解法可以归纳成一张表:
| 策略 | 核心做法 | 适用场景 | 主要局限 |
|---|---|---|---|
| 全量快照覆盖 | 重跑即重算,直接覆盖结果表 | 核心维度表、口径变更 | 重算成本随数据量增长 |
| 批次号 + 去重 | 每次执行携带 run_id,先查后写 | 历史脚本低成本改造 | 需要额外的执行元数据表 |
| 主键 upsert | 用 MERGE / INSERT ON CONFLICT 替代增量 UPDATE | 账务、状态类表 | 依赖主键设计是否完备 |
| 断点续跑 | 按批次切片,重跑从失败批次续传 | 大批量回填 | 切片粒度影响恢复精度 |
| 湖仓快照提交 | Iceberg、Hudi 的原子快照 commit,重跑生成新快照而非叠加 | 湖仓一体管道 | 需要元数据治理配套 |
| 事后对账 | 校验和、影响行数、业务规则交叉验证 | 兜底防线 | 只能发现问题,不能防问题 |
这套组合拳里,最容易被忽视的是最后一行。素材里那个事故之所以拖了三天,不是因为缺少幂等设计——那当然有——而是因为管道跑完后,没有任何机制回答『结果对不对』这个问题。余额和消费流水一对,异常立刻现形;但对账是业务侧客服发现的,不是管道侧自己发现的。
生态层面也在给出系统性答案。dbt 把模型定义成可重建的物化(table/incremental),重跑语义内建在框架里;Iceberg 等表格式用原子快照替换来杜绝『叠加式』写入;新一代调度器普遍把重试、断点、幂等承诺打包成默认能力。趋势很明确:幂等正在从工程师的个人素养,下沉为基础设施的平台责任。 这和当年测试从手工 QA 下沉到 CI 流水线,是同一条路径。
🏆 AI 时代,幂等从最佳实践变成底线
如果说账务翻倍还是『传统』数据事故,那么 Data for AI 的兴起正在把幂等的重要性再抬一级。
今天的训练数据集构建,本质上就是一条超大规模的数据管道:抓取、清洗、去重、标注、合成、版本化回填。一条清洗作业被重复执行,后果不再是『余额翻倍』这种可对账的错误,而是训练集被污染——重复样本悄悄改变数据分布,进而影响模型行为,而且极难溯源。数据集的『可重放性』(reproducibility)直接依赖底层每一次写入的幂等承诺。
把当前非幂等管道的痛点和解法对齐一下:
还有一个常被忽略的成本视角。非幂等管道迫使他团队不敢开自动重试,于是故障恢复退化为人肉值班——这本身就是真金白银的人力成本。反过来说,幂等不是给管道上保险的『额外开销』,而是把故障恢复从人力切换到机器的前提条件。 不解决幂等,所有自动化都是带引信的。
对数据平台的采购方,这甚至应该成为评估项:问一句『这条管道重跑会发生什么』,对方的回答质量,比任何架构图都更能说明其工程水位。
回到开头那段 300 行的脚本。它不坏,甚至算得上认真;坏的是整个系统没有回答一个问题:这段逻辑被重复执行时,世界会变成什么样? 死锁只是扣动了扳机,非幂等才是膛里的子弹。当重试成为所有数据基础设施的默认行为,『跑一遍是对的』早已不够格——只有『跑 N 遍都对』的管道,才配得上自动化这个词。下一代数据平台之间的差距,不会写在功能列表里,而会写在这些没人看见的语义承诺上。
本文由本站 AI 辅助聚合生成,原始来源如下: