为一个流媒体平台设计事件处理平台:客户端每次播放、暂停、卡顿都打一条事件,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)供离线精算与回放。
原理:传统认知是「批 = 攒一批跑一次,流 = 来一条算一条」,但更深的洞见是——批处理只是流处理的一个特例:有界数据(bounded)= 知道结尾的无界数据(unbounded)。Spark 的 micro-batch 把流切成小批(200ms 一个),Flink 则是真·逐条流式、把批当成「有头有尾的流」来跑。延迟下限因此天差地别。
| Spark (micro-batch) | Flink (true streaming) | 纯批 (MapReduce/Spark batch) | |
|---|---|---|---|
| 延迟 | 百毫秒~秒 | 毫秒级 | 分钟~小时 |
| 吞吐 | 极高 | 高 | 极高 |
| 状态/窗口 | 支持但偏弱 | 一等公民 | 无(每次全量) |
| 适合 | ETL+近实时混合 | 低延迟有状态 | 大规模回填/训练集 |
原理:Lambda 架构(Nathan Marz)跑两条链路——batch layer 算精确结果(高延迟)、speed layer 算实时近似(低延迟),查询时把两者合并。问题是同一套业务逻辑要写两遍(一份 Spark、一份 Flink),永远对不齐、维护翻倍。Kappa 架构(Jay Kreps)的洞见:既然流引擎已经成熟,砍掉批层,只留一条流链路,要纠错就把日志从头 replay 一遍。
# Kappa 的「纠错」不是补丁,而是重放
# 1. 发现聚合逻辑有 bug,修正 Flink 作业代码
# 2. 起一个新作业实例,从 offset 0(或某 checkpoint)重新消费
# 3. 写入一张新结果表 metrics_v2
# 4. 校验无误后,下游查询原子切到 v2,删除 v1
# 关键前提:Kafka retention 足够长 + 结果表可双写并存
原理:分清两个时间——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
allowed lateness 容忍一段迟到并更新已发结果;再晚的进 side output 单独处理。Akidau 的 Dataflow 模型把它拆成 What/Where/When/How 四问。# 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());
原理:分布式下消息会重发、节点会崩。at-most-once(可能丢)、at-least-once(可能重)易实现;exactly-once 最难。关键澄清:网络层不可能真正「只传一次」,工程上追求的是 effectively-once——状态与输出等价于只处理一次。Flink 的做法是 checkpoint:用 Chandy-Lamport 的变体「异步屏障快照(ABS)」,往数据流里插入屏障(barrier),算子收到屏障就快照自己的状态;崩溃后从上个一致快照恢复、并把 source offset 一起回滚。
# 端到端 exactly-once = 状态快照 + 事务性输出(2PC)
# checkpoint 完成时:
# pre-commit: 把这一批输出预写到事务(Kafka txn / DB 临时区)
# notify: 所有算子快照成功 → coordinator 发 commit
# commit: 事务提交,下游可见;崩溃则 abort,回滚到上个 checkpoint
# 失败恢复:source offset 与算子状态一起回到上个一致点,重放但不重复对外
TwoPhaseCommitSinkFunction 做端到端 exactly-once(官方文档详述)。高频追问:① 为什么说「批是流的特例」?micro-batch 与 true streaming 的延迟下限差在哪?② Lambda 的两套代码具体怎么不一致、Kappa 怎么解决又引入什么新约束?③ Watermark 推进慢导致窗口不关、状态暴涨,怎么排查与缓解?④ 端到端 exactly-once 需要 source、算子、sink 各满足什么条件?⑤ 5000 万 eps、5s SLO,并行度与状态后端怎么估算?
有界数据(一个文件、一天的日志)= 知道何时结束的无界数据流。一旦把批看成「有头有尾的流」,就只需一个引擎、一套窗口/状态/时间语义同时表达两者:处理实时流时 watermark 随真实时间推进;处理历史批时 watermark 可以「快进」,瞬间扫完全部数据。
工程好处:① 业务逻辑写一遍同时服务实时与回填,消除 Lambda 的双份代码与口径漂移;② 测试可用历史数据回放验证流作业正确性;③ 运维只维护一个技术栈。Flink/Beam 正是按这个理念设计——`Bounded` 与 `Unbounded` source 走同一套算子。代价是这个引擎本身比纯批引擎复杂得多(要处理 event-time、状态、容错)。
关键认知:算子的 watermark = 所有上游输入分区 watermark 的最小值(min)。只要一个分区不推进,整体就被它拖住——这是「木桶最短板」。
排查靠 Flink Web UI 的 per-operator watermark 指标,逐级看是哪个 subtask 的 watermark 偏低。
所以 exactly-once = 一致快照(内部)+ 原子提交(边界)。Flink 用两阶段提交把 sink 的可见性与 checkpoint 绑定:checkpoint 成功才 commit 事务,失败则 abort。代价是输出有「提交延迟」——下游只能看到已 commit 的 checkpoint,所以 exactly-once 天然牺牲一点延迟。这也是监控类作业宁愿用 at-least-once + 幂等 upsert 的原因。
数量级估算(面试就要这个):
最可能的瓶颈:不是 CPU,而是①状态 IO(大 key 基数下 RocksDB 读写放大);②数据倾斜(热门视频的 key 把单 subtask 打爆,要预聚合/加盐打散);③checkpoint 时长(状态大时快照久,影响恢复与 exactly-once 提交延迟)。
三者本质同源——都建立在「日志是真相、可回放」这一前提上。