结算系统 52 小时断流没人报警:低量时段的 DQC 盲区与四层对账方案
结论先行:DQC 规则「全部通过」≠ 数据没坏。我复盘过一次真实的结算断流事故:上游 52 小时没出数,21 条 DQC 规则一条都没响,最终靠商家投诉才被发现,少结算 12.7 万。根因不是「规则写错」,而是规则被设计成只对「有量的日子」有效。本文给一套「源端到达 + 周级基线 + 业务闭环 + 覆盖度」的四层对账方案,专门补低量时段的盲区。
一、现象与背景:52 小时断流,DQC 一条都没响
先摆数字,方便对号入座。
- 一个 B 端 SaaS 商家结算系统:850 家商家、日均约 2800 笔结算单、日均结算金额约 41.8 万;
- 链路:上游业务库(MySQL)→ CDC → Kafka → Flink → ClickHouse
dwd_settle_order,调度用 DolphinScheduler 3.2,每天凌晨 03:00 跑批聚合到ads_settle_day; - DQC 规则:挂 21 条,覆盖「非空、唯一、行数波动 ±30%、金额 > 0、枚举合法」等常规项,走同一个告警群。
9 月 28 日上午 10:00,上游做了一次 schema 迁移,把 ods_settle_order 改名成 ods_settle_order_v2(老表保留 7 天,没删)。跑批任务里 INSERT INTO ... SELECT FROM ods_settle_order 开始报「Table not found」,但调度框架配置了 retry=3、continue-on-error=true,异常被吞掉,任务最终 exit 0、状态标为「成功」。
10 月 1 日早上 09:00 才有人手工把 SQL 改回新表名。也就是说,从 9/28 10:00 到 10/1 09:00,整整 52 小时 dwd_settle_order 一条新数据都没进。
10 月 8 日,一家月结算 18 万的商家来投诉「这个月少了 12.7 万」,我们才把 9/28–10/1 这 3 个分区拉出来对账——3 个分区全部为 0 行。回头看这 3 天的 DQC 执行日志:21 条规则全部 PASS。
为什么 0 行也能全 PASS?三条规则的判定逻辑:
- 「行数波动 ±30%」:实现是
abs(today - yesterday) / yesterday <= 0.30,昨天 2800 行、今天 0 行 → 波动 100%,但当时这条规则被业务方标记为「节假日可忽略」,配了skip_on_weekend = true+skip_on_holiday = true,10/1 是假期直接跳过; - 「金额 > 0」:SQL 是
SELECT SUM(amount) FROM dwd_settle_order WHERE ds = '2024-09-29',空集 SUM 返回 NULL,NULL > 0 是 UNKNOWN,被 WHERE 过滤后 0 行命中,规则判定「无违规」; - 「非空、唯一、枚举合法」:空表上跑,一行都不扫,全部 PASS。
一句话:所有规则都默认「表里应该有数据」,但没有一条规则负责检查「数据到底来没来」。
二、原理与根因:DQC 的三个断点
站在 10 年做指标体系的角度看,这次事故不是「阈值没调对」,而是三个机制性的断点:
- 「有量」被假设成「常态」:±30% 波动、金额 > 0、非空率 > 99% 这类规则,全部隐含一个前提——今天有数据可查。低量时段(周末、节假日、淡季)这个前提不成立,规则就退化成恒真。
- 「调度成功」被当成「业务成功」:DolphinScheduler 的「成功」定义是 exit code 0,但业务语义是「今天的分区有数、且和上游对得上」。这两个概念之间没有任何桥梁。
- 规则之间没有互补:21 条规则各自独立判定,没有一条是「源端到达」级别的硬断言。当所有规则都在「数据存在」的前提下判定时,数据不存在这个事实本身永远不会被任何一条规则触发。
引用一下同社区 s292 在《DQC 规则怎么分层落地》里的核心观点——「DQC 的本质不是检查,而是把坏数据翻译成谁、在什么时间、该被叫醒」。这次事故里,翻译器根本没启动:因为「坏数据」的形态是「没有数据」,而所有规则都在翻译「数据长什么样」,没人翻译「数据到底来没来」。
三、具体做法:四层对账,专治低量盲区
第 0 层:监控表(先有一张事实表)
CREATE TABLE dqc_arrival (
ds DATE,
src_table VARCHAR(64), -- ods_settle_order_v2
src_row_count BIGINT, -- 源端当天行数
dwd_row_count BIGINT, -- 目标表当天行数
dwd_amount DECIMAL(18,2),
arrived_at DATETIME, -- 数据首次出现的时间
PRIMARY KEY (ds, src_table)
) ENGINE = MergeTree
ORDER BY (ds, src_table);
这张表是所有 DQC 判断的事实底:它记录的是「数据有没有到、什么时候到、量是多少」,而不是「数据长什么样」。
第 1 层:源端到达检测(最硬的断言)
不依赖任何阈值,只看「源端 offset 是否在动 + 目标表是否跟上」:
-- 每 15 分钟跑一次
WITH src AS (
-- 从 CDC 元数据里取源端当天 offset
SELECT max_offset FROM cdc_offset
WHERE table_name = 'ods_settle_order_v2'
AND offset_time >= today()
),
tgt AS (
SELECT max(event_offset) AS max_offset
FROM dwd_settle_order
WHERE ds = today()
)
SELECT
CASE
WHEN src.max_offset IS NULL THEN 'P1: 源端无 offset(CDC 断流)'
WHEN tgt.max_offset IS NULL THEN 'P1: 目标表 0 行且源端有数据(跑批失败)'
WHEN src.max_offset - tgt.max_offset > 1000
AND src.max_offset = previous_check.max_offset
THEN 'P1: offset 15 分钟无增长(Flink 卡住)'
ELSE 'OK'
END AS status;
关键点:这层不看行数、不看金额、不看波动率,只看「上游有没有动、下游有没有跟」。低量时段 100 笔和 2800 笔都触发同样的判定逻辑,天然抗低量。
第 2 层:周级基线(替代固定 ±30%)
固定阈值(±30%)的问题是「用静态线套动态量」。改成同周几的 28 天滚动分位数:
-- 基线表,每天凌晨 01:00 预计算
CREATE TABLE dqc_baseline AS
SELECT
day_of_week,
quantile(0.50)(row_count) AS p50,
quantile(0.10)(row_count) AS p10,
min(row_count) AS floor_val
FROM dqc_arrival
WHERE ds BETWEEN today() - INTERVAL 28 DAY AND today() - INTERVAL 7 DAY
GROUP BY day_of_week;
-- 判定:今天行数 < p10 且 < 前 7 天同周几 p50 的 50%
SELECT
CASE
WHEN today.row_count < baseline.p10
AND today.row_count < 0.5 * baseline.p50
THEN 'P2: 行数跌破周级基线'
ELSE 'OK'
END
FROM dqc_arrival today
JOIN dqc_baseline baseline ON baseline.day_of_week = dayOfWeek(today.ds);
为什么用 p10 而不是 min:min 会被节假日拉到 0,基线就废了。p10 在 28 天窗口里至少覆盖 2~3 个「正常低量日」,比 min 稳、比 p50 灵敏。
第 3 层:业务闭环对账(金额守恒)
不依赖行数,直接对金额:
-- 上游订单表 vs 下游客户余额变更表
SELECT
CASE
WHEN abs(a.total - b.total) / greatest(a.total, 1) > 0.01
THEN 'P1: 金额对不上(差 > 1%)'
ELSE 'OK'
END AS status
FROM (
SELECT SUM(amount) AS total FROM ods_settle_order_v2 WHERE biz_date = today()
) a
CROSS JOIN (
SELECT SUM(delta) AS total FROM ods_balance_change WHERE biz_date = today()
) b;
这层的价值:即使行数对了、金额错了(比如 Flink 里 amount 字段被截断成 0),也能被抓住。反过来,行数为 0 但金额也对(都是 0),说明源端真的没出单,走「低量豁免」逻辑而不是告警。
第 4 层:覆盖度检查(不只看总量)
-- 活跃商家覆盖度
SELECT
CASE
WHEN (SELECT COUNT(DISTINCT merchant_id) FROM dwd_settle_order WHERE ds = today())
< 0.8 * (SELECT COUNT(DISTINCT merchant_id) FROM dwd_settle_order
WHERE ds BETWEEN today() - INTERVAL 7 DAY AND today() - INTERVAL 1 DAY)
THEN 'P2: 商家覆盖度跌破 80%'
ELSE 'OK'
END AS status;
为什么需要这层:总量对但 850 家里只有 200 家有数据,「金额守恒」可能因为低量商家金额本来就小而对不上也感知不到。覆盖度是独立的一票。
告警路由(对齐 s292 的分层思路)
| 级别 | 触发条件 | 响应 | |---|---|---| | P1 | 源端 offset 无增长 / 金额对不上 | 立即电话 + IM,15 分钟未响应升级 | | P2 | 行数跌破周级基线 / 覆盖度 < 80% | IM 通知,当天处理 | | P3 | 单条规则告警(非空、枚举) | 汇总到周报,不单独响 |
四、踩过的坑与反直觉之处
- 空集 SUM 返回 NULL 不是 0:
SUM(amount)在 0 行时返回 NULL,NULL > 0是 UNKNOWN 不是 FALSE,所以「金额 > 0」规则在空表上不报错而是静默 PASS。改成COALESCE(SUM(amount), 0) > 0才能抓住。 skip_on_holiday = true是把双刃剑:它解决了节假日误报,但也把真正的断流藏起来了。正确做法不是「跳过」,而是切换判定逻辑——节假日用「源端到达」层代替「行数波动」层,而不是直接关掉。- 调度框架的「成功」≠ 业务成功:DolphinScheduler 的
continue-on-error + retry组合,在「上游表改名」这种场景下会把真实故障吞成 exit 0。我在跑批任务里加了一个 post-hook:SELECT COUNT(*) FROM dwd_settle_order WHERE ds = today()为 0 时直接exit 1,让调度系统把「空分区」当失败处理。 - p10 比 min 稳,但 p10 也会骗你:如果 28 天窗口里有 3 天是 0 行(断流期),p10 会被拉到极低值,基线形同虚设。对策:基线计算时先过滤掉「源端 offset 也没增长」的天(真正的业务低量),只保留「源端有数据但目标表行数低」的天(跑批问题)来算分位数。
- 不要一上来就 23 张表全挂:先挑结算核心表 + 1 张高频指标表跑 2 周,校准 p50/p10 参数,再扩。我们第一版全量铺开,结果 23 张表里有 4 张的 p10 被历史断流期拉到 0,基线全废,白跑两周。
五、结论与可复用清单
核心观点:DQC 的盲区不在「规则写错」,而在规则默认了「数据存在」这个前提。低量时段、节假日、断流期,这个前提不成立,所有规则集体失声。解法不是加更多规则,而是加一层「源端到达」的硬断言——它不依赖任何统计量,只看「上游有没有动、下游有没有跟」,天然抗低量。
可复用清单(按优先级):
- [ ] 建一张
dqc_arrival监控表,记录源端行数、目标行数、金额、到达时间; - [ ] 第 1 层「源端到达」检测:offset 15 分钟无增长 → P1;
- [ ] 第 2 层「周级基线」:同周几 28 天 p10,替代固定 ±30%;
- [ ] 第 3 层「金额守恒」:上游 SUM = 下游 SUM,差 > 1% → P1;
- [ ] 第 4 层「覆盖度」:活跃商家数 < 80% 近 7 天 → P2;
- [ ] 调度 post-hook:空分区 → exit 1,让调度框架把「没数据」当失败;
- [ ] 节假日策略:切换判定层(源端到达替代行数波动),不是直接关掉;
- [ ] 基线计算过滤「源端也没动」的天,防止 p10 被断流期拉到 0。
一句话总结:DQC 的第一条规则不应该是「数据长什么样」,而应该是「数据到底来没来」。