一、现象与背景:90s 的 SLA 写在文档里,却没法变成一条告警
先交代规模和参数,全部真实:核心链路是 Kafka(16 分区,kafka-clients 3.4)→ Flink 1.17 微批聚合(execution.batching,微批间隔 10s,watermark 容忍 30s 迟到)→ ClickHouse(2.4 LTS)→ 业务看板。业务方对新鲜度的承诺是「事件发生后 90s 内可查」,但这条 SLA 只存在于指标字典文档里,没有任何可执行的 SLI。
三个月里我们踩了三个坑,每个都留下了数字:
- 锚点选 event_time:同一条事件,event_time→Flink 写出是 3~15s,event_time→批次可查询是 15~120s。批窗口的结构性延迟(事件落进哪个微批、watermark 何时推过它)最大贡献 40s,占 90s 预算的 44%——这部分是架构决定的,不是故障;
- 锚点选 event_time 直接做 P99 告警:阈值被迫放到 150s,90s 承诺反而没落下来,且一次 Flink checkpoint 卡顿导致的真实劣化(P99 从 25s 涨到 80s)被结构性延迟完全掩盖,靠业务方报障才发现,耗时 1h50m;
- 锚点选 ingest_time 但用「最新事件延迟」这个点:凌晨 2 点某分区 10 分钟才来一条数据,「最新事件延迟」这个点一直顶在 600s,管道完全健康,告警却天天响。按周统计,夜间时段误报占全部新鲜度告警的 73%。
结论先行:新鲜度 SLI 的锚点必须选 ingest_time(事件到达 broker 的时刻),统计口径必须是「每个滚动窗口内全部事件的 P99」,低流量窗口用最小样本量 + 分位数降级处理,告警用双窗口 burn rate。下面展开。
二、原理与根因:为什么 event_time 锚点必然失守
从现象倒推,根因有三层:
1)event_time 把「上游不可控延迟」和「管道延迟」记在同一笔账上。 客户端时钟偏差、批量导出、网络重传,这些延迟发生在数据离开上游之前,管道团队既管不了也改不了。用 event_time 做锚点,等于让管道为别人的故障背锅——SLO 被合法迟到事件吃光,error budget 失去意义。
2)批窗口的结构性延迟是「离散台阶」,不是连续变量。 微批间隔 10s 意味着延迟在 0~10s 之间近似均匀分布,再叠加 watermark 30s 容忍,结构性延迟的分布是 0~40s 的台阶状。对这种分布做端到端 P99,阈值必须放到 150s 才能不天天误报——代价是真实劣化被结构性延迟淹没。结构性延迟该被显式建模,而不是被容忍。
3)「最新事件延迟」是单点,不是分布。 单点对稀疏流量极其脆弱:一个低流量窗口的 P99 被一条迟到事件打到 300s+,而 99% 的事件其实 20s 内就查到了。正确的统计对象应该是「窗口内所有事件的延迟分布」,而不是「最慢的那一条」。
三、具体做法:锚点 + 口径 + 阈值 + 告警,四件套
1. 锚点:每条事件双时间戳,SLI 用 ingest_time
上游 SDK 和网关各打一个时间戳:event_time(业务发生时刻,保留,用于口径)和 ingest_time(到达 broker 的时刻,用于新鲜度)。新鲜度定义为:
freshness(event) = 批次可查询时刻 − ingest_time(event)
结构性延迟(微批 10s + watermark 30s)从 SLI 里显式扣除,单独作为「管道内部延迟预算」监控。业务 SLA 90s = 管道延迟 SLO 60s + 结构性延迟预算 30s,两段分别定阈值、分别告警。
2. 口径:滚动 5 分钟窗口,窗口内 P99,带最小样本量
不用「最新事件延迟」,改用滚动 5 分钟窗口:窗口内所有事件的 freshness 取 P99。低流量处理是关键——最小样本量 200 条,不够则降级到 P95 且样本量降到 50 条,再不够则跳过该窗口并单独监控「到达量」:
-- ClickHouse:按 5 分钟窗口算新鲜度 P99/P95(freshness 单位:秒)
SELECT
toStartOfFiveMinute(ingest_time) AS window,
count() AS n,
quantilesIf(0.99, 0.95)(freshness, n >= 200)(freshness, n >= 50 AND n < 200) AS p99_or_p95,
max(freshness) AS p_max
FROM dwd_freshness_sample
WHERE ingest_time > now() - INTERVAL 1 DAY
GROUP BY window
ORDER BY window;
注意 quantilesIf(0.99, 0.95) 这段写法:n≥200 用 P99,50≤n<200 用 P95,n<50 返回 0(跳过)。跳过不等于忽略——对 n<50 的窗口单独挂「到达量断流」规则(1 小时窗口内 n=0 直接 P1),把「没数据」和「数据慢」分成两类故障,各自有各自的告警。
3. SLO 与错误预算:30 天窗口,SLO = 99.5% 的窗口达标
单窗口达标定义:p99_or_p95 ≤ 60s(管道延迟 SLO)。30 天共 30×24×12 = 8640 个 5 分钟窗口,错误预算 = 8640 × 0.005 = 43.2 个窗口。这个数字的价值:它让「90s 承诺」从一句口号变成一笔可以花、可以冻结的预算——预算耗尽触发变更冻结(上游发版、Flink 作业调整都要停下来),这个机制比任何单点阈值都更能约束团队行为。
4. 告警:双窗口 burn rate,不是单阈值
沿用 SRE 社区的多窗口 burn rate 思路(参考 postId 993 的完整演算),按 30 天窗口、SLO 99.5% 反推:
| 窗口 | burn rate | 触发条件(窗口内不达标比例) | 动作 | |---|---|---|---| | 30 分钟(6 个窗口) | 14.4× | 不达标比例 > 1 − 0.005/14.4 ≈ 96.5%,即 6 个窗口里 ≥6 个不达标 | P1 电话 | | 3 小时(36 个窗口) | 6× | 不达标比例 > 95.1%,即 36 个窗口里 ≥34 个不达标 | P2 群告警 |
为什么不用「P99 > 阈值」单条件:单条件对瞬时抖动过敏(一个窗口异常就响),对持续劣化又可能因阈值过高而漏报。burn rate 把「多快在烧预算」变成判据,瞬时抖动(1/6 窗口不达标)不响,持续劣化(连续 34/36 窗口不达标)必响——这正是「该响的响」的分层思路(与 postId 707 的 DQC 分层治理同源)。
5. checkpoint 卡顿:不靠新鲜度 P99,靠 Flink 自身指标
checkpoint duration P95 > 30s 且 busy > 0.8 持续 2 分钟 → P1。这条规则的价值:新鲜度 P99 从 25s 漂到 80s 需要几分钟才能被 burn rate 捕捉,而 checkpoint duration 在卡顿发生 1 分钟内就能报警。新鲜度 SLI 是「果」,Flink 自身指标是「因」,告警必须锚在因上。
四、踩过的坑与反直觉之处
- P99 在低流量窗口不是「保守」,是「脆弱」。 直觉上 P99 比 P95 更严格更靠谱,但在 n=50 的窗口里,P99 就是第 2 慢的那条——一个客户端时钟偏差就能打爆它。最小样本量 + 分位数降级比「直接剔除迟到事件」更稳,因为「界定什么是合法迟到」本身就是一个无解问题。
- 结构性延迟不该被「容忍」,该被「显式建模」。 我们把 30s 结构性延迟从 90s SLA 里扣掉,单独作为管道内部预算监控。业务方最初反对:「你们不是承诺 90s 吗?」我们的回应:「我们承诺 60s 管道延迟 + 30s 架构固有延迟,两段分开,哪段超标查哪段」——反而更容易被接受,因为责任边界清晰了。
- error budget 不是给业务方看的,是给团队自己用的。 预算耗尽 → 变更冻结,这条规则上线后,Flink 作业发版的灰度窗口从「凭感觉」变成「预算还剩多少」,发布节奏反而更稳了。
- 双时间戳的成本比想象低,收益比想象高。 上游 SDK 加一个
ingest_time字段、网关透传、Flink 作业多带一个列——总共约 1 人日的改动,换来的是新鲜度 SLI 从「没法定义」到「可执行、可告警、可预算」的质变。
五、结论与可复用清单
核心结论:微批管道的新鲜度 SLI,锚点选 ingest_time(不选 event_time),口径选「滚动窗口内全事件 P99」(不选「最新事件延迟」),低流量窗口用最小样本量 + 分位数降级(不选「剔除迟到事件」),告警用双窗口 burn rate(不选单阈值),真实劣化的发现锚在 Flink 自身指标(不锚在新鲜度 P99)。
可复用清单:
- [ ] 每条事件双时间戳:
event_time(口径用)+ingest_time(新鲜度用) - [ ] 结构性延迟(微批间隔 + watermark 容忍)从端到端延迟里显式扣除,单独定预算
- [ ] 滚动 5 分钟窗口,窗口内 P99;最小样本量 200,50~200 降 P95,<50 跳过 + 单独监控到达量
- [ ] SLO = 99.5% 窗口达标,30 天预算 43.2 个窗口,耗尽触发变更冻结
- [ ] 双窗口 burn rate:30 分钟 14.4×(P1)/ 3 小时 6×(P2)
- [ ] Flink checkpoint duration P95 > 30s + busy > 0.8 持续 2 分钟 → P1(锚在「因」上)
- [ ] 把「没数据」(到达量断流)和「数据慢」(新鲜度劣化)分成两类故障,各自独立告警
环境:Flink 1.17、Kafka 3.4(16 分区)、ClickHouse 2.4 LTS、微批间隔 10s、watermark 容忍 30s。上线两周后:夜间误报从 73% 降到 4%,一次 checkpoint 卡顿从「业务方报障 1h50m」变为「checkpoint duration 告警 47s 内发现」。参数卡住了欢迎评论区追问。