11 minute read

“只有一只表的人知道现在几点;有两只表的人永远拿不准。”——Segal 定律

为什么你的增量同步在悄悄丢数据

从「任务成功」到「数据丢失」——区分资深与初级的,是你怕不怕那条绿色的日志

想象你要预测一个球沿着过山车轨道滚下来的轨迹。

牛顿的解法:苦逼的“微观包工头” 你必须盯着这个球的每一个瞬间,画出它受到的重力、轨道的支持力、摩擦力,并把这些力全部拆解到 X 轴和 Y 轴上。最让人抓狂的是,因为过山车轨道是弯曲的,那个“支持力”的方向每一毫米都在变。你就像一个微观包工头,必须死死盯住每一个细节,算错一个角度,整道题就全错了。

拉格朗日的解法:聪明的“大局观老板” 拉格朗日根本不关心球每一秒到底受了什么方向的力,他甚至不画受力分析图。他只看两个宏观指标:动能(跑得多快)和势能(在什么高度)。他把这两个能量相减,创造了一个终极指标——拉格朗日量。接着,他利用了宇宙里最酷的一条潜规则:大自然极其“懒惰”(最小作用量原理)。球从起点到终点有无数种可能的跑法,但大自然一定会自动规划出一条让“总折腾程度”最小的路线。你只要把能量公式套进一个固定的数学方程里,它就会自动吐出球的运动轨迹。

为什么这是降维打击? 因为它消灭了“方向”的纠缠,无视了复杂的“支持力”。它从宏观的规则出发,而不是在微观的细节里挣扎。

在分布式系统的世界里,我们经常陷入“牛顿式”的陷阱。当一个任务失败时,我们习惯于做“微观包工头”:盯着日志看连接断没断、SQL 写对没对、耗时是不是超了。这些都是我们看得见的“力”。

但是,当你的增量同步在“任务成功”的情况下悄悄丢数据时,牛顿式的思维就失效了。因为在表面上,所有的“力”都是正确的。

这时候,你需要切换到“拉格朗日”的视角。你要停止盯着每一条绿色的日志,转而去关注系统的“能量”——也就是状态的守恒。在增量同步中,如果你只关注任务是否“跑通了”,你只是在观察轮子的摩擦力;你必须去关注数据从源到目的地的宏观流转规则。只有理解了状态转换的本质,你才能在“任务成功”的假象下,揪出那些消失的数据。

一条凌晨的定时任务,状态是 success,耗时 3 分钟,抽取行数 0。监控面板一片安静,邮件没响,值班的人翻个身继续睡。

这就是我这个月排查过的最贵的一次故障:它从来没有报错。

在数据工程的实战中,我们习惯了“红灯报警”逻辑——只要系统没崩溃,只要日志里没有 Error 或 Exception 抛出,我们就默认逻辑是通畅的。然而,这种对“成功”的盲目信任,正是构建在沙滩上的城堡。最危险的故障往往不是那些炸裂式的崩溃,而是那些在逻辑正确的前提下,由于“时钟”错位而导致的静默数据丢失。

这就是我这个月排查过的最贵的一次故障:它从来没有报错。

背景是一条很常见的管道——把一套 LMS(Moodle 架构)里的学业行为信号,经 Snowflake ODS 清洗后,增量同步到 CRM(某主流 CRM 平台的教育云模块),给辅导员的学业预警看板供数。某天业务反馈:有一批成绩明明改过,CRM 里却查不到。不是晚了,是压根没有。

我先去翻死信队列(DLQ)——空的。再翻控制表——对应那个时间窗的记录,清一色 SUCCESS,records_extracted = 0。任务没失败,数据没进 DLQ,就这么人间蒸发了。

如果你也维护过任何“每天批量拉一次上游、再往下游增量推”的管道,下面这一节就是为你准备的。读完你会带走三个直觉:

  • 批处理管道里最危险的日志不是红色的报错,而是一条绿色的 success, rows=0;
  • “现在几点”这个问题,在一条跨系统管道里有两个互相矛盾的答案,而大多数 bug 就住在这条缝里;
  • 幂等的 upsert 不只是“防重复”,它是你敢回头重读的底气。

讲解顺序和我 debug 的顺序是反的——我把最有用的结论(两只表)放最前面,而不是按“先翻 DLQ、再翻控制表”的侦查流水账来讲。

1. 现场:一条不报错的管道

先看抽取 SQL 的骨架,这是所有增量管道长得都差不多的那段:

SELECT ...
FROM   ods.lms.grade_grades gg
WHERE  gg.timemodified >  :watermark_from
  AND  gg.timemodified <= :watermark_to

watermark_from 是上一次的 watermark_to,窗口首尾相接、绝不重叠。问题出在 watermark_to 怎么取:

# runtime 里决定窗口右边界的那一行
watermark_to = conf.get("watermark_to") or now_epoch

now_epoch —— 墙上时钟,任务跑起来那一刻的 UTC 秒。看起来天经地义:我这次拉“从上次到现在”的增量,有什么错?

错就错在:这条 SQL 查的是 ODS,而 ODS 不是实时的。

2. 机制:一条管道里住着两只表

ODS 里的 lms schema 不是实时镜像,而是上游每天夜里跑一次的快照。拉数的 Airflow DAG,它的 cron 是 52 22 * * *——每晚 22:52 才从源库搬一次,当天实测数据落到 ODS 里的时间戳停在凌晨 01:40 左右。

于是一条管道里出现了两只走得不一样的表:

  它代表什么 怎么走
源时钟 grade_grades.timemodified——学生/老师真正改动成绩的那一刻 实时,一直在走
ODS 快照时钟 ODS 里能看到的最大 timemodified 每天只跳一次(夜里那次加载)

同步任务用的 watermark_to = now,是墙上时钟;可它查的数据来自落后的快照时钟。两只表,你信了错的那只。

这里就是对称性破缺(symmetry breaking)。 「watermark 用 now」这个抽象之所以优雅,隐含了一个假设:此刻 ODS 里就有截止此刻的数据。一旦上游变成每日快照,这个对称性就破了——墙上时钟一路向前,数据时钟每天才挪一格,窗口和数据从此错位。

走一遍时间线就全明白了。假设某条成绩在周一 10:00 被老师改了(timemodified = 周一 10:00):

时刻 发生了什么 这条成绩在 ODS 里吗?
周一 10:00 成绩在源库被改 ❌ ODS 还没拉
周一 12:00 同步跑,窗口 (周一08:00, 周一12:00] ❌ 查 ODS,查不到 → 窗口白过
周一 15:00 同步跑,窗口 (周一12:00, 周一15:00] ❌ 还是查不到
周一 22:52 ODS 加载,这条成绩进了 ODS ✅ 现在有了
周二 01:00 同步跑,窗口 (周一15:00, 周二01:00] ✅ 在 ODS 里,但……

关键在最后一行:周二凌晨这次,ODS 里确实有这条成绩了,可它的 timemodified 是周一 10:00。而这次窗口的左边界是周一 15:00——10:00 < 15:00,被 WHERE 过滤掉了。

而它本该归属的那个窗口 (周一08:00, 周一12:00],周一中午就已经 SUCCESS 并把 watermark 推过去了,再也不回头。

这条成绩不是同步失败——它在每一个本该抓到它的窗口里,要么还没进 ODS,要么已经被 watermark 甩在身后。它掉进了两只表之间的缝里。

3. 为什么它比报错还难抓

初级工程师的排查直觉:出问题了,先看报错、翻 DLQ、查重试。这套直觉在这里全部失灵,因为根本没有任何异常信号:

普通人的看法 资深工程师的洞察
没报错 = 没问题 没报错恰恰是最坏的一类 bug:它骗过了你所有的告警
rows=0 说明那个窗口本来就没数据 rows=0 也可能是“数据还没到 / 已经被甩开”,两者在日志里长得一模一样
任务 SUCCESS 就代表数据正确 SUCCESS 只代表这段代码没抛异常,和数据对不对是两件事

我手里的真实证据(已脱敏):某个白天时段(源库里)确有 47 条符合口径的成绩被改过;去 CRM 里查,0 条;去 DLQ 里查,0 条;对应的每一个控制表窗口,状态都是 SUCCESS / extracted=0。再往前看一周,成绩类任务 26 次定时运行里,只有 1~2 次真的抽到了数据——因为白天所有窗口查的都是“昨晚那份快照”,而那份快照里压根不含当天的改动;等夜里快照更新了,对应窗口早过去了。

面试拿分点: 当有人问你“你怎么监控一条 ETL 管道是否健康”,如果你的答案只有“任务成功率 + 报错告警”,那你会漏掉这一整类故障。真正该加的监控是:一个本应持续有数据的任务,连续 N 个窗口 extracted=0,就该告警——沉默本身就是一种症状。

一句话哲学:批处理里最吓人的不是红色的 Exception,是一条没人看的绿色 success, rows=0。

4. 第一性原理:窗口到底该对齐谁

退回到第一性原理问一句:增量窗口的右边界,本质上应该是什么?

它应该是“我这次能可靠看到的数据,截止到哪”——也就是数据时钟,而不是“我这次是几点跑的”——墙上时钟。两者在实时源里恰好重合,所以大多数人从没意识到自己依赖的是哪一只;一旦源变成快照,分歧就暴露了。

知道了这个,修法其实有两条干净的路,外加一条代价最低的权宜之计:

方案 做法 代价
对齐数据时钟 watermark_to = MIN(now, 源表 MAX(timemodified)) 每次抽取前多查一次各源表的 MAX,改动碰核心逻辑
回看重叠 watermark_from = max(0, 上次 watermark_to − overlap),overlap 取 48h 每次多读一段已同步过的“重叠区”数据,靠幂等 upsert 去重
拉长调度间隔 干脆只在快照加载完之后跑一次 时效性变差,且仍然脆——换个加载时间就又错位

我这次选了回看重叠,原因下一篇细讲;这里只先给结论和那一行改动:

# 后续窗口从「上次 watermark_to 往前 overlap 秒」开始;
# ODS 是每日快照,改动会晚一夜才到,重叠能把它们重新捞回来,
# upsert 让这次重复无害。
watermark_from = max(0, int(last_watermark_to) - watermark_overlap_seconds)

48 小时这个值不是拍脑袋出来的:ODS 最坏延迟约一天,再留一天冗余,覆盖掉“改动当天”加“进 ODS 当天”这两天。而且这一改还有个附带好处——它把修复上线之前已经漏掉的历史数据,在下一次运行时自动补回来,因为那些被甩掉的旧 timemodified 现在重新落回了窗口里。

直觉口诀:窗口对齐数据的时钟,不对齐你的手表。

立刻可以做的事

  1. 翻出你每一条“增量”管道,问一个问题:它的 watermark_to 取的是 now,还是源数据的实际最大时间戳? 如果是 now,再问:这个源是实时的,还是定时加载的?只要是后者,你大概率正在丢数据。
  2. 给“本应持续有数据”的任务加一条告警:连续 N 个窗口 extracted=0 就报警。把沉默变成信号。
  3. 确认你的下游写入是幂等的(基于外部唯一键 upsert)。只有幂等,你才敢用回看重叠这种“重读换安全”的修法。
  4. 如果确实在丢数据,用一次宽窗口手动回灌(watermark_from 设到足够早),把缝里的数据捞回来——前提是第 3 条已满足。

预告

这篇讲的是「病」:窗口对错了时钟。下一篇讲「药」——增量抽取窗口的几种设计范式:回看重叠 vs 对齐高水位、幂等 upsert 为什么是一切的前提、以及当某个窗口变成“毒窗口”时,熔断器怎么保证管道不被一条脏数据永久卡死。如果你想看这条管道完整的架构全貌(成绩树权重、时区、状态机),可以先读这篇企业级 LMS→CRM 集成架构。

报错会叫,丢数据不会。工程的成熟度,一半写在你怎么处理异常里,另一半藏在你肯不肯怀疑那条绿色的日志里。

Updated: