串流資料管線 — 面試深入解析
範圍 — 資料工程師面試會問到的串流處理全貌:何時該用串流、參考架構、時間語意與 watermark、視窗與遲到資料、狀態與 checkpoint、端到端 exactly-once、串流 join、背壓與擴展、各引擎(Flink、Spark Structured Streaming、Kafka Streams、Beam)、即時服務與維運。Kafka broker 內部機制不在這裡 —— 它們屬於
kafka.md。 另見:../faq_DE_interview.md—— 總覽與考前檢查清單;./kafka.md—— 大多數串流底下的那份 log;./batch_pipeline.md—— 同一條管線的批次那一側;./spark.md—— Spark 內部機制;./flink.md—— Flink 的 runtime、API 與設定的深入內容;../flink/faq_flink.md與../stream/faq_stream.md—— 較早的筆記。
每一節都先給你會說出口的那句話,再講機制,然後是面試官下一步會追問的取捨。 程式碼以 Flink 1.19+ / 2.x、Spark 3.5+ 與 Kafka Streams 3.x 為準;版本有差的地方會特別註明。
1) 批次 vs 串流 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
1-1) 什麼時候值得用串流
只有當有人會根據結果採取行動、而且需要比批次更快拿到時,才用串流 —— 串流買到的是 延遲,付出的代價是永遠在線的基礎設施與更難的正確性。
| 批次 | 串流 | |
|---|---|---|
| 輸入 | 有界(一天、一小時) | 無界 |
| 延遲 | 分鐘–小時 | 毫秒–秒 |
| 完整性 | job 執行時所有資料都在 | 資料遲到、亂序到達 |
| 狀態 | 每次執行重建 | 在 job 的整個生命週期內保留 |
| 重新處理 | 重跑該 partition | 把 log 重播進新的 job |
| 故障處理 | 重試 task | checkpoint、重啟、處理重複資料 |
| 成本 | 按次付費 | 為永遠在線、有狀態的 worker 付費 |
好的串流理由:詐欺與濫用偵測、告警與監控、即時營運 dashboard、個人化特徵、 系統之間的 CDC 複製。 薄弱的理由:「日報應該要即時」,但根本沒人一天看超過一次 —— 每小時一次的批次 更便宜也更簡單。
面試訊號:選擇之前先問「誰會讀這個結果,它可以多舊?」
1-2) Micro-batch vs 逐筆處理
Micro-batch 引擎把一小批紀錄收集起來,對它跑一個迷你批次 job;逐筆(record-at-a-time) 引擎則在每筆紀錄到達時就把它推過各個運算子。
| Micro-batch | 逐筆處理 | |
|---|---|---|
| 例子 | Spark Structured Streaming(預設 trigger) | Flink、Kafka Streams、Dataflow |
| 典型延遲 | 約 100 ms 到數秒,下限由 trigger 決定 | 毫秒 |
| 吞吐量 | 高 —— 批次開銷被攤提 | 高 |
| 容錯 | 重播失敗的 micro-batch | 分散式快照(checkpoint) |
| 心智模型 | 反覆執行的批次查詢 | 永不停止的資料流圖 |
Spark 也有 continuous processing 模式,但它仍標示為實驗性,而且只支援類似 map 的運算 —— 不要把它當成低延遲的答案。
1-3) Lambda vs Kappa 架構
Lambda 用一個批次層負責準確、一個速度層負責新鮮度,再把兩者合併;Kappa 只跑串流, 重新處理時就重播 log。
Lambda Kappa
┌─► batch layer ─┐ ┌─► stream job v1 ─► serving
source ─► log ──┤ ├─► serve source ─► log
└─► speed layer ─┘ └─► stream job v2 (replay) ─► serving
| Lambda | Kappa | |
|---|---|---|
| 程式碼 | 兩套(批次 + 串流),必須一致 | 一套 |
| 重新處理 | 重跑批次層 | 把 log 重播進新版本的 job,再切換過去 |
| 需要 | 批次儲存 + 串流基礎設施 | 長的 log retention(或分層儲存 / 封存) |
| 弱點 | 兩條路徑之間的邏輯漂移 | 用串流 job 重播好幾個月的資料很慢 |
現代做法偏向 Kappa:一個能用同一份邏輯跑批次與串流的引擎(Flink、Spark、Beam), 原始串流封存在物件儲存或 lakehouse 表裡,批次回補用同一份程式碼讀那份封存。
2) 參考架構 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
2-1) 標準的管線
來源把事件寫進一份持久的 log,有狀態的處理器把它們轉成結果,sink 再把結果提供給 dashboard、服務或 lakehouse。
Apps / devices ─► producers ─┐
├─► Kafka (raw topics) ─► Stream processor ─┬─► Kafka (derived topics)
OLTP DB ─► CDC (Debezium) ───┘ │ (Flink / Spark / ├─► Real-time OLAP (Pinot, Druid, ClickHouse)
│ Kafka Streams) ├─► Serving store (Redis, Cassandra, DynamoDB)
│ state + checkpoints└─► Lakehouse table (Iceberg / Delta / Hudi)
└─► archive to object storage (replay, batch backfill)
各個元件以及它們存在的理由:
- 持久 log(Kafka、Kinesis、Pub/Sub)—— 讓 producer 與 consumer 解耦,並讓來源 可以重播,每一個復原與重新處理的方案都依賴這一點。
- Schema Registry —— producer 與 consumer 對事件的形狀達成一致;見
schema_design.md。 - 串流處理器 —— 過濾、補充(enrichment)、彙總、join;持有狀態。
- 衍生 topic —— 其他團隊消費的結果,所以一個 job 的輸出就是另一個 job 的輸入。
- Sink —— 依存取模式選擇:依 key 查找 → key-value 儲存,任意切片的彙總 → 即時 OLAP,歷史與分析 → lakehouse 表。
2-2) 以 CDC 作為來源
變更資料擷取(CDC)讀取資料庫自己的變更 log(MySQL binlog、PostgreSQL WAL),把每一次 insert、update、delete 轉成事件,而不必查詢資料表。
- 常見答案是跑在 Kafka Connect 上的 Debezium;每張表變成一個以主鍵為 key 的 topic,
帶有
before/after映像與一個op欄位(c、u、d,快照則是r)。 - 它先做一次初始快照,再從它記錄下的 log 位置開始串流變更。
- 刪除會先送出一個 delete 事件,再跟著一個 tombstone(值為 null),讓 compacted topic 可以丟掉那個 key。
- Outbox 模式 —— 服務在同一個本地交易中寫入業務資料列和一筆
outbox資料列; CDC 負責發佈 outbox。它避免了雙寫問題(DB 提交成功但 Kafka 發佈失敗,或反過來)。
2-3) 選擇 sink
| 需求 | Sink | 為什麼 |
|---|---|---|
| 依 key 查找(feature store、使用者檔案) | Redis、Cassandra、DynamoDB | 依 key 低延遲讀取;以 upsert 寫入結果 |
| 次秒級、任意切片的 dashboard | Pinot、Druid、ClickHouse | 欄式,直接從 Kafka 擷取 |
| 歷史、分析、ML 訓練 | Iceberg / Delta / Hudi 表 | 便宜的儲存、SQL 引擎、time travel |
| 搜尋事件 | Elasticsearch / OpenSearch | 倒排索引 |
| 其他串流 job | 一個 Kafka topic | 可重播、fan-out |
Sink 決定你能提供的投遞保證(第 6 節):依 key upsert 的 sink 能讓 at-least-once 看起來像 exactly-once;只能 append 的 sink 則不行。
3) 時間語意與 Watermark 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
3-1) 事件時間、擷取時間、處理時間
事件時間是事情發生的時間;處理時間是引擎看到它的時間。正確的彙總要用事件時間, 因為處理時間每次重播都會變。
| 時間 | 由誰設定 | 重播時是否確定? | 用途 |
|---|---|---|---|
| 事件時間(Event time) | producer,寫在紀錄裡 | 是 | 業務指標、視窗、join |
| 擷取時間(Ingestion time) | 到達時由 broker / 來源設定 | 否 | 事件本身不帶時間戳時 |
| 處理時間(Processing time) | 運算子的牆鐘時間 | 否 | 逾時、監控、「最近 N 秒的流量」 |
事件時間與處理時間之間的差距(skew)從來不是常數:行動裝置會離線、某個 partition 會落後、producer 會重試。這正是 watermark 存在的全部理由。
3-2) Watermark
Watermark 是串流中的一個標記,意思是「不會再有時間戳 ≤ T 的事件了」;當 watermark 越過事件時間視窗與計時器時,視窗才會關閉、計時器才會觸發。
常見策略是 bounded out-of-orderness(有界亂序):watermark = 目前看到的最大事件時間 − 一個固定的上限。每個平行的來源實例各自送出 watermark,而有多個輸入的運算子取它們的 最小值。
// java — Flink 1.19+: watermarks for a Kafka source
WatermarkStrategy<Ride> watermarks = WatermarkStrategy
.<Ride>forBoundedOutOfOrderness(Duration.ofSeconds(10)) // tolerate 10 s of disorder
.withTimestampAssigner((ride, recordTs) -> ride.getEventTimeMillis())
.withIdleness(Duration.ofMinutes(1)); // see 3-3
DataStream<Ride> rides = env.fromSource(kafkaSource, watermarks, "rides");
| 上限設太小 | 上限設太大 |
|---|---|
| 更多事件落在 watermark 之後,被算成遲到 | 視窗更晚關閉,所以結果延遲 |
| 延遲較低、狀態較少 | 結果較完整、保留的狀態較多 |
Watermark 是一種啟發式估計,不是保證:它用完整性換延遲,而落在它之後的一切 都需要一個遲到資料策略(第 4-3 節)。
3-3) 閒置來源與 watermark 對齊
因為運算子取輸入 watermark 的最小值,一個停止送資料的 partition 就會凍結整個 job 的 watermark —— 所有視窗都不再關閉。
- Idleness(閒置) ——
withIdleness(...)在安靜一段時間後把該 split 標成閒置, 讓它在資料再次到達前不參與最小值計算。 - Watermark alignment(對齊)(Flink 1.15+,
withWatermarkAlignment)—— 處理相反的 問題:某個來源遠遠領先其他來源,讓下游運算子必須把它的資料緩衝在狀態裡。對齊會暫停 那些領先太多的來源。 - 一個 partition 數多於活躍 key 數的 topic,或一個讀某個 partition 比另一個快的回補, 都會同時觸發這兩個問題;在 Flink UI 檢查每個 subtask 的 watermark。
4) 視窗、Trigger 與遲到資料 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
4-1) 視窗類型
視窗把無限的串流切成可以彙總的有限區塊;類型決定這些區塊是固定的、重疊的, 還是由活動的間隔來定義。
| 視窗 | 定義 | 每個事件落在 | 例子 |
|---|---|---|---|
| Tumbling(滾動) | 固定大小、不重疊 | 剛好一個視窗 | 每城市每分鐘的行程數 |
| Sliding / hopping(滑動) | 固定大小,以小於大小的 slide 前進 | size ÷ slide 個視窗 | 每分鐘計算一次 10 分鐘移動計數 |
| Session(會話) | 每個 key 在一段閒置間隔後關閉 | 一個 session(session 會合併) | 使用者的造訪,直到閒置 30 分鐘 |
| Global(全域) | 所有時間共用一個視窗 | 那唯一的視窗 | 需要自訂 trigger 才會輸出 |
Cumulative(累積)(Flink SQL CUMULATE) |
在最大大小內分步成長 | 直到最大值的每一步 | 今天到目前為止的營收,每分鐘更新 |
// java — Flink 1.19+: one-minute tumbling event-time windows per city
rides.keyBy(Ride::getCity)
.window(TumblingEventTimeWindows.of(Duration.ofMinutes(1)))
.aggregate(new CountAndSumFare()) // incremental: keeps one accumulator per window
.sinkTo(resultSink);
優先用增量的 aggregate / reduce,而不是會緩衝每個事件的 process 視窗函式:
每個視窗的狀態就從「所有事件」降為「一個累加器」。
4-2) Trigger:視窗何時輸出
Trigger 決定視窗的結果何時輸出;預設的事件時間 trigger 只觸發一次,就在 watermark 越過視窗結束時間的時候。
- 提早觸發(early firing) —— 在視窗關閉前輸出推測性的結果(每 10 秒處理時間一次), 讓 dashboard 不會在一小時長的視窗裡整段空白。
- 延後觸發(late firing) —— 在允許的遲到時間內,每個遲到事件都輸出一次更新後的結果。
- 第一次之後的每次觸發都是同一個視窗的修正(refinement),所以 sink 必須能接受它:
依
(key, window_start)覆寫,或消費 retraction 串流。 - Beam 把這幾個問題講得很明白:計算什麼(轉換)、在事件時間的哪裡(視窗)、 在處理時間的何時(trigger),以及修正之間如何關聯(accumulating vs discarding panes)。
4-3) 遲到資料策略
遲到事件是指在 watermark 已經越過其視窗之後才到達的事件;你可以丟掉它、把它導到 旁邊,或更新已經輸出的結果。
| 策略 | 怎麼做 | 成本 |
|---|---|---|
| 丟棄 | 視窗關閉後的預設行為 | 默默少算;至少要計算丟了多少 |
| Allowed lateness(允許遲到) | 視窗狀態多保留一段時間,每個遲到事件都重新觸發 | 更多狀態;下游會看到更新 |
| Side output(側輸出) | 把遲到事件送到另一個串流 / 表 | 之後由批次 job 對帳 |
| 下游 upsert / retract | 以視窗為 key 輸出結果;後來的結果覆寫前面的 | sink 必須支援 upsert |
| 用批次修正 | 串流給快速估計,對封存資料跑的批次 job 給最終數字 | 兩條路徑,就像 Lambda |
// java — Flink: keep windows 5 extra minutes, send anything later to a side output
final OutputTag<Ride> lateRides = new OutputTag<Ride>("late-rides") {};
SingleOutputStreamOperator<CityCount> counts = rides
.keyBy(Ride::getCity)
.window(TumblingEventTimeWindows.of(Duration.ofMinutes(1)))
.allowedLateness(Duration.ofMinutes(5))
.sideOutputLateData(lateRides)
.aggregate(new CountAndSumFare());
DataStream<Ride> tooLate = counts.getSideOutput(lateRides); // e.g. write to a reconciliation table
5) 狀態 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
5-1) Keyed state vs operator state
Keyed state 依串流的 key 分區,只有帶著那個 key 的紀錄看得到;operator state 屬於 一個平行實例 —— 來源已讀到的 Kafka offset 就是經典例子。
| Keyed state | Operator state | |
|---|---|---|
| 範圍 | keyBy 之後,每個 key 一個值 |
每個平行 subtask 一個值 |
| 類型(Flink) | ValueState、ListState、MapState、ReducingState、AggregatingState |
ListState、union list state、broadcast state |
| 擴縮時如何重新分配 | 依 key group(第 8-2 節) | 切分 / 合併那些 list |
| 例子 | 每個使用者的累計總額、每台裝置最後出現的時間 | 來源 offset、緩衝中的 sink 交易 |
// java — Flink keyed state: alert when a card's running spend exceeds a limit
public class SpendTracker extends KeyedProcessFunction<String, Payment, Alert> {
private transient ValueState<Double> total;
@Override
public void open(OpenContext ctx) { // Flink 1.19+; older versions: open(Configuration)
StateTtlConfig ttl = StateTtlConfig.newBuilder(Duration.ofHours(24)).build();
ValueStateDescriptor<Double> desc = new ValueStateDescriptor<>("total", Double.class);
desc.enableTimeToLive(ttl); // bound state: idle cards expire
total = getRuntimeContext().getState(desc);
}
@Override
public void processElement(Payment p, Context ctx, Collector<Alert> out) throws Exception {
double current = total.value() == null ? 0.0 : total.value();
current += p.getAmount();
total.update(current);
if (current > 10_000.0) {
out.collect(new Alert(p.getCardId(), current));
}
}
}
5-2) State backend
State backend 決定即時狀態放在哪裡、怎麼做快照:放在 JVM heap 求快,或放在本機磁碟上 的嵌入式 RocksDB 以容納比記憶體更大的狀態。
| Backend | 存放於 | 適合 | 注意 |
|---|---|---|---|
Flink HashMapStateBackend |
JVM heap,以物件形式 | 小狀態、最低延遲 | GC 停頓,狀態受限於 heap |
Flink EmbeddedRocksDBStateBackend |
本機磁碟上的 RocksDB | 大狀態、增量 checkpoint | 每次存取都要序列化;要調 RocksDB 記憶體 |
| Flink 2.0 ForSt | 分離式、遠端儲存 | 非常大的狀態、更快的擴縮 | 較新 —— 確認你版本的成熟度 |
| Spark 預設 state store | executor 記憶體,版本化檔案存在 checkpoint 目錄 | 小到中等的狀態 | key 很多時有記憶體壓力 |
| Spark RocksDB state store(3.2+) | 本機磁碟上的 RocksDB | 大狀態 | 設定 spark.sql.streaming.stateStore.providerClass |
| Kafka Streams | 本機 RocksDB + 一個 changelog topic | 每個應用程式實例的狀態 | 還原時間 = 重播 changelog |
不論即時狀態放在哪個 backend,checkpoint 本身都寫到持久儲存(S3、HDFS、GCS)。
5-3) 狀態 TTL 與狀態大小
除非有東西把它移除,串流狀態會無限成長:視窗關閉、watermark 越過,或明確的 TTL。
- 無界的 key —— 沒有視窗的
GROUP BY user_id會為每個出現過的使用者保留一筆。 設定 TTL(StateTtlConfig、Flink SQLtable.exec.state.ttl)或把彙總加上視窗。 - 沒有時間上限的串流-串流 join 會永遠保留兩邊(第 7-1 節)。
- 去重集合需要一個時間範圍:「丟掉 24 小時內看過的重複」,而不是「永遠」。
- 狀態成長的徵兆:checkpoint 大小與時間持續上升、RocksDB 磁碟逐漸塞滿、 每週復原都變得更慢。
- TTL 是正確性的決定,不只是維運的決定:過期的 key 表現得像新的一樣,所以超過 TTL 後回來的使用者會被當成新使用者。
6) 容錯與 Exactly-Once 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
6-1) Checkpoint:分散式快照
Checkpoint 是每個運算子狀態加上其對應來源 offset 的一致性快照;故障時,job 還原最後 一個 checkpoint,並從那些 offset 重播來源。
Flink 實作的是 Chandy-Lamport 演算法的一種變體(asynchronous barrier snapshotting, 非同步屏障快照):
source ──[e5][e4][ B n ][e3][e2]──► operator ──► sink
1. The checkpoint coordinator injects barrier n into every source; each source records its offsets.
2. Barriers flow with the data. An operator that has received barrier n on all inputs snapshots
its state (asynchronously) and forwards the barrier.
3. When every sink has acknowledged barrier n, checkpoint n is complete.
4. Failure → restart all tasks from checkpoint n: restore state, rewind sources to n's offsets.
Barrier n 之前的一切都在快照裡;之後的一切都會被重播。這就是狀態一致的原因:沒有任何 事件在狀態中被算兩次,也沒有任何事件從狀態中遺失。
// java — Flink: checkpoint every minute, exactly-once state semantics
env.enableCheckpointing(60_000, CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30_000);
env.getCheckpointConfig().setCheckpointTimeout(10 * 60_000);
6-2) 對齊 vs 非對齊 checkpoint
使用對齊(aligned)checkpoint 時,有多個輸入的運算子要等每個輸入都收到 barrier 才做 快照;在背壓下這段等待可能讓 checkpoint 卡很久,非對齊(unaligned)checkpoint 就是為了 避免這點。
| 對齊 | 非對齊(Flink 1.11+) | |
|---|---|---|
| 機制 | 已送來 barrier 的輸入先被擋住,直到所有輸入都送來 | barrier 超車緩衝中的資料;傳輸中的緩衝一起放進 checkpoint |
| 背壓下 | checkpoint 時間隨積壓量成長 | checkpoint 很快完成 |
| Checkpoint 大小 | 只有狀態 | 狀態 + 傳輸中的資料 |
| 模式 | exactly-once 與 at-least-once | 只有 exactly-once |
At-least-once checkpoint 完全跳過對齊:在其輸入的 barrier 之後才到的紀錄可能被處理、 然後又被重播,所以可能被算兩次。
6-3) Checkpoint vs savepoint
Checkpoint 是自動的,用來從故障中復原;savepoint 由你觸發,用來在不遺失狀態的情況下 停止、升級、擴縮或遷移 job。
| Checkpoint | Savepoint | |
|---|---|---|
| 由誰觸發 | Flink,週期性 | 維運者(flink savepoint、flink stop --savepointPath) |
| 用途 | 故障復原 | 計畫性升級、擴縮、Flink 版本升級、job 版本 A/B |
| 格式 | 可能是增量的、依 backend 而定 | canonical(可跨 backend 移植)或 native |
| 生命週期 | 由 Flink 管理,舊的會被清掉 | 由你管理,保留到你刪除為止 |
給每個有狀態的運算子一個穩定的 .uid("..."):savepoint 的狀態是依 id 對應到運算子的,
而自動產生的 id 在 job 圖改變時就會變。
6-4) 端到端 exactly-once 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
串流引擎裡的「exactly-once」指的是每個事件只影響狀態一次;要在輸出也做到,需要 三樣東西:可重播的來源、一致的 checkpoint,以及支援交易或冪等的 sink。
| 元件 | 要求 | 例子 |
|---|---|---|
| 來源 | 能倒回到記錄下的位置 | Kafka offset、Kinesis sequence number、檔案 |
| 引擎 | 狀態 + offset 原子地一起快照 | Flink checkpoint、Spark checkpoint + offset 的 WAL |
| Sink | 重播的輸出不會重複 | Kafka transaction、兩階段提交、每批一次的 Delta / Iceberg commit、依 key upsert |
- 交易式 sink —— 輸出先暫存,只有在 checkpoint 提交時才變得可見。下游讀取者必須
只讀已提交的資料(Kafka
isolation.level=read_committed)。 代價:輸出每個 checkpoint 才可見一次,所以延遲 ≥ checkpoint 間隔。 - 冪等 sink —— 重播時以相同的值寫入相同的 key(依
(key, window_start)upsert、 確定性的 id)。延遲較低,但讀取者可能短暫看到一個之後會被以相同值重寫的結果。 - Sink 以外的副作用(寄信、呼叫 API)永遠不在保證範圍內 —— 用去重 key 讓它們冪等。
- 沒有交易式或冪等的 sink 時,誠實的描述是 at-least-once。
6-5) 兩階段提交 sink
兩階段提交 sink 每個 checkpoint 開一個交易,barrier 到達時預提交(pre-commit), 只有在整個 checkpoint 成功後才正式提交。
barrier n arrives at sink ──► pre-commit txn n (flush, keep it open) ──► start txn n+1
checkpoint n completes ──► commit txn n (now visible to read_committed readers)
failure before completion ──► abort txn n, restore checkpoint n-1, replay
// java — Flink KafkaSink with exactly-once delivery
KafkaSink<String> sink = KafkaSink.<String>builder()
.setBootstrapServers("broker:9092")
.setRecordSerializer(KafkaRecordSerializationSchema.builder()
.setTopic("ride-counts")
.setValueSerializationSchema(new SimpleStringSchema())
.build())
.setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
.setTransactionalIdPrefix("ride-counts-job") // must be unique per job
.setProperty("transaction.timeout.ms", "900000") // ≤ broker transaction.max.timeout.ms
.build();
陷阱:producer 的 transaction.timeout.ms 必須涵蓋最長的 checkpoint 加上重啟時間,否則
broker 會中止一個已預提交的交易,其資料就遺失了;而且它不能超過 broker 的
transaction.max.timeout.ms(預設 15 分鐘)。
7) 串流 Join 與資料補充 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
7-1) 串流-串流 join
Join 兩個無界串流需要一個時間上限 ——「付款在訂單後 15 分鐘內到達」—— 引擎才知道 什麼時候可以忘掉它正在緩衝的資料列。
| Join | 條件 | 保留的狀態 |
|---|---|---|
| Interval join(區間 join) | b.ts BETWEEN a.ts - x AND a.ts + y |
每一邊保留該區間,由 watermark 清除 |
| Window join(視窗 join) | 兩個事件在同一個視窗 | 每個視窗 |
| 一般(無界)join | 只有 key 相等 | 兩邊永遠保留 —— 需要 TTL |
-- Flink SQL: match each order to its payment within 15 minutes (interval join)
SELECT o.order_id, o.amount, p.payment_id, p.pay_time
FROM orders o, payments p
WHERE o.order_id = p.order_id
AND p.pay_time BETWEEN o.order_time AND o.order_time + INTERVAL '15' MINUTE;
Outer join 多了一個問題:什麼時候輸出「沒有付款的訂單」?只有在 watermark 越過區間結束 之後,所以 outer join 中未匹配的資料列會延遲「區間長度 + watermark 上限」。基於同樣的理由, Spark 的 outer 串流-串流 join 要求兩邊都有 watermark 並帶時間條件。
7-2) 串流-表格資料補充
資料補充(enrichment)是為每個事件加上維度屬性(使用者等級、商家類別);你可以在外部 儲存查找,或把那張表當成 job 內的狀態保存。
| 做法 | 怎麼做 | 取捨 |
|---|---|---|
| 每個事件查找一次 | 每筆紀錄查詢一次 DB / 快取(Flink async I/O、lookup join) | 簡單;有外部延遲與負載,要用 async I/O 加快取 |
| 表格當狀態 | 把該表的 CDC 串流消費進 keyed state,在本地 join | 快、沒有外部呼叫;狀態裝著整張表 |
| Broadcast | 小表送到每個平行實例(7-4) | 最快;只適合小表 |
| Kafka Streams KStream-KTable join | 從 compacted topic 物化出來的 KTable | 和表格當狀態一樣,依 key 分區 |
Lookup join 看到的是處理時間當下的表,所以重播時會用今天的屬性去補充舊事件; temporal join(7-3)解決這個問題。
7-3) Temporal join
Temporal join 把每個事件 join 到在事件時間當下有效的那個表格版本 —— 等同於串流版的 SCD Type 2 查找。
-- Flink SQL: price each order at the exchange rate valid when it was placed
-- currency_rates must be a versioned table: a PRIMARY KEY and an event-time attribute with a watermark
SELECT o.order_id,
o.amount * r.rate AS amount_usd
FROM orders AS o
JOIN currency_rates FOR SYSTEM_TIME AS OF o.order_time AS r
ON o.currency = r.currency;
重播會得到相同的答案,因為查找是以事件時間為準,而不是 join 執行的那一刻。
7-4) Broadcast state
Broadcast state 把一個小而變化緩慢的串流 —— 規則、門檻、功能開關 —— 送到每個平行實例, 讓每一個都能把它套用到自己那一份主串流上。
- 典型用法:詐欺 job 的規則在執行期更新,不需要重新部署。
- 每個實例都持有完整一份,所以只適合小資料。
- Broadcast 那一側在每個實例上必須以完全相同的方式處理;每個 key 的邏輯留在 keyed 那一側。
7-5) 去重
Producer 會重試、來源會重播,所以重複資料是常態;在一個有界的時間範圍內,依唯一的 事件 id 去重。
-- Flink SQL: keep the first event per event_id (the planner recognises this pattern)
SELECT event_id, user_id, event_time
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY event_time ASC) AS rn
FROM clicks
)
WHERE rn = 1;
# python — Spark 3.5+: drop duplicates seen within the watermark delay
deduped = (events.withWatermark("event_time", "1 hour")
.dropDuplicatesWithinWatermark(["event_id"]))
- id 必須在 producer 端指定 —— 擷取之後才產生的 id 每次重播都不一樣。
- 限制狀態大小:狀態 TTL(Flink SQL)或 watermark(Spark)決定 id 被記住多久。 在那個範圍之後才到的重複資料會直接通過。
8) 背壓、擴展與熱點 Key 優先度 3/5 — 值得會 — 多半是必備模式的變形
8-1) 背壓
背壓(backpressure)是一個慢的運算子或 sink 讓它上游的一切都跟著變慢,而不是一路緩衝 到記憶體耗盡。
- Flink —— 有界的網路緩衝區:當下游 task 的緩衝區滿了,上游 task 就送不出去而變慢, 一路回推到來源。Web UI 會顯示每個 task 的背壓與忙碌時間。
- Spark —— 每個 micro-batch 只取有限的量:
maxOffsetsPerTrigger(Kafka)或maxFilesPerTrigger(檔案)。 - Kafka Streams —— consumer 單純地 poll 得比較慢。
- 不論哪一種,積壓最後都會變成 Kafka 裡的 consumer lag,而那正是它該在的地方: 由 log 吸收突發流量。
怎麼診斷:找出第一個忙碌、而它下一個卻不忙的運算子 —— 那就是瓶頸。常見原因:慢的 外部查找(用 async I/O)、傾斜的 key、partition 太少的 sink,或 RocksDB 裡狀態很大但 記憶體不夠。
8-2) 擴展與重新擴縮
平行度的上限是來源的 partition 數;keyed state 被切成 key group,重新擴縮時就是把整個 key group 重新分配給新的一組實例。
- Kafka 來源每個 partition 最多一個活躍讀取者,所以平行度超過 partition 數時,多出來的 來源實例會閒置(下游運算子仍可以更寬)。
- Flink 把每個 key 雜湊到
maxParallelism個 key group 之一;每個 subtask 擁有一段 連續的範圍。重新擴縮 = 做一個 savepoint、以新的平行度重啟,每個 subtask 載入它的新範圍。 maxParallelism固定了 key group 的數量,除非改寫狀態(State Processor API),否則 一個 job 的 keyed state 無法改變它。在第一次正式部署之前就刻意設定好。- 自動擴縮:Flink Kubernetes Operator 的 autoscaler、Dataflow 的自動擴縮,Kafka Streams 則是增加實例(partition 會被重新分配)。
8-3) 熱點 key
一個承載大部分流量的 key 會讓一個 subtask 卡在 100%,其他的卻閒著,再多的平行度都沒用。
| 修法 | 怎麼做 | 何時 |
|---|---|---|
| 兩階段彙總 | 在 key 上加隨機 salt,先依 (key, salt) 預彙總,再依 key 彙總 |
可結合的彙總(count、sum、min、max) |
| 本地預彙總 | 在 shuffle 前於每個 subtask 內先合併(Flink SQL table.exec.mini-batch.* + two-phase agg) |
高流量的 group-by |
| 把熱點 key 拆出去 | 已知的熱點 key 走專用的路徑 | 少數已知的 key(測試帳號、大租戶) |
| 換 key | 用更細的粒度當 key(city, zone),再在下游彙總 |
粒度允許時 |
9) 各引擎 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
9-1) 比較
| Apache Flink | Spark Structured Streaming | Kafka Streams | Apache Beam / Dataflow | |
|---|---|---|---|---|
| 模型 | 逐筆處理的資料流 | micro-batch(增量查詢) | 逐筆處理的函式庫 | 統一模型;由 runner 執行 |
| 部署 | Flink 叢集(K8s operator、YARN、託管服務) | Spark 叢集 / Databricks | 你 Java 應用程式裡的一個函式庫 | Dataflow,或 Flink / Spark runner |
| 狀態 | 一等公民、非常大、有 savepoint | 每個 micro-batch 的 state store | 本地 RocksDB + changelog topic | 由 runner 管理 |
| 事件時間 | 完整:watermark、計時器、遲到資料 | 彙總與 join 的 watermark | grace period、stream time | 完整:視窗、trigger、累積模式 |
| 來源 / sink | 大量 connector | Spark 資料來源 | Kafka 進、Kafka 出 | 大量 IO |
| 何時選 | 低延遲、複雜的事件時間、大狀態 | 團隊在跑 Spark;批次 + 串流一個引擎 | 以 Kafka 為中心的微服務,不用維護叢集 | GCP,或要跨 runner 的可移植性 |
9-2) Flink:DataStream 與 Table / SQL
Flink 在同一個 runtime 上有兩套主要 API:完全掌控狀態與計時器的 DataStream API,以及 由 planner 最佳化的宣告式 Table API / SQL。
- DataStream ——
keyBy、視窗、帶狀態與事件時間計時器的KeyedProcessFunction、 side output、async I/O。用在自訂邏輯(詐欺規則、複雜事件處理)。 - Table API / SQL —— 動態表:串流就是一張不斷變化的表,對它的查詢會產生一份 changelog
(
+I插入、-U/+U更新前/後、-D刪除)。 - 只會 append 的結果(視窗彙總、過濾)可以寫到任何 sink;會更新的結果(沒有視窗的
GROUP BY)需要帶主鍵的 upsert sink(upsert-kafka、帶 key 的 JDBC、Paimon / Hudi)。 - CEP 函式庫 —— 在事件序列上做模式比對(SQL 裡的
MATCH_RECOGNIZE)。
9-3) Spark Structured Streaming
Structured Streaming 把串流當成一張無界的表,對每個 trigger 一個 micro-batch,增量地 執行你的批次 DataFrame 查詢。
| 輸出模式 | 輸出 | 適用於 |
|---|---|---|
| Append | 只有永遠不會再變的資料列 | 不做彙總的查詢;有 watermark 的彙總(視窗關閉時輸出) |
| Update | 自上次 trigger 以來有變的資料列 | 彙總(在一般查詢上等同 append);不是每個 sink 都支援 |
| Complete | 每次 trigger 輸出整張結果表 | 只有彙總;結果必須放得進記憶體 |
| Sink | 輸出模式 | 備註 |
|---|---|---|
| 檔案(Parquet 等) | append | 透過 sink 的 metadata log 做到 exactly-once |
| Delta Lake | append、complete | 不支援 update;要更新就用 foreachBatch + MERGE |
| Kafka | append、update、complete | at-least-once |
foreachBatch |
任何模式 | 每個 micro-batch 跑你的程式碼;用 batch id 讓它冪等 |
| Console / memory | 任何模式 | 只用於除錯 |
# python — Spark 3.5+: one-minute ride counts per city, written to Delta
from pyspark.sql import functions as F
rides = (spark.readStream.format("kafka")
.option("kafka.bootstrap.servers", "broker:9092")
.option("subscribe", "rides")
.option("maxOffsetsPerTrigger", 500000) # bound each micro-batch
.load()
.select(F.from_json(F.col("value").cast("string"), ride_schema).alias("r"))
.select("r.*"))
counts = (rides.withWatermark("event_time", "10 minutes")
.groupBy(F.window("event_time", "1 minute"), "city")
.agg(F.count("*").alias("rides"), F.sum("fare").alias("revenue")))
# append: each window is written once, after the watermark passes its end
(counts.writeStream.outputMode("append")
.format("delta")
.option("checkpointLocation", "s3://bucket/chk/rides_per_city")
.trigger(processingTime="1 minute")
.start("s3://bucket/tables/rides_per_city"))
# python — updating results into Delta: foreachBatch + MERGE, idempotent per batch
from delta.tables import DeltaTable
def upsert(batch_df, batch_id):
target = DeltaTable.forPath(spark, "s3://bucket/tables/rides_per_city_live")
(target.alias("t")
.merge(batch_df.alias("s"),
"t.city = s.city AND t.window = s.window")
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute())
(counts.writeStream.outputMode("update")
.foreachBatch(upsert)
.option("checkpointLocation", "s3://bucket/chk/rides_per_city_live")
.start())
- Checkpoint 位置存放 offset、commit log 與狀態;刪掉它,或以不相容的方式改變查詢 (加入有狀態運算子、改變分組 key),就代表要從頭開始。
trigger(availableNow=True)(3.3+)處理目前所有可用的資料後就停止 —— 把串流程式碼 當成增量批次 job 來跑。
9-4) Kafka Streams:KStream 與 KTable
Kafka Streams 是函式庫,不是叢集:你的應用程式從 Kafka 讀取,把狀態放在以 changelog topic 為後盾的本地 RocksDB,再寫回 Kafka。
| 抽象 | 是什麼 | 例子 |
|---|---|---|
| KStream | 事件串流 —— 每筆紀錄都是一個新事實 | 點擊、付款 |
| KTable | changelog —— 每個 key 的最新值 | 使用者檔案、帳戶餘額 |
| GlobalKTable | 完整複製到每個實例的 KTable | 小型參考資料;不需 co-partitioning 就能 join |
// java — Kafka Streams 3.x: rides per city per minute, 30 s grace for late events
StreamsBuilder builder = new StreamsBuilder();
KStream<String, Ride> rides = builder.stream("rides", Consumed.with(Serdes.String(), rideSerde));
KTable<Windowed<String>, Long> perCity = rides
.groupBy((key, ride) -> ride.getCity(), Grouped.with(Serdes.String(), rideSerde))
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(1), Duration.ofSeconds(30)))
.count();
perCity.toStream()
.map((windowedCity, count) -> KeyValue.pair(
windowedCity.key() + "@" + windowedCity.window().start(), count))
.to("rides-per-city", Produced.with(Serdes.String(), Serdes.Long()));
- Join 需要 co-partitioning:兩個 topic 有相同的 key 與相同的 partition 數;重新指定
key(
groupBy、selectKey)會建立一個內部的 repartition topic。 processing.guarantee=exactly_once_v2用 Kafka transaction 做 read-process-write。- 多啟動幾個應用程式實例就能擴展;partition 會在它們之間重新平衡,被搬走的 task 從 changelog 還原狀態(standby replica 能縮短這段時間)。
9-5) Apache Beam 與 Dataflow
Beam 是一套程式設計模型 —— PCollection、視窗、trigger、watermark —— 跑在你選的 runner 上;大家說的通常是託管 runner Google Cloud Dataflow。
- 批次與串流共用一份 pipeline 定義;由 runner(Dataflow、Flink、Spark)執行。
- 它的模型把 what / where / when / how 這幾個問題講得最清楚(第 4-2 節)。
- Dataflow 加上自動擴縮、動態工作重新平衡,以及託管的 shuffle / streaming engine。
- 取捨:可移植性與託管服務,換來較少的調整旋鈕與各 runner 特有的缺口。
10) 串流 SQL 優先度 3/5 — 值得會 — 多半是必備模式的變形
10-1) Flink SQL 的視窗彙總
串流 SQL 先宣告帶有 watermark 的來源,再跑一個看起來很普通的查詢,其視窗來自 table-valued function。
-- Flink SQL 1.19+
CREATE TABLE rides (
ride_id STRING,
city STRING,
fare DECIMAL(10, 2),
event_time TIMESTAMP(3),
WATERMARK FOR event_time AS event_time - INTERVAL '10' SECOND
) WITH (
'connector' = 'kafka',
'topic' = 'rides',
'properties.bootstrap.servers' = 'broker:9092',
'properties.group.id' = 'rides-per-city',
'scan.startup.mode' = 'earliest-offset',
'format' = 'json'
);
CREATE TABLE rides_per_city (
window_start TIMESTAMP(3),
window_end TIMESTAMP(3),
city STRING,
rides BIGINT,
revenue DECIMAL(38, 2)
) WITH (
'connector' = 'kafka',
'topic' = 'rides-per-city',
'properties.bootstrap.servers' = 'broker:9092',
'format' = 'json'
);
INSERT INTO rides_per_city
SELECT window_start, window_end, city, COUNT(*) AS rides, SUM(fare) AS revenue
FROM TABLE(TUMBLE(TABLE rides, DESCRIPTOR(event_time), INTERVAL '1' MINUTES))
GROUP BY window_start, window_end, city;
TUMBLE、HOP、CUMULATE與SESSION(1.19+ 起為 TVF)就是視窗函式。- 視窗彙總是只會 append 的,所以一般的
kafkasink 就行;同一個查詢若沒有視窗, 就會產生更新,需要帶主鍵的upsert-kafka。
11) 提供即時結果 優先度 3/5 — 值得會 — 多半是必備模式的變形
11-1) 即時 OLAP 儲存
即時 OLAP 儲存直接從 Kafka 擷取,並在次秒級時間內回答彙總查詢,這正是建立在原始事件上 的即時 dashboard 所需要的。
| 儲存 | 強項 | 備註 |
|---|---|---|
| Apache Druid | 依時間分區的 segment、擷取時 rollup | 原生 Kafka / Kinesis 擷取;擅長時間序列的切片分析 |
| Apache Pinot | 低延遲、面向使用者的分析、upsert | star-tree 索引;LinkedIn 打造、Uber 使用 |
| ClickHouse | MergeTree 表上非常快的掃描 |
Kafka table engine + materialised view;也是通用分析資料庫 |
| StarRocks / Doris | 支援即時擷取與 join 的 MPP SQL | 相容 MySQL 協定 |
查詢模式固定時(一個 dashboard 方塊)在串流 job 裡先預彙總;使用者會依任意維度切片時, 把原始事件擷取進 OLAP 儲存。
11-2) 串流寫入 lakehouse 表
串流寫進 Iceberg、Delta 或 Hudi,讓串流資料有一個持久、可查詢、具 ACID 提交的家 —— 代價是分鐘級的新鮮度與小檔案問題。
- 每次提交(一個 Flink checkpoint、一個 Spark micro-batch)都會新增檔案;提交越頻繁小檔案
越多,所以要排程壓實(compaction)(Delta
OPTIMIZE、Icebergrewrite_data_files) 與 snapshot 過期清理。 - Upsert / CDC —— Hudi merge-on-read、Iceberg v2 列級刪除、Delta
MERGE,以及 Apache Paimon(為 Flink 串流 upsert 而生)依 key 套用變更。 - 提交間隔決定新鮮度:一分鐘一次的 checkpoint 代表資料大約在到達一分鐘後可以查詢。
- 這讓 Kappa 式的系統不需要另一份批次副本就能回答歷史問題。
12) 維運 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
12-1) 要監控什麼
| 指標 | 告訴你 | 何時告警 |
|---|---|---|
| Consumer lag(筆數 / 時間) | job 落後它的來源 | lag 持續成長 |
| 端到端延遲(事件時間 → sink) | 使用者感受到的新鮮度 | 超過 SLA |
| Watermark 延遲(現在 − watermark) | 事件時間落後多少 | 持續成長,或凍結(閒置來源) |
| Checkpoint 時間 / 大小 / 失敗 | 容錯機制的健康度 | 有失敗,或時間接近間隔 |
| 每個運算子的背壓 / 忙碌時間 | 瓶頸在哪 | 持續偏高 |
| 被丟棄的遲到紀錄 | 因 watermark 而損失的完整性 | 高於預期比例 |
| 重啟次數 | 當機循環 | 任何重啟風暴 |
| 狀態大小 | 無界成長 | 流量穩定但持續成長 |
12-2) 故障情境
| 故障 | 會發生什麼 | 緩解 |
|---|---|---|
| Task / 節點掛掉 | 從最後一個 checkpoint 重啟,重播那之後的資料 | checkpoint;備用 slot |
| 毒訊息(schema 錯誤、null) | job 在它上面當機、重啟、再當機 | 攔截錯誤、送到 dead-letter queue、繼續執行 |
| Sink 掛掉 | 背壓、lag 成長,checkpoint 可能逾時 | 帶退避的重試;Kafka 吸收積壓;對 lag 告警 |
| 停機期間來源 retention 過期 | 你從沒讀過的資料被刪除 | retention 比最糟的停機更長;封存到物件儲存 |
| Checkpoint 儲存無法使用 | checkpoint 失敗;之後的故障要重播更多 | 容忍 N 次 checkpoint 失敗,並告警 |
| 錯誤的部署寫出錯誤結果 | 下游拿到錯誤資料 | 部署前做 savepoint;從它或更早的 offset 重播到新的輸出 |
| Producer 時鐘偏差 | 未來的事件時間把 watermark 往前推,其他一切都變成遲到 | 夾住或拒絕超前太多的時間戳 |
12-3) 部署新版本與重新處理
- 相容的變更 —— 用 savepoint 停止、部署、從 savepoint 還原。穩定的運算子 uid 讓這件事 可行。
- 不相容的變更(新的彙總 key、新的狀態 schema)—— 讓新 job 和舊 job 並行,從較早的 offset 寫到一個新的輸出表 / topic,比對之後切換讀取者並淘汰舊 job。這就是 Kappa 的 重新處理手法。
- 回補 —— retention 涵蓋時從 Kafka 重播;否則把同一份邏輯當批次 job 跑在封存的原始資料上 (Flink 與 Spark 都能用批次模式跑這份程式碼)。
13) 實戰設計:每城市即時行程數 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
題目:「做一個 dashboard,顯示每個城市開始的行程數與營收,一分鐘內更新, 涵蓋過去 24 小時。」
先釐清:事件量、新鮮度 SLA、數字是否可以因遲到事件而修正、使用者會往回看多遠、 以及是否會依其他維度過濾。
rider / driver apps ─► API ─► Kafka "ride_events" (key = ride_id, Avro + Schema Registry)
│
▼
Flink job (event time, 30 s bounded out-of-orderness)
1. dedupe by event_id (24 h state TTL)
2. enrich with city from zone (broadcast state, small table)
3. 1-minute tumbling window per city: count, sum(fare)
4. allowed lateness 10 min → updated results keyed by (city, window_start)
│
┌────────────────────┼──────────────────────────┐
▼ ▼ ▼
upsert-kafka "city_minute" Pinot table (upsert on Iceberg table (raw events,
(other consumers) city + window_start) hourly compaction) → batch
│ reconciliation and history
▼
dashboard (queries Pinot)
要說出口的決策:
- 事件時間搭配 watermark,因為 app 事件會因為連線差而遲到。
- 結果以
(city, window_start)為 key 做 upsert,讓遲到事件修正某一分鐘,而不是產生 重複的資料列;dashboard 永遠讀到最新值。 - 寫到 Pinot 用 at-least-once + 冪等 upsert,而不是付出 exactly-once Kafka 輸出的交易延遲; 重播造成的重複會覆寫同一個 key。
- 依 producer 指定的
event_id去重,因為行動客戶端會重試。 - 原始事件寫進 Iceberg,作為歷史資料,並用每晚的批次對帳修正那些超過允許遲到時間才到的資料。
- 擴展:Kafka partition 依尖峰吞吐量規劃;視窗以城市為 key,如果某個城市佔大宗,就改用
(city, zone)為 key,在查詢時再彙總。 - 維運:對 consumer lag、watermark 延遲與 checkpoint 失敗告警;格式錯誤的事件送到 dead-letter。
14) 快問快答 優先度 3/5 — 值得會 — 多半是必備模式的變形
Q:Kafka 是串流處理器嗎? Kafka 是 log;Kafka Streams 與 ksqlDB 才是跑在它上面的處理器。Broker 本身只負責儲存與 提供紀錄。
Q:為什麼不用處理時間 —— 它比較簡單? 因為結果取決於 job 什麼時候跑:重播或落後的 partition 都會改變計數。處理時間適合營運訊號 (「最近一分鐘流量裡的錯誤」),不適合業務指標。
Q:如果某個 Kafka partition 沒有收到資料,視窗會怎樣? 它的 watermark 不會前進,運算子的最小 watermark 就卡住,視窗不再關閉 —— 除非那個來源 被標成閒置。
Q:Exactly-once 是指每個事件只被處理一次嗎? 不是。故障後事件可能被處理不只一次;exactly-once 指的是狀態與已提交的輸出對每個事件只 反映一次。
Q:要怎麼改變一個正在跑的有狀態 job 的彙總邏輯? 狀態相容就用 savepoint 再還原。不相容就讓新版本從較早的 offset 寫到新的輸出,驗證後再把 讀取者切過去。
Q:2 秒的 SLA,用 micro-batch 還是 continuous? 兩者都能達到;短 trigger 的 micro-batch 往往就夠了。SLA 是次秒級、或邏輯需要逐事件的計時器時, 才選逐筆處理。
Q:怎麼測試串流 job?
用測試 harness 對函式做單元測試(Flink KeyedOneInputStreamOperatorTestHarness、
Kafka Streams TopologyTestDriver),刻意餵入亂序與遲到的事件,再對嵌入式或容器化的
Kafka 跑整合測試。