Day 20 Hard Data Processing Batch/Stream Lambda/Kappa Exactly-once

计算作业系统 — 批处理与流处理的统一战争Data Processing: Batch vs Stream, Lambda/Kappa, Watermark, Exactly-once

问题场景 + 需求约束

为一个流媒体平台设计事件处理平台:客户端每次播放、暂停、卡顿都打一条事件,2 万亿 events/天(均值 ~2300 万 eps,晚高峰冲到 5000 万)。两类截然不同的消费方:

核心矛盾:同一份数据,一边要低延迟近似、一边要高吞吐精确。这正是「批 vs 流」「Lambda vs Kappa」之争的源头。关键约束:事件乱序到达(移动端弱网,离线缓存几小时后才上报)、平台失败必须不丢不重(计费场景)。

高层架构(以可回放日志为中心)

graph LR
    SRC["客户端事件
5000万 eps"] LOG["Kafka
可回放日志 · 保留7天"] subgraph 流处理层 SP["Flink 作业
event-time 窗口"] end subgraph 回放/批层 BP["回放作业
同一份代码"] end RT[("实时存储
Druid/Redis")] LAKE[("数据湖
S3 + Iceberg")] SERVE["服务层
推荐/告警/BI"] SRC --> LOG LOG -->|实时消费| SP --> RT --> SERVE LOG -.从头replay.-> BP --> LAKE --> SERVE classDef src fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef log fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef proc fill:#0e2030,stroke:#5eead4,color:#e8eef5 classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class SRC src class LOG log class SP,BP proc class RT,LAKE,SERVE store

Kafka 是真相之源:实时链路在线消费,纠错时从 offset 0 回放同一份数据,无需另建批管道

三段职责:可回放日志(Kafka/Pulsar)做缓冲与解耦,让生产消费解绑、支持 replay;流处理层(Flink)做有状态的窗口聚合,输出实时指标;存储层分两路——实时指标进 Druid/Redis 供低延迟查询,明细落数据湖(S3+Iceberg)供离线精算与回放。

关键技术点

1. 批 vs 流:有界数据是无界数据的特例

原理:传统认知是「批 = 攒一批跑一次,流 = 来一条算一条」,但更深的洞见是——批处理只是流处理的一个特例:有界数据(bounded)= 知道结尾的无界数据(unbounded)。Spark 的 micro-batch 把流切成小批(200ms 一个),Flink 则是真·逐条流式、把批当成「有头有尾的流」来跑。延迟下限因此天差地别。

Spark (micro-batch)Flink (true streaming)纯批 (MapReduce/Spark batch)
延迟百毫秒~秒毫秒级分钟~小时
吞吐极高极高
状态/窗口支持但偏弱一等公民无(每次全量)
适合ETL+近实时混合低延迟有状态大规模回填/训练集
Trade-off:
现实案例:

2. Lambda vs Kappa:要不要维护两套代码

原理:Lambda 架构(Nathan Marz)跑两条链路——batch layer 算精确结果(高延迟)、speed layer 算实时近似(低延迟),查询时把两者合并。问题是同一套业务逻辑要写两遍(一份 Spark、一份 Flink),永远对不齐、维护翻倍。Kappa 架构(Jay Kreps)的洞见:既然流引擎已经成熟,砍掉批层,只留一条流链路,要纠错就把日志从头 replay 一遍。

Trade-off:
# Kappa 的「纠错」不是补丁,而是重放
# 1. 发现聚合逻辑有 bug,修正 Flink 作业代码
# 2. 起一个新作业实例,从 offset 0(或某 checkpoint)重新消费
# 3. 写入一张新结果表 metrics_v2
# 4. 校验无误后,下游查询原子切到 v2,删除 v1
# 关键前提:Kafka retention 足够长 + 结果表可双写并存
现实案例:

3. Event-time 与 Watermark:和乱序、迟到数据讲和

原理:分清两个时间——event-time(事件真实发生的时刻,写在数据里)与 processing-time(系统处理它的时刻)。网络抖动、移动端离线缓存让两者出现偏移(skew),事件乱序到达。按 event-time 开窗才能算对「8:00–8:05 这个视频的播放量」,但系统永远无法确定「8:05 的数据是不是全到齐了」。Watermark 是引擎对「event-time 已推进到 T,比 T 早的数据基本到齐了」的启发式断言——它一推过窗口右界,就触发计算并关窗。

graph LR
    subgraph 乱序到达["事件按 processing-time 到达(乱序)"]
      direction LR
      A["e@8:01"] --> B["e@8:04"] --> C["e@8:02"] --> WM["💧Watermark=8:05"] --> D["e@8:03
迟到!"] end WM -->|触发| W["关闭 8:00-8:05 窗口
输出聚合结果"] D -.allowed lateness.-> W2["更新已发结果
或进 side output"] classDef ev fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef wm fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef win fill:#0e2030,stroke:#5eead4,color:#e8eef5 class A,B,C,D ev class WM wm class W,W2 win
Trade-off(completeness vs latency):
# Flink event-time 滚动窗口 + 迟到处理
stream
  .assignTimestampsAndWatermarks(           // 声明 event-time 与 watermark 策略
     WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(10)))
  .keyBy(e -> e.videoId)
  .window(TumblingEventTimeWindows.of(Time.minutes(5)))
  .allowedLateness(Time.minutes(1))          // 关窗后再容忍 1 分钟迟到
  .sideOutputLateData(lateTag)               // 更晚的旁路输出,离线补
  .aggregate(new PlayCountAgg());
现实案例:

4. Exactly-once:到底是处理一次,还是「效果」一次

原理:分布式下消息会重发、节点会崩。at-most-once(可能丢)、at-least-once(可能重)易实现;exactly-once 最难。关键澄清:网络层不可能真正「只传一次」,工程上追求的是 effectively-once——状态与输出等价于只处理一次。Flink 的做法是 checkpoint:用 Chandy-Lamport 的变体「异步屏障快照(ABS)」,往数据流里插入屏障(barrier),算子收到屏障就快照自己的状态;崩溃后从上个一致快照恢复、并把 source offset 一起回滚。

Trade-off:
# 端到端 exactly-once = 状态快照 + 事务性输出(2PC)
# checkpoint 完成时:
#   pre-commit:  把这一批输出预写到事务(Kafka txn / DB 临时区)
#   notify:      所有算子快照成功 → coordinator 发 commit
#   commit:      事务提交,下游可见;崩溃则 abort,回滚到上个 checkpoint
# 失败恢复:source offset 与算子状态一起回到上个一致点,重放但不重复对外
现实案例:

扩展与优化(增长后怎么办)

常见陷阱 + 面试问题

1. 用 processing-time 开窗算「每分钟指标」。 一旦数据乱序或回放,结果全错——回放时所有历史数据都在「现在」涌入,processing-time 窗口会把它们塞进同一分钟。计窗口必须用 event-time。
2. 以为开了 exactly-once 就万事大吉。 它只保证状态与对 sink 的输出精确;副作用(发邮件、调外部 API)不在事务里,照样可能重复。外部副作用要单独做幂等。
3. Kappa 却没规划 Kafka retention。 要 replay 30 天却只存 3 天,纠错时数据已被删——Kappa 的可行性直接绑定日志保留期与重算成本。
4. watermark 设太松或太紧。 太紧→大量迟到丢数;太松→窗口不关、延迟与状态双高。要按真实 skew 分布(看 p99 延迟)来定,并用 allowed lateness 兜底。

高频追问:① 为什么说「批是流的特例」?micro-batch 与 true streaming 的延迟下限差在哪?② Lambda 的两套代码具体怎么不一致、Kappa 怎么解决又引入什么新约束?③ Watermark 推进慢导致窗口不关、状态暴涨,怎么排查与缓解?④ 端到端 exactly-once 需要 source、算子、sink 各满足什么条件?⑤ 5000 万 eps、5s SLO,并行度与状态后端怎么估算?

深入资源

深入思考(点击展开答案)

1. 为什么说「批处理只是流处理的特例」?这个统一视角在工程上带来什么具体好处?

有界数据(一个文件、一天的日志)= 知道何时结束的无界数据流。一旦把批看成「有头有尾的流」,就只需一个引擎、一套窗口/状态/时间语义同时表达两者:处理实时流时 watermark 随真实时间推进;处理历史批时 watermark 可以「快进」,瞬间扫完全部数据。

工程好处:① 业务逻辑写一遍同时服务实时与回填,消除 Lambda 的双份代码与口径漂移;② 测试可用历史数据回放验证流作业正确性;③ 运维只维护一个技术栈。Flink/Beam 正是按这个理念设计——`Bounded` 与 `Unbounded` source 走同一套算子。代价是这个引擎本身比纯批引擎复杂得多(要处理 event-time、状态、容错)。

2. 一个 event-time 窗口因为某个分区的 watermark 卡住而迟迟不关,怎么定位根因?至少给 3 个方向。

关键认知:算子的 watermark = 所有上游输入分区 watermark 的最小值(min)。只要一个分区不推进,整体就被它拖住——这是「木桶最短板」。

  • 空闲分区(idle partition):某 Kafka 分区没新数据,它的 watermark 永远停在旧值,把全局拉住。解法:开启 `withIdleness`,让空闲源不参与 min 计算。
  • 数据倾斜/落后分区:某分区生产严重滞后(如某区域上报延迟),其 event-time 真的落后。解法:排查上游延迟,或调整 watermark 策略容忍。
  • 时钟/时间戳错误:少数事件带了未来或远古时间戳,污染 watermark 生成。解法:对时间戳做边界过滤,异常进 side output。
  • 反压:下游慢导致屏障/数据滞留,间接表现为 watermark 不推进。看 backpressure 指标区分。

排查靠 Flink Web UI 的 per-operator watermark 指标,逐级看是哪个 subtask 的 watermark 偏低。

3. 端到端 exactly-once 要 source、算子、sink 三方各满足什么?为什么只做 checkpoint 不够?
  • Source 必须可重放且可定位:能按 offset 回退重读(Kafka 行,普通 socket 不行)。恢复时 offset 随状态一起回滚到 checkpoint 点。
  • 算子状态必须一致快照:靠 ABS 屏障对齐,保证快照反映「恰好处理到屏障前所有记录」的一致切面。
  • Sink 必须支持事务或幂等:这是最容易漏的一环。只做 checkpoint、sink 却是普通 append——崩溃重放时,上个 checkpoint 之后已写出的数据会被再写一遍,下游重复。

所以 exactly-once = 一致快照(内部)+ 原子提交(边界)。Flink 用两阶段提交把 sink 的可见性与 checkpoint 绑定:checkpoint 成功才 commit 事务,失败则 abort。代价是输出有「提交延迟」——下游只能看到已 commit 的 checkpoint,所以 exactly-once 天然牺牲一点延迟。这也是监控类作业宁愿用 at-least-once + 幂等 upsert 的原因。

4. 5000 万 eps、端到端 p99 < 5s,粗估流作业的并行度与状态规模,瓶颈最可能在哪?

数量级估算(面试就要这个):

  • 并行度:单个 Flink slot 乐观处理 ~10–50 万 eps(取决于逻辑复杂度)。5000 万 / 20 万 ≈ 250+ 并行度,留余量按 ~400 个 slot 起步。Kafka 分区数要 ≥ 并行度,否则有 slot 闲置。
  • 状态规模:若按 videoId 做 5 分钟窗口聚合,活跃 video 假设 1000 万、每个 key 状态 ~200B → ~2GB 活跃状态;但若 key 基数高(如 user×video)或窗口长,轻松到 TB 级 → 必须 RocksDB 状态后端 + 增量 checkpoint。
  • 延迟预算:5s 里要塞下 watermark skew(容忍乱序,可能占 2–3s 大头)+ 窗口触发 + checkpoint 间隔 + sink 提交。watermark 容忍度往往是 p99 延迟的主导项,不是计算本身。

最可能的瓶颈:不是 CPU,而是①状态 IO(大 key 基数下 RocksDB 读写放大);②数据倾斜(热门视频的 key 把单 subtask 打爆,要预聚合/加盐打散);③checkpoint 时长(状态大时快照久,影响恢复与 exactly-once 提交延迟)。

5. 串联 Day 5/8:Kappa 的「replay 纠错」和复制延迟、消息投递语义是什么关系?

三者本质同源——都建立在「日志是真相、可回放」这一前提上

  • 对 Day 8(消息队列):Kappa 能成立完全依赖 Kafka 的持久化可重放日志at-least-once 投递。replay 就是把 consumer offset 重置到过去。但 at-least-once 意味着 replay 会重复投递,所以下游处理必须靠 checkpoint+事务做成 exactly-once,否则重算结果会翻倍——这正是技术点 4 与 Kappa 的咬合点。
  • 对 Day 5(复制):流处理的状态后端 + checkpoint 本身就是一种「状态机复制」——把输入日志确定性地重放,任意副本都能重建相同状态。这与数据库 leader 把 WAL 流给 follower 重放是同一思想:日志 + 确定性回放 = 可恢复、可复制的状态。
  • 反向约束:正因为 replay 依赖确定性,流作业里不能有非确定逻辑(如 `now()`、随机数、依赖外部可变状态),否则重放结果和首次不一致,Kappa 的「重算等价于原算」前提就破了。这是面试官最爱的隐藏陷阱。