下面是一则用于讲解重跑风险的合成情境,不对应某次真实事故;时间与数字只为展示机制。
凌晨两点十七分,任务红了。
值班群里先跳出一条告警,接着是运营同事的一句:“明早八点的经营会要用,能不能先补一下?”屏幕右上角的时间还在往前走,桌上的茶已经凉了。数据开发打开调度平台,看到昨天分区失败在最后一个写入节点,于是点了“重跑”。
二十多分钟后,节点全绿。群里有人回了一个“好了”,大家各自去睡。
第二天九点,报表上的订单金额接近往日两倍。
这个合成情境之所以值得拆解,是因为其中的操作很合乎人的直觉:任务失败了,就再跑一次;调度成功了,数据应该也好了。问题在于,调度平台证明的只是这次进程走到了终点,并没有证明目标表回到了正确状态。

补数最危险的地方,往往不在那条失败的 SQL,而在“再跑一次”这四个字里。它听起来像重复一个动作,实际上可能是把旧结果保留着,又塞进一份新结果;也可能只重跑了上游,却让下游把同一批消息再消费一遍。
所以,在点下重跑之前,我更愿意先问四个问题:这次到底覆盖哪一段数据?同一条业务记录怎么认出来?失败后从哪里重新开始?下游会不会再吃一遍?
这四个问题,才是补数能不能安全的幂等条件。
幂等不是“这次没报错”,而是再跑十次也不多一行
“幂等”这个词听上去有些像数学老师在黑板上留下的记号。放到数据任务里,其实很朴素:输入和规则不变时,同一个任务运行一次、两次或十次,最终数据状态应该相同。
注意,是“最终状态相同”,不是“每次日志都长得一样”,也不是“每次都返回 success”。
一条任务第一次向目标表插入 10 万行,第二次又插入 10 万行,两次都可能成功。若目标表变成 20 万行,它就是一个很勤快、也很不幂等的任务。勤快有时比报错更麻烦,因为报错会叫人来看,重复数据却常常安静地躺到第二天。
Apache Airflow 的官方最佳实践把任务类比为数据库事务:任务应该避免留下不完整结果;由于任务可能重试,每次重跑应产生相同结果。文档给出的建议很直白:重跑时不要只做 INSERT,应考虑 UPSERT;读取和写入都要落到明确分区,不要使用会随时间变化的“最新数据”。
dbt 对幂等的定义也类似:同一个模型跑一次或一百次,最终结果应一致。特别是增量模型,如果只追加、不去重,也没有可靠的 unique_key,重跑很容易把边界上的记录再次写入。
工具已经把道理说得很清楚。真正到了凌晨,难的是把它翻译成手上的四次确认。
条件一:重跑范围必须能被一句话说清
补数前,先别问“重跑哪个节点”,先问“这次要修复哪个数据范围”。
这两个问题看着很像,答案却常常不同。
比如一条日任务在 2026-08-24 分区失败。你点“重跑昨日任务”,系统实际读的可能是“当前时间往前 24 小时”;也可能读取源表里最新到达的数据;还可能在写入时覆盖当天分区,却在中间表继续追加。等到凌晨三点再执行,输入边界已经和第一次不同。
安全的范围至少要写清三件事:
- 业务时间范围:修复哪一天、哪一个小时,时区是什么。
- 输入快照范围:按哪个事件时间、分区或批次号取数。
- 输出替换范围:覆盖整个分区、替换某批记录,还是只追加缺失部分。
如果这三件事说不清,所谓重跑就不是“把同一件事再做一次”,而是“在另一个时间重新猜一次输入”。
这也是为什么 Airflow 建议任务读写指定分区,而不是依赖 latest 或运行时的 now()。幂等的第一步,不是写更复杂的去重 SQL,而是先把时间边界钉在桌面上。
在执行记录里,可以要求值班同事写一句完整的话:
本次重跑读取北京时间 8 月 24 日 00:00—24:00 的订单事件,按
event_date=2026-08-24重建目标分区,不读取 8 月 25 日新到数据。
能写成这样,范围才算有了形状。只写“补昨天订单”,还不够。
条件二:每条业务记录必须有稳定身份
范围钉住以后,第二个问题是:同一条记录,系统能不能认出它还是它?
订单表最常见的是 order_id,但真实系统很少这么省心。一个订单可能拆成多个商品行,需要 order_id + item_id;一笔支付可能经历授权、扣款、退款,需要再加事件类型;同一业务键还可能出现迟到更新,必须用版本号或更新时间决定保留哪一条。
所谓“唯一键”,不是随手挑一列看起来不重复,而是把目标表的一行究竟代表什么说清楚。
dbt 官方文档提醒,增量模型配置 unique_key 后,匹配记录可以被更新或替换;没有它时,多数适配器会退回追加行为。文档还特别指出:唯一键列如果含有空值,匹配可能失败,结果仍会出现重复。
这件事很容易在补数现场露出原形。平时新增数据只走一次,空键似乎没有惹事;一旦重跑,数据库发现 NULL 和 NULL 不能稳定匹配,于是很有礼貌地再插一行。第二天大家看到两条一模一样的记录,才想起“唯一”只是名字,不是事实。
因此,补数前至少要验证:
- 唯一键覆盖目标表真实业务粒度;
- 键值不为空,组合键没有漏列;
- 同键多版本有明确保留规则;
- 写入方式是覆盖、
MERGE、UPSERT或先删后写,而不是无条件追加。

这里还有一个常见误会:做了 distinct 就算去重。
distinct 只能去掉所有字段完全相同的两行。如果同一订单第二次写入时多了一个 updated_at,或任务给每次执行生成新的批次号,两行就不再相同。真正要去的是“同一业务事实的重复版本”,判断依据必须来自业务身份,而不是整行长相。
条件三:失败位置和重试起点必须配成一对
许多任务不是一条 SQL,而是一串动作:读源表、写临时表、合并目标表、刷新汇总、发送完成标记。失败发生在哪一步,决定了重跑应该从哪里开始。
可惜调度平台上的“重跑”按钮经常把这件事藏得很好。按钮只有一个,背后的状态却可能有四种:
- 目标表还没写,整段可以重做;
- 目标表写了一半,留下了部分结果;
- 目标表已经提交,只是成功回执丢了;
- 主表成功,下游汇总或消息发送失败。
这四种状态不能用同一种方式处理。
尤其是第三种。任务日志停在超时,看起来像失败;数据库事务其实已经提交。值班同事如果把它当作“没有写入”再执行一次,重复就这样产生了。分布式系统最爱在这个缝隙里开玩笑:服务端做完了,客户端没听见。
解决它不能只靠一句“失败就重试”,而要给每个批次一个稳定的执行身份,并记录几个关键状态:批次开始、目标写入提交、对账完成、下游可见。重跑时先查状态,再决定是继续、覆盖、补偿还是停止。
批处理里,这个身份可以是 dataset + business_date + version;流任务里,可以是稳定的事务 ID、消息键和消费位点。名字不重要,重要的是同一批工作不要因为进程重启就换了身份证。
Apache Kafka 的 KIP-98 解释了另一个边界:生产者幂等可以防止客户端重试造成同一分区内的重复写入,但跨多个分区、同时包含消费位点和输出写入时,需要事务把这些动作作为一个整体提交或放弃。换句话说,“写消息不重复”不自动等于“整条消费—处理—输出链路不重复”。
这正是补数现场最容易漏掉的一层。你修复的不是一个进程,而是一段状态迁移。
条件四:下游必须知道这批数据是修复,不是新来一遍
假设目标明细表已经安全覆盖,是否就可以收工?还不行。
一张表的下游可能有聚合表、缓存、搜索索引、导出文件、消息队列和经营报表。上游覆盖了分区,下游却可能采用追加消费;上游行数正确,下游累计值仍然多算一次。
这类事故有一个很迷惑的表象:你在修复表里查不到重复,业务报表却还是翻倍。数据开发盯着正确的明细表,分析师盯着错误的汇总数,两边都觉得对方没刷新页面。
因此,补数计划里要明确列出下游的处理方式:
- 下游按分区重算,还是按事件增量累计;
- 修复批次是否会触发第二次消息;
- 已消费位点是否需要回退或保持;
- 缓存、抽取文件和 BI 数据集是否需要刷新;
- 哪些下游不能自动重放,必须单独做补偿。
如果链路跨越消息系统,还要特别小心“至少一次”语义。它通常保证消息不会轻易丢失,却允许重试时出现重复。是否最终只生效一次,还取决于生产者、事务范围和消费者写入是否共同处理了重复。
说到底,幂等不是某一张表的个人品德,而是整条链路的共同约定。
一个补数事故,通常是怎样把数字翻倍的
我们用一个简化的演示场景把四个条件串起来。
某订单日任务正常应产出 100 万条商品明细。第一次运行到 92 万条时,写入连接超时,调度节点标记失败。目标表使用追加写入,没有按日期覆盖;任务也没有稳定的业务唯一键约束。
值班同事直接重跑整个任务。第二次成功写入 100 万条,于是目标表里有 192 万条。下游销售汇总按新增行累计,又把其中 92 万条算了一次。调度看起来完全正常,数字却已经走远了。
如果四个条件都在,处理方式会不同:
- 范围固定为当天分区,不把第二天迟到数据混进来;
- 目标表按业务键合并,或原子替换当天分区;
- 根据批次提交状态判断第一次写入是否生效;
- 下游按同一修复批次重算,而不是把它当作新订单追加。
这四步没有一项特别神秘。它们只是要求人在凌晨点按钮之前,多花几分钟确认系统到底记住了什么。
紧急补数前,具体检查什么
如果时间很紧,可以先把检查压缩成一张四行记录。信息齐全、链路简单时,这一步可能在十几分钟内完成;复杂链路需要更久,不能把“15 分钟”当作时限或安全保证。记录不是为了填表,而是为了让值班人和第二天接手的人看到同一件事。
| 检查项 | 必须回答的问题 | 最低证据 |
|---|---|---|
| 数据范围 | 读哪段、写哪段、按哪个时区 | 分区值、时间窗、输入批次号 |
| 业务身份 | 同一条记录靠什么识别 | 唯一键测试、空值数、同键多版本规则 |
| 重试边界 | 第一次执行到底提交到哪一步 | 目标表快照、事务/批次状态、失败节点日志 |
| 下游消费 | 哪些对象会再次计算或累计 | 血缘清单、消费位点、刷新/补偿方式 |
确认后,再选择动作。动作最好只落在以下三类之一:
- 重建:清除或原子替换明确范围,从固定输入重新计算;
- 续跑:从可验证的检查点继续,不重复已提交部分;
- 补偿:保留原结果,用一笔可追踪的反向或修正记录恢复状态。
最怕的是三种方式混在一起:先手工删几行,再重跑半个节点,发现不对又改一条汇总 SQL。最后数字也许对了,却没人能说清为什么对。下一次事故来时,团队只剩下一段传说。

任务转绿以后,还要留下四个数
补数完成不能只截一张绿色节点图。绿色是过程证据,不是结果证据。
至少留下这四个数:
- 目标行数:补数前、补数后与正常基线分别是多少。
- 唯一键数:总行数和唯一业务键数是否符合预期。
- 核心金额或数量:与源系统、上游快照或独立汇总能否对上。
- 下游可见值:关键报表、汇总表或消费端的最终结果是什么。
如果有条件,再做一次“二次重跑验证”:在隔离环境或可回滚分区里,对同一输入再执行一次,确认四个数没有变化。幂等这件事,最好的证明不是设计文档,而是再跑一次,结果真的不动。
当然,不是所有链路都能立刻做到严格幂等。遗留系统可能没有唯一键,外部接口可能无法回放,消息下游也可能不支持事务。此时不要假装按钮安全,而应把风险写出来:哪些范围会重复、需要谁复核、出现偏差如何回退。
系统不完美并不可耻。真正危险的是,大家都知道它不完美,却还把“重跑成功”当成“数据正确”。
下一次告警响起时,先把手从重跑按钮上移开
凌晨值班最稀缺的不是技术,而是注意力。人困,群里催,会议时间又写在明早。重跑按钮放在那里,像一个很有礼貌的捷径。
可是数据任务失败以后,我们面对的不是一个需要再执行的命令,而是一份已经改变、又没有完全说明自己的数据状态。
先确认范围,再确认身份;先看提交边界,再看下游。检查耗时取决于证据是否齐全,不能保证消除事故;它的作用,是在执行前暴露重复写入和下游重放风险。
调度转绿,只说明机器不再喊疼。
数据能不能用,还得由证据说话。
我叫石头,在互联网数据领域工作 11 年,曾任阿里巴巴高级数据架构专家。这里把补数风险整理成一套可复核的方法,结论仍应以具体链路的日志、快照和对账结果为准。
来源:Apache Airflow Best Practices · dbt:Idempotence in dbt · dbt:Configure incremental models · Apache Kafka KIP-98:Exactly Once Delivery and Transactional Messaging