跳到正文

更多文章

数据周刊|2026年第37周:AI落地,先算清性能、语义和价值三笔账 用户数涨了,活跃率反而降了:分析师先拆哪 3 个分母 大促券核销率突然翻倍:分析师用 5 张表拆开重复领取与真实转化 文件名都叫最终版,分析师先用 4 个字段结束 Excel 版本大战 老板问数据团队下季度要不要加人:负责人用 5 类证据写容量计划
凌晨补数显示成功,第二天报表却多了一倍:数据开发先查 4 个幂等条件

下面是一则用于讲解重跑风险的合成情境,不对应某次真实事故;时间与数字只为展示机制。

凌晨两点十七分,任务红了。

值班群里先跳出一条告警,接着是运营同事的一句:“明早八点的经营会要用,能不能先补一下?”屏幕右上角的时间还在往前走,桌上的茶已经凉了。数据开发打开调度平台,看到昨天分区失败在最后一个写入节点,于是点了“重跑”。

二十多分钟后,节点全绿。群里有人回了一个“好了”,大家各自去睡。

第二天九点,报表上的订单金额接近往日两倍。

这个合成情境之所以值得拆解,是因为其中的操作很合乎人的直觉:任务失败了,就再跑一次;调度成功了,数据应该也好了。问题在于,调度平台证明的只是这次进程走到了终点,并没有证明目标表回到了正确状态。

凌晨值班的数据开发在失败任务前判断是否重跑

补数最危险的地方,往往不在那条失败的 SQL,而在“再跑一次”这四个字里。它听起来像重复一个动作,实际上可能是把旧结果保留着,又塞进一份新结果;也可能只重跑了上游,却让下游把同一批消息再消费一遍。

所以,在点下重跑之前,我更愿意先问四个问题:这次到底覆盖哪一段数据?同一条业务记录怎么认出来?失败后从哪里重新开始?下游会不会再吃一遍?

这四个问题,才是补数能不能安全的幂等条件。

幂等不是“这次没报错”,而是再跑十次也不多一行

“幂等”这个词听上去有些像数学老师在黑板上留下的记号。放到数据任务里,其实很朴素:输入和规则不变时,同一个任务运行一次、两次或十次,最终数据状态应该相同。

注意,是“最终状态相同”,不是“每次日志都长得一样”,也不是“每次都返回 success”。

一条任务第一次向目标表插入 10 万行,第二次又插入 10 万行,两次都可能成功。若目标表变成 20 万行,它就是一个很勤快、也很不幂等的任务。勤快有时比报错更麻烦,因为报错会叫人来看,重复数据却常常安静地躺到第二天。

Apache Airflow 的官方最佳实践把任务类比为数据库事务:任务应该避免留下不完整结果;由于任务可能重试,每次重跑应产生相同结果。文档给出的建议很直白:重跑时不要只做 INSERT,应考虑 UPSERT;读取和写入都要落到明确分区,不要使用会随时间变化的“最新数据”。

dbt 对幂等的定义也类似:同一个模型跑一次或一百次,最终结果应一致。特别是增量模型,如果只追加、不去重,也没有可靠的 unique_key,重跑很容易把边界上的记录再次写入。

工具已经把道理说得很清楚。真正到了凌晨,难的是把它翻译成手上的四次确认。

条件一:重跑范围必须能被一句话说清

补数前,先别问“重跑哪个节点”,先问“这次要修复哪个数据范围”。

这两个问题看着很像,答案却常常不同。

比如一条日任务在 2026-08-24 分区失败。你点“重跑昨日任务”,系统实际读的可能是“当前时间往前 24 小时”;也可能读取源表里最新到达的数据;还可能在写入时覆盖当天分区,却在中间表继续追加。等到凌晨三点再执行,输入边界已经和第一次不同。

安全的范围至少要写清三件事:

  1. 业务时间范围:修复哪一天、哪一个小时,时区是什么。
  2. 输入快照范围:按哪个事件时间、分区或批次号取数。
  3. 输出替换范围:覆盖整个分区、替换某批记录,还是只追加缺失部分。

如果这三件事说不清,所谓重跑就不是“把同一件事再做一次”,而是“在另一个时间重新猜一次输入”。

这也是为什么 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 万条算了一次。调度看起来完全正常,数字却已经走远了。

如果四个条件都在,处理方式会不同:

  1. 范围固定为当天分区,不把第二天迟到数据混进来;
  2. 目标表按业务键合并,或原子替换当天分区;
  3. 根据批次提交状态判断第一次写入是否生效;
  4. 下游按同一修复批次重算,而不是把它当作新订单追加。

这四步没有一项特别神秘。它们只是要求人在凌晨点按钮之前,多花几分钟确认系统到底记住了什么。

紧急补数前,具体检查什么

如果时间很紧,可以先把检查压缩成一张四行记录。信息齐全、链路简单时,这一步可能在十几分钟内完成;复杂链路需要更久,不能把“15 分钟”当作时限或安全保证。记录不是为了填表,而是为了让值班人和第二天接手的人看到同一件事。

检查项必须回答的问题最低证据
数据范围读哪段、写哪段、按哪个时区分区值、时间窗、输入批次号
业务身份同一条记录靠什么识别唯一键测试、空值数、同键多版本规则
重试边界第一次执行到底提交到哪一步目标表快照、事务/批次状态、失败节点日志
下游消费哪些对象会再次计算或累计血缘清单、消费位点、刷新/补偿方式

确认后,再选择动作。动作最好只落在以下三类之一:

  • 重建:清除或原子替换明确范围,从固定输入重新计算;
  • 续跑:从可验证的检查点继续,不重复已提交部分;
  • 补偿:保留原结果,用一笔可追踪的反向或修正记录恢复状态。

最怕的是三种方式混在一起:先手工删几行,再重跑半个节点,发现不对又改一条汇总 SQL。最后数字也许对了,却没人能说清为什么对。下一次事故来时,团队只剩下一段传说。

补数完成后用行数、唯一键、核心金额和下游可见值验收

任务转绿以后,还要留下四个数

补数完成不能只截一张绿色节点图。绿色是过程证据,不是结果证据。

至少留下这四个数:

  1. 目标行数:补数前、补数后与正常基线分别是多少。
  2. 唯一键数:总行数和唯一业务键数是否符合预期。
  3. 核心金额或数量:与源系统、上游快照或独立汇总能否对上。
  4. 下游可见值:关键报表、汇总表或消费端的最终结果是什么。

如果有条件,再做一次“二次重跑验证”:在隔离环境或可回滚分区里,对同一输入再执行一次,确认四个数没有变化。幂等这件事,最好的证明不是设计文档,而是再跑一次,结果真的不动。

当然,不是所有链路都能立刻做到严格幂等。遗留系统可能没有唯一键,外部接口可能无法回放,消息下游也可能不支持事务。此时不要假装按钮安全,而应把风险写出来:哪些范围会重复、需要谁复核、出现偏差如何回退。

系统不完美并不可耻。真正危险的是,大家都知道它不完美,却还把“重跑成功”当成“数据正确”。

下一次告警响起时,先把手从重跑按钮上移开

凌晨值班最稀缺的不是技术,而是注意力。人困,群里催,会议时间又写在明早。重跑按钮放在那里,像一个很有礼貌的捷径。

可是数据任务失败以后,我们面对的不是一个需要再执行的命令,而是一份已经改变、又没有完全说明自己的数据状态。

先确认范围,再确认身份;先看提交边界,再看下游。检查耗时取决于证据是否齐全,不能保证消除事故;它的作用,是在执行前暴露重复写入和下游重放风险。

调度转绿,只说明机器不再喊疼。

数据能不能用,还得由证据说话。


我叫石头,在互联网数据领域工作 11 年,曾任阿里巴巴高级数据架构专家。这里把补数风险整理成一套可复核的方法,结论仍应以具体链路的日志、快照和对账结果为准。

来源:Apache Airflow Best Practices · dbt:Idempotence in dbt · dbt:Configure incremental models · Apache Kafka KIP-98:Exactly Once Delivery and Transactional Messaging

Elazer (石头)
Elazer (石头)

11 年数据老兵,从分析师到架构专家。用真实经历帮数据人少走弯路。

加入免费社群

和数据从业者一起交流成长

了解详情 →

成为会员

解锁全部内容 + 知识库

查看权益 →

1v1 咨询

有具体职业困惑?一小时说清楚

预约咨询 →
← 上一篇 业务一句“这个看板要实时”,数据负责人用 5 个问题拆成 3 档 SLA 下一篇 → 老板问数据团队下季度要不要加人:负责人用 5 类证据写容量计划