總覽與定位
在無界資料流上做有狀態運算:事件什麼時候發生、視窗何時該關、狀態掛了怎麼恢復。
Kafka 篇把 Kafka 講成可重放的分散式日誌:一則紀錄的地址是 topic + partition + offset,消費者自己握游標。本篇假設那套心智模型已經在手上,不再重講 broker 內部、Retention 或 Rebalance。交付語意的定義(at-least-once、exactly-once 是什麼)在訊息佇列基礎篇;事件該帶多少欄位、Outbox、Schema 演進留給 Event-Driven Architecture。
串流處理要解的痛是另一件:資料沒有「全部到齊」的那一天。點擊、感測、交易、日誌會一直來,而且會亂序、會遲到、會在你以為結束之後又冒出來。你卻還是得每分鐘算一次「這個使用者買了多少」、每五分鐘關一扇窗、機器掛掉還能接著算——不能每次從 offset 0 重掃全歷史。
本篇談 exactly-once,只限這一層:Checkpoint 加上交易性 Sink,如何構成端到端的「效果一次」——不重述 broker 的冪等 PID,也不把消費端 handler 冪等再講一遍。
Kafka 給你可重放的輸入與可定址的游標;串流引擎在每個 key 上維持狀態、用 Watermark 決定何時輸出、用 Checkpoint 決定掛了從哪裡接。少了可重放日誌,Kappa 與「從 checkpoint 恢復」都少了一條腿。
Event time 與 Processing time
兩條時鐘對不齊的那一段,就是亂序與遲到的來源。
Event time 寫在紀錄裡:感測器量到的時刻、使用者按下按鈕的時刻、資料庫交易提交的時刻。Processing time 是算子機器的牆上時鐘:這則紀錄被這個 operator 看到的那一瞬。有些系統還標 Ingestion time——事件進入串流平台的時刻——作為折衷,本篇不把它當第三條獨立語意展開。
- 與機器負載無關,跨重放應得到同一條答案。
- 來源是 producer 或裝置時鐘,可能歪、可能缺。
- 視窗邊界、會話間隙、遲到判定都該用它。
- 實作簡單:用牆鐘關窗,不必 Watermark。
- 同一份 log 重放,答案會變——因為牆鐘不會倒帶。
- 適合「現在這分鐘的即時儀表」,不適合「那五分鐘的成交額」。
兩欄的差別只在第三點:event time 決定的是「窗邊界、會話間隙與遲到判定」,processing time 只決定「你何時看到」——重放同一份 log 時,只有左欄那條答案不會變。
落差從哪裡來
一則事件從發生到被算子看見,中間至少疊了這幾層延遲:裝置離線後一次回灌、producer 批次與 linger、broker 堆積、消費者 lag、反壓讓下游減速、重試把同一事實又送一次。裝置時鐘彼此不準,還會讓「先發生的事件」帶著比較晚的時間戳。於是你在 processing time 的 t 看到的,常常是 event time 散落在 t − Δ 附近的一堆紀錄——Δ 不是常數。
面試時若有人把「五分鐘視窗」講成 setInterval(5min),你要能指出:那是 processing-time window,重放不可復現,跨時區與跨機也不穩。要的是「事件時間落在 [10:00, 10:05) 的那些點擊」,就必須帶著 event time 走,並且承認它們不會依 event time 的順序抵達。
ingestion time 對「進站之後」是單調的,重放同一段 log 卻會得到新的 ingestion 戳記。它能避開裝置時鐘,但救不了可重現性。缺 event time 時它是降級,不是等價物。
Windowing 三型
Tumbling、Sliding、Session 的邊界怎麼切、一則事件會進幾扇窗、什麼條件觸發輸出。
無界流不能「等全部到齊再算」。Window 把時間軸切成有限區間,讓聚合有一個可以關閉的範圍。切法決定一則事件進幾扇窗,也決定狀態會長多大。
- 例如每 5 分鐘
[0,5)、[5,10)。 - 每則事件恰好進一扇窗。
- 狀態量 ≈ 並行 key 數 × 尚未關閉的窗數(通常是 1 加遲到緩衝)。
- 例如長度 6、步進 3:
[0,6)與[3,9)重疊。 - 一則事件進 size / slide 扇窗。
- 適合「最近 N 分鐘」這種移動指標;狀態隨重疊倍數漲。
- 同一 key 的事件若間距小於 gap 就合併。
- 窗的起訖由資料決定,不是牆上的整點。
- 適合「這次瀏覽會話」;長尾活躍 key 會把窗撐得很長。
先看每欄第二行的「一則事件進幾扇窗」:Tumbling 恰好 1 扇、Sliding 固定 size / slide 扇、Session 由資料決定——這個數字就是第三行狀態量估計的乘數。
邊界與指派
Tumbling 的起點通常對齊 epoch 的整數倍:event time t 落入
Sliding 則對每個 start = k × slide,只要 start ≤ t < start + size 就指派一扇。Session 沒有事先畫好的格子:把同一 key 的事件依 event time 排序,相鄰差距小於 gap 的收成一簇,窗為 [min_et, max_et + gap)。
| 事件 event time | Tumbling · size 5 | Sliding · size 6 / slide 3 | Session · gap 4 |
|---|---|---|---|
t=2 |
[0,5) |
[-3,3)、[0,6) |
[2,11) |
t=4 |
[0,5) |
[0,6)、[3,9) |
[2,11) |
t=7 |
[5,10) |
[3,9)、[6,12) |
[2,11) |
| 一則進幾扇 | 1 | 2(= size / slide) | 1(三則共用同一扇) |
同一批事件換切法就換答案:Tumbling 每則恰好一扇;Sliding 固定進 size/slide 扇(t=2 的其中一扇起點落在時間軸原點之前,仍算一扇);Session 看相鄰間距,2→4→7 的差都小於 4,三點併成 [2, 7+4)。狀態量跟著這張表的格子數走。
觸發條件
「窗裡有資料」不是「可以輸出」。觸發器決定何時把聚合結果送出去:
| 觸發 | 條件 | 適合 | 代價 |
|---|---|---|---|
| Processing-time | 牆鐘走到窗結束 | 即時儀表、對重放無要求 | 同一份 log 重算答案會變 |
| Event-time | Watermark ≥ 窗結束 | 可重放的業務指標 | 必須維護水位;亂序要等 |
| Count / Punctuation | 滿 N 筆或看到標記 | 批次對齊、測試 | 時間語意被筆數綁架 |
本篇後續預設 event-time 觸發:窗在 Watermark 越過結束點時第一次輸出,allowed lateness 內的遲到事件觸發更新。
Tumbling 答「每個整點區間的總和」;Sliding 答「任意時刻往回看 N」;Session 答「一次連續活動」。面試先講清楚一則事件進幾扇窗、狀態跟誰成正比,再報 Flink 的 TumblingEventTimeWindows 才像做過的人。
Watermark:把「要等多久」收成參數
水位線是流上的標點符號:它宣稱「event time 小於此值的事件,大概不會再來了」。
若堅持等「所有可能的遲到」,窗永遠關不了,狀態永遠佔著。Watermark 是一個啟發式標點:算子用它推進 event-time 時鐘。常見的週期水位:
out_of_orderness_bound 就是你願意等的亂序上界。設 0,等於假設事件按 event time 到達,遲到立刻被判晚。設太大,窗關得慢、狀態堆著、端到端延遲變差。它不是正確性證明,是延遲與完整度的旋鈕。
- 該窗第一次觸發,結果可以送給下游或 Sink。
- 之後抵達、event time 仍落在窗內的紀錄,叫做遲到。
- 多條輸入(多 partition、多 source)通常取 min:最慢的那條卡住全域水位。
- 某條 Kafka partition 暫時沒有資料,若不作 idle 標記,min 水位停住,所有窗都不關。
- 一則帶著未來時鐘的事件會把 max_et 拉飛,後面大量「正常」事件被判遲到。
- 這就是為什麼要在 source 側做 watermark strategy,而不是只在聚合算子上設 timeout。
右欄兩條是同一個 min 規則的兩種壞結局:一條空閒 partition 讓水位停住(窗永遠不關),一則帶著未來時鐘的事件讓水位跳飛(後面正常的事件全被判遲到)。
你已經知道 Kafka 的並行單位是 partition、順序只在單一 partition 內成立。對串流引擎來說,每條 partition 各自看到的 event time 進度可以差很遠。全域 Watermark 取最小進度,是為了不在「還沒讀到的那條 partition」上提前關窗。代價是:一條緩慢或空閒的 partition 拖住整條作業。
它說「我認為 W 以前的事件到齊了」。真的還有更早的事件來,系統必須另有遲到政策——丟棄、側輸出、或更新已發出的結果。把 Watermark 講成「數學上不會再有更早的事件」,是把啟發式講成不變式。
遲到資料:還能改、還是只能旁路
allowed lateness 決定狀態多活一會兒;側輸出、重算與更正決定遲到之後還能做什麼。
當 event_time < W,這則紀錄相對於水位是遲到的。窗可能仍在,也可能已被回收。三個旋鈕要分開講:
| 抵達時 | 窗的命運 | 主路徑 | 面試怎麼講 |
|---|---|---|---|
| et ≥ W | 尚未關 | 準時,納入聚合 | 正常路徑 |
| W ≥ 窗尾,且 W < 窗尾 + lateness | 已觸發、狀態仍在 | 遲到但准入,更新並再輸出 | 完整度換狀態存活時間 |
| W ≥ 窗尾 + lateness | 已回收 | 丟棄,或進 side output | 延遲有上限;剩下靠重放 |
「丟棄」是保護成功——你用狀態上限換確定的延遲,不是引擎算錯。需要事後更正,走側輸出加離線作業,或重放那段 changelog。
更新已發出的結果有兩種常見契約:retract(先撤銷舊值再發新值,下游必須能認撤回)與 update / upsert(同一 key 的新版本覆蓋舊版本,下游是表)。沒有這種契約,就不要在窗關閉後還承諾「數字會自己變對」。
每一分鐘的 lateness 都是每一個 key、每一扇還沒 GC 的窗繼續佔 State Backend。流量高、key 基數大時,這比把 Watermark 延遲加長更貴——延遲加長是「晚點關」,lateness 是「關了還留著」。兩者都該有監控:輸出延遲、遲到比率、狀態位元組。
互動 · 亂序事件、視窗與水位
拖動六則事件的 event time、Watermark 延遲與 allowed lateness,看同一序列在 Tumbling / Sliding / Session 下落進哪扇窗、誰被判遲到、誰只剩側輸出。
六則事件依 e1→e6 的到達順序一次送完(到達順序不可改,可改的是它們各自的 event time)。每一則的判定用的都是它送入前的 Watermark——這是這個模擬器最關鍵的一條規則,也是「同一組時間戳換一個 delay 就換一組答案」的來源。
—
四組值得停下來比對的設定:(1) 預設 (2, 7, 4, 13, 11, 3)、delay=3、lateness=3 的 Tumbling——最後一則 et=3 碰到已回收的 [0,5)(回收線 關@8,而最終 W=10),判定表最後一列因此寫著「太遲 → 側輸出」,主結果完整度 83.3%。那是延遲上限的保護,不是算錯。(2) lateness 拖到 6:[0,5) 的回收線變成 關@11,還沒被 W=10 追上,同一則 et=3 就轉為「遲到准入」,完整度回到 100%。(3) lateness 拖到 0:判定完全不變(仍是 5/0/1),但「開著的窗」少了一扇——lateness 動的是狀態要留多久,不一定動判定。(4) delay 拖到 0:水位一路貼著 max et 走,e3 與 e5 由準時轉為遲到准入——同一批時間戳、同一種切窗,只換一個參數就換一組答案。
Checkpoint 與 State Backend
本地狀態加上一致快照,才能在故障後接回同一條 event-time 時間線;端到端 exactly-once 停在「快照 + 交易性 Sink」。
視窗聚合、會話、join、去重,本質都是 keyed state:每個 key 在算子本地放一塊資料。這塊資料若只活在 process 記憶體裡,重啟就歸零,從 Kafka 的舊 offset 重拉也會「再算一次」——若 Sink 不認得重複,效果就不是一次。
- 對齊的是:每個 operator 的狀態 + 輸入的消費位點(你熟悉的 partition offset)。
- Barrier 順著資料流走,把「這之前」與「這之後」切開,避免快照看到一半的窗。
- 恢復 = 載入最近成功快照,從快照裡的 offset 繼續拉。
- Heap:快、受 JVM 限制,適合小狀態。
- 本地嵌入式 LSM(如 RocksDB):熱資料在塊快取,其餘在本地磁碟,快照可增量。
- 遠端持久層收的是快照檔,不是每筆更新都同步寫遠端。
左欄看「快照對齊了什麼」——狀態與消費位點是成對存下來的;右欄只決定狀態放哪。故障後能接回同一條 event-time 時間線,靠的是左欄那個成對關係,不是右欄的儲存選擇。
狀態隨時間膨脹
每個未關閉的窗、每個還沒過 TTL 的 key、每一份 allowed lateness 緩衝,都在 State Backend 裡佔位。Session 窗對活躍使用者尤其危險:gap 設太寬,一次會話能跨數小時。治理手段要講得出來:TTL / 狀態過期、按 key group 拆並行、增量 checkpoint(只上傳有髒頁的部分)、以及「不要用 sliding 重疊倍數換一個用 tumbling 就能答的問題」。
端到端 exactly-once 只講到這一層
訊息佇列基礎篇已經定義過交付語意。串流作業要的「效果一次」不是再定義一次,而是兩段對齊:
交易性 Sink(例如對 Kafka 用交易性寫入,或對支援兩階段提交的倉儲)把「輸出可見」與「checkpoint 成功」綁在同一筆決策:checkpoint 失敗則輸出不提交,恢復後同一批會再寫但下游看不到重複。Sink 若不參與這筆交易,你最多拿到「計算側 exactly-once、輸出側 at-least-once」——重複要靠下游冪等消化。冪等 PID、消費端去重表,不在本篇重講。
間隔短,丟的進行中狀態少,但 barrier 與上傳更頻繁。間隔長,恢復時要從更舊的 offset 重放更多輸入。端到端保證看的是「成功的那一次 checkpoint 與 Sink 是否同生共死」,不是間隔設 10 秒就比較 exactly-once。
Stream–Table Duality
表是每個 key 的現況;流是這張表的 changelog。兩者是同一份事實的兩種投影。
一串「使用者餘額變更」事件,摺疊到每個 user_id 的最後值,就是一張表。把表上每一次 upsert / delete 再攤開,又變回 changelog 流。這不是兩套系統,是同一份狀態的兩種讀法:
+A=15
+B=3
B=3
B=3
摺過去再攤回來,A 只剩最後一版 15——中間的 10 消失不是 duality 的 bug,是你把事實流當成了現況表。方向由箭頭承載,toTable 與 toStream 是動作,不是資料。
- 聚合、去重、join 的右表,本質都是「這個 key 現在是什麼」。
- 遲到更新 = 對該 key 再寫一版;下游若是表,用 upsert 覆蓋即可。
- tombstone(value = null)刪除 key,對應表上的 DELETE。
- 任何表的變更都可以再以事件形式送出,供下游作業訂閱。
- 你已經在 Kafka 篇看過 compaction:broker 側每 key 留最後一筆,正是 changelog 的儲存形狀。
- 本篇不重講 compaction 參數;重點是引擎裡的表與 log 上的 compacted topic 是同一種 duality。
兩欄的箭頭方向相反,但資料是同一份。看左欄第二行與右欄第一行:遲到更新之所以能「改掉已經發出的數字」,前提是下游被當成表(可 upsert)而不是當成流。
面試好用的對照:事實事件(訂單已成立)應該保持為 append-only 流,用 Retention 留歷史;現況(購物車內容、帳號狀態)才摺成表、用 changelog 傳播。把事實流摺成表再攤回去,中間版本會消失——那不是 duality 的 bug,是你選錯了投影。
每扇窗、每個 key 輸出的聚合,可以看成 (key, window_end) → value 這張表的 upsert。遲到更正就是對同一列再寫一版。這樣講,side output 與 retract 才有座標:前者是「這列已經被 drop 了」,後者是「這列還在,但舊版本要撤回」。
Lambda、Kappa 與面試折衷
批流一體不是口號:你要兩套程式合併結果,還是一套程式靠重放校正。
- Batch 層用完整歷史算出「正確」視圖;Speed 層用串流補低延遲增量。
- Serving 層把兩份結果合併——這合併本身就是一組一致性問題。
- 痛:同一套業務邏輯寫兩次,口徑容易漂;好處是批層可用任意重算工具。
- 只有一條處理路徑。發現邏輯錯了,把作業從舊 offset 重跑,覆蓋下游表。
- 前提是可重放日誌(Kafka 那篇的模型)加上可重置的 Sink。
- 痛:重放要算力與時間;狀態遷移、Schema 對不上時,不是按一下 rewind 就結束。
兩欄的痛點都在第三行:Lambda 付的是「同一套業務邏輯寫兩次、口徑會漂」,Kappa 付的是「重放的算力、狀態遷移與 Schema 對不上」。選擇題不是哪個優雅,而是你付得起哪一種。
Kappa 並沒有取消「批」這件工作——它把批定義成「用同一套算子,從更早的 offset 再走一次」。Lambda 則承認批與流的執行引擎可以不同(MapReduce / Spark 對 Flink),用工程換兩套口徑。選哪邊,問三個問題:邏輯能不能只維護一份、下游能不能被重寫、重放窗口的磁碟你買不買得起。
| 面試常問 | 先答這句 | 不要滑去 |
|---|---|---|
| Event time 跟 Processing time 差在哪? | 事實何時發生 vs 算子何時看到;亂序是落差,不是故障 | 用牆鐘 setInterval 冒充業務視窗 |
| 三種 Window 怎麼選? | 整點區間用 Tumbling;「最近 N」用 Sliding;連續活動用 Session | 先報框架 class 名,卻說不清一則事件進幾扇窗 |
| Watermark 是什麼? | 啟發式標點:max et 減亂序上界;多源通常取 min | 講成「之後絕不會有更早的事件」 |
| 遲到怎麼辦? | lateness 內更新;再晚丟棄或側輸出;要改舊結果需 retract/重放 | 把 lateness 開無限大當正確性方案 |
| 作業掛了怎麼恢復? | 載入 checkpoint(狀態 + 輸入 offset)從位點續跑 | 重講 broker Retention,或把 heap dump 當恢復策略 |
| 串流的 exactly-once? | Checkpoint 與交易性 Sink 同生共死;本篇不重定義交付語意 | 把它講成 Kafka acks=all,或消費端去重表 |
| Stream 和 Table 什麼關係? | 表是每 key 現況,流是 changelog;兩者可互轉 | 事件建模風格、Outbox、Schema Registry |
| Lambda 還是 Kappa? | 兩套程式加合併,或一套程式加可重放日誌 | 宣稱 Kappa 讓批處理從此消失 |
下一站:用事件重劃服務邊界與 Outbox,見 Event-Driven Architecture;把資料庫日誌變成事件流,見 CDC。Broker 內部與交付語意定義請回 Kafka 與訊息佇列基礎,不要在本篇展開。
Watermark 延遲買完整度、賣結果何時能見。Allowed lateness 買關窗後的更正、賣 State Backend。Sliding 重疊買平滑指標、賣每事件的狀態倍數。Checkpoint 間隔買較小的重放、賣對齊成本。講得出「誰承擔後果」,比背 Flink 預設值更像做過線上作業的人。