01

總覽與定位

在無界資料流上做有狀態運算:事件什麼時候發生、視窗何時該關、狀態掛了怎麼恢復。

Kafka 篇把 Kafka 講成可重放的分散式日誌:一則紀錄的地址是 topic + partition + offset,消費者自己握游標。本篇假設那套心智模型已經在手上,不再重講 broker 內部、Retention 或 Rebalance。交付語意的定義(at-least-once、exactly-once 是什麼)在訊息佇列基礎篇;事件該帶多少欄位、Outbox、Schema 演進留給 Event-Driven Architecture。

串流處理要解的痛是另一件:資料沒有「全部到齊」的那一天。點擊、感測、交易、日誌會一直來,而且會亂序、會遲到、會在你以為結束之後又冒出來。你卻還是得每分鐘算一次「這個使用者買了多少」、每五分鐘關一扇窗、機器掛掉還能接著算——不能每次從 offset 0 重掃全歷史。

1 · 時間軸
Event time · Processing time
事實發生的時刻與算子看到它的時刻不是同一條線。亂序與遲到都從這條落差長出來。
2 · 切窗與水位
Window · Watermark · Lateness
把無界流切成可關閉的區間;Watermark 把「要等多久」收成參數;遲到走側輸出或更正。
3 · 狀態與批流
Checkpoint · Duality · Kappa
本地狀態加快照換故障恢復;表與 changelog 互轉;Lambda / Kappa 決定你要幾套程式。

本篇談 exactly-once,只限這一層:Checkpoint 加上交易性 Sink,如何構成端到端的「效果一次」——不重述 broker 的冪等 PID,也不把消費端 handler 冪等再講一遍。

座標串流處理站在日誌之上,不是另一種訊息佇列

Kafka 給你可重放的輸入與可定址的游標;串流引擎在每個 key 上維持狀態、用 Watermark 決定何時輸出、用 Checkpoint 決定掛了從哪裡接。少了可重放日誌,Kappa 與「從 checkpoint 恢復」都少了一條腿。

一句話答法串流處理是在無界、可能亂序的事件流上做有狀態運算;時間語意、視窗關閉與狀態恢復是三件必須分開答的事。
02

Event time 與 Processing time

兩條時鐘對不齊的那一段,就是亂序與遲到的來源。

Event time 寫在紀錄裡:感測器量到的時刻、使用者按下按鈕的時刻、資料庫交易提交的時刻。Processing time 是算子機器的牆上時鐘:這則紀錄被這個 operator 看到的那一瞬。有些系統還標 Ingestion time——事件進入串流平台的時刻——作為折衷,本篇不把它當第三條獨立語意展開。

Event time
事實何時發生
  • 與機器負載無關,跨重放應得到同一條答案。
  • 來源是 producer 或裝置時鐘,可能歪、可能缺。
  • 視窗邊界、會話間隙、遲到判定都該用它。
Processing time
算子何時看到
  • 實作簡單:用牆鐘關窗,不必 Watermark。
  • 同一份 log 重放,答案會變——因為牆鐘不會倒帶。
  • 適合「現在這分鐘的即時儀表」,不適合「那五分鐘的成交額」。

兩欄的差別只在第三點:event time 決定的是「窗邊界、會話間隙與遲到判定」,processing time 只決定「你何時看到」——重放同一份 log 時,只有左欄那條答案不會變。

落差從哪裡來

一則事件從發生到被算子看見,中間至少疊了這幾層延遲:裝置離線後一次回灌、producer 批次與 linger、broker 堆積、消費者 lag、反壓讓下游減速、重試把同一事實又送一次。裝置時鐘彼此不準,還會讓「先發生的事件」帶著比較晚的時間戳。於是你在 processing time 的 t 看到的,常常是 event time 散落在 t − Δ 附近的一堆紀錄——Δ 不是常數。

processing_time(e) − event_time(e) = 路徑延遲 + 時鐘偏差 + 重試與回灌

面試時若有人把「五分鐘視窗」講成 setInterval(5min),你要能指出:那是 processing-time window,重放不可復現,跨時區與跨機也不穩。要的是「事件時間落在 [10:00, 10:05) 的那些點擊」,就必須帶著 event time 走,並且承認它們不會依 event time 的順序抵達。

陷阱用 ingestion time 假裝有 event time

ingestion time 對「進站之後」是單調的,重放同一段 log 卻會得到新的 ingestion 戳記。它能避開裝置時鐘,但救不了可重現性。缺 event time 時它是降級,不是等價物。

一句話答法Event time 是事實發生的時刻,Processing time 是算子看見的時刻;亂序與遲到是這兩條時鐘的落差,不是 bug。
03

Windowing 三型

Tumbling、Sliding、Session 的邊界怎麼切、一則事件會進幾扇窗、什麼條件觸發輸出。

無界流不能「等全部到齊再算」。Window 把時間軸切成有限區間,讓聚合有一個可以關閉的範圍。切法決定一則事件進幾扇窗,也決定狀態會長多大。

Tumbling
固定長度 · 不重疊
  • 例如每 5 分鐘 [0,5)、[5,10)。
  • 每則事件恰好進一扇窗。
  • 狀態量 ≈ 並行 key 數 × 尚未關閉的窗數(通常是 1 加遲到緩衝)。
Sliding
長度 + 步進 · 可重疊
  • 例如長度 6、步進 3:[0,6) 與 [3,9) 重疊。
  • 一則事件進 size / slide 扇窗。
  • 適合「最近 N 分鐘」這種移動指標;狀態隨重疊倍數漲。
Session
資料驅動 · 閒置間隙
  • 同一 key 的事件若間距小於 gap 就合併。
  • 窗的起訖由資料決定,不是牆上的整點。
  • 適合「這次瀏覽會話」;長尾活躍 key 會把窗撐得很長。

先看每欄第二行的「一則事件進幾扇窗」:Tumbling 恰好 1 扇、Sliding 固定 size / slide 扇、Session 由資料決定——這個數字就是第三行狀態量估計的乘數。

邊界與指派

Tumbling 的起點通常對齊 epoch 的整數倍:event time t 落入

[ floor(t / size) × size , floor(t / size) × size + size )

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 內的遲到事件觸發更新。

關鍵先選切法,再談框架 API

Tumbling 答「每個整點區間的總和」;Sliding 答「任意時刻往回看 N」;Session 答「一次連續活動」。面試先講清楚一則事件進幾扇窗、狀態跟誰成正比,再報 Flink 的 TumblingEventTimeWindows 才像做過的人。

一句話答法Tumbling 不重疊、每事件一窗;Sliding 重疊、每事件進 size/slide 扇;Session 用閒置間隙合併。輸出觸發預設看 Watermark 是否越過窗尾。
04

Watermark:把「要等多久」收成參數

水位線是流上的標點符號:它宣稱「event time 小於此值的事件,大概不會再來了」。

若堅持等「所有可能的遲到」,窗永遠關不了,狀態永遠佔著。Watermark 是一個啟發式標點:算子用它推進 event-time 時鐘。常見的週期水位:

W = max(event_time seen) − out_of_orderness_bound

out_of_orderness_bound 就是你願意等的亂序上界。設 0,等於假設事件按 event time 到達,遲到立刻被判晚。設太大,窗關得慢、狀態堆著、端到端延遲變差。它不是正確性證明,是延遲與完整度的旋鈕。

水位推進之後
W ≥ window_end
  • 該窗第一次觸發,結果可以送給下游或 Sink。
  • 之後抵達、event time 仍落在窗內的紀錄,叫做遲到。
  • 多條輸入(多 partition、多 source)通常取 min:最慢的那條卡住全域水位。
水位卡住
idle partition · 壞時鐘
  • 某條 Kafka partition 暫時沒有資料,若不作 idle 標記,min 水位停住,所有窗都不關。
  • 一則帶著未來時鐘的事件會把 max_et 拉飛,後面大量「正常」事件被判遲到。
  • 這就是為什麼要在 source 側做 watermark strategy,而不是只在聚合算子上設 timeout。

右欄兩條是同一個 min 規則的兩種壞結局:一條空閒 partition 讓水位停住(窗永遠不關),一則帶著未來時鐘的事件讓水位跳飛(後面正常的事件全被判遲到)。

你已經知道 Kafka 的並行單位是 partition、順序只在單一 partition 內成立。對串流引擎來說,每條 partition 各自看到的 event time 進度可以差很遠。全域 Watermark 取最小進度,是為了不在「還沒讀到的那條 partition」上提前關窗。代價是:一條緩慢或空閒的 partition 拖住整條作業。

關鍵Watermark 是契約,不是保證

它說「我認為 W 以前的事件到齊了」。真的還有更早的事件來,系統必須另有遲到政策——丟棄、側輸出、或更新已發出的結果。把 Watermark 講成「數學上不會再有更早的事件」,是把啟發式講成不變式。

一句話答法Watermark = 目前見過的最大 event time 減去亂序上界;它把關窗等待收成可調參數,多源時通常取最慢的那條。
05

遲到資料:還能改、還是只能旁路

allowed lateness 決定狀態多活一會兒;側輸出、重算與更正決定遲到之後還能做什麼。

當 event_time < W,這則紀錄相對於水位是遲到的。窗可能仍在,也可能已被回收。三個旋鈕要分開講:

Allowed lateness
window_end + lateness
水位越過窗尾之後,狀態再留一段時間。遲到事件仍可更新聚合,並再次觸發輸出。過了這條線,窗被 GC。
Side output
太遲的旁路
窗已回收後抵達的事件,不進主結果,而進側流。下游可告警、落盤、或觸發離線重算。
更正 / 重算
retract · update · replay
已發出的結果若被遲到事件改寫,要送 retractions 或新版本。改不動就重放那段 log。
抵達時 窗的命運 主路徑 面試怎麼講
et ≥ W 尚未關 準時,納入聚合 正常路徑
W ≥ 窗尾,且 W < 窗尾 + lateness 已觸發、狀態仍在 遲到但准入,更新並再輸出 完整度換狀態存活時間
W ≥ 窗尾 + lateness 已回收 丟棄,或進 side output 延遲有上限;剩下靠重放

「丟棄」是保護成功——你用狀態上限換確定的延遲,不是引擎算錯。需要事後更正,走側輸出加離線作業,或重放那段 changelog。

更新已發出的結果有兩種常見契約:retract(先撤銷舊值再發新值,下游必須能認撤回)與 update / upsert(同一 key 的新版本覆蓋舊版本,下游是表)。沒有這種契約,就不要在窗關閉後還承諾「數字會自己變對」。

陷阱把 allowed lateness 開到「足夠大就等於正確」

每一分鐘的 lateness 都是每一個 key、每一扇還沒 GC 的窗繼續佔 State Backend。流量高、key 基數大時,這比把 Watermark 延遲加長更貴——延遲加長是「晚點關」,lateness 是「關了還留著」。兩者都該有監控:輸出延遲、遲到比率、狀態位元組。

一句話答法準時進窗;水位過窗尾但未過 lateness 就更新;再晚就丟棄或側輸出。已發出的數字要改,必須有 retract/upsert 契約或重放。
06

互動 · 亂序事件、視窗與水位

拖動六則事件的 event time、Watermark 延遲與 allowed lateness,看同一序列在 Tumbling / Sliding / Session 下落進哪扇窗、誰被判遲到、誰只剩側輸出。

六則事件依 e1→e6 的到達順序一次送完(到達順序不可改,可改的是它們各自的 event time)。每一則的判定用的都是它送入前的 Watermark——這是這個模擬器最關鍵的一條規則,也是「同一組時間戳換一個 delay 就換一組答案」的來源。

互動 · Window × Watermark 任何一個旋鈕一動,六則全部重算

—

事件時間戳(拖動 = 改 event time,到達順序不變)
觀測(全部由上面的旋鈕算出)
準時納入 遲到但窗仍在 太遲 / 側輸出
Event-time 軸 · 視窗帶 · 水位線
視窗與回收線(刪除線 = 已回收)
側輸出 / 丟棄匣
尚無太遲事件
逐事件判定(判定用的是「送入前 W」)
—

四組值得停下來比對的設定:(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 由準時轉為遲到准入——同一批時間戳、同一種切窗,只換一個參數就換一組答案。

一句話答法同一批亂序事件,切窗規則決定落入哪裡;Watermark 延遲與 lateness 決定誰還能改結果、誰只能走側輸出。
07

Checkpoint 與 State Backend

本地狀態加上一致快照,才能在故障後接回同一條 event-time 時間線;端到端 exactly-once 停在「快照 + 交易性 Sink」。

視窗聚合、會話、join、去重,本質都是 keyed state:每個 key 在算子本地放一塊資料。這塊資料若只活在 process 記憶體裡,重啟就歸零,從 Kafka 的舊 offset 重拉也會「再算一次」——若 Sink 不認得重複,效果就不是一次。

Checkpoint
一致快照
  • 對齊的是:每個 operator 的狀態 + 輸入的消費位點(你熟悉的 partition offset)。
  • Barrier 順著資料流走,把「這之前」與「這之後」切開,避免快照看到一半的窗。
  • 恢復 = 載入最近成功快照,從快照裡的 offset 繼續拉。
State Backend
狀態放哪、怎麼長大
  • Heap:快、受 JVM 限制,適合小狀態。
  • 本地嵌入式 LSM(如 RocksDB):熱資料在塊快取,其餘在本地磁碟,快照可增量。
  • 遠端持久層收的是快照檔,不是每筆更新都同步寫遠端。

左欄看「快照對齊了什麼」——狀態與消費位點是成對存下來的;右欄只決定狀態放哪。故障後能接回同一條 event-time 時間線,靠的是左欄那個成對關係,不是右欄的儲存選擇。

狀態隨時間膨脹

每個未關閉的窗、每個還沒過 TTL 的 key、每一份 allowed lateness 緩衝,都在 State Backend 裡佔位。Session 窗對活躍使用者尤其危險:gap 設太寬,一次會話能跨數小時。治理手段要講得出來:TTL / 狀態過期、按 key group 拆並行、增量 checkpoint(只上傳有髒頁的部分)、以及「不要用 sliding 重疊倍數換一個用 tumbling 就能答的問題」。

端到端 exactly-once 只講到這一層

訊息佇列基礎篇已經定義過交付語意。串流作業要的「效果一次」不是再定義一次,而是兩段對齊:

恢復用的輸入 offset ≡ 快照裡的狀態 ≡ Sink 已提交的那批輸出

交易性 Sink(例如對 Kafka 用交易性寫入,或對支援兩階段提交的倉儲)把「輸出可見」與「checkpoint 成功」綁在同一筆決策:checkpoint 失敗則輸出不提交,恢復後同一批會再寫但下游看不到重複。Sink 若不參與這筆交易,你最多拿到「計算側 exactly-once、輸出側 at-least-once」——重複要靠下游冪等消化。冪等 PID、消費端去重表,不在本篇重講。

關鍵Checkpoint 間隔是 RPO,不是正確性開關

間隔短,丟的進行中狀態少,但 barrier 與上傳更頻繁。間隔長,恢復時要從更舊的 offset 重放更多輸入。端到端保證看的是「成功的那一次 checkpoint 與 Sink 是否同生共死」,不是間隔設 10 秒就比較 exactly-once。

一句話答法Checkpoint 對齊狀態與輸入 offset;State Backend 決定狀態放記憶體還是本地 LSM。端到端 exactly-once = 這份快照加上交易性 Sink,不是另一種 broker acks。
08

Stream–Table Duality

表是每個 key 的現況;流是這張表的 changelog。兩者是同一份事實的兩種投影。

一串「使用者餘額變更」事件,摺疊到每個 user_id 的最後值,就是一張表。把表上每一次 upsert / delete 再攤開,又變回 changelog 流。這不是兩套系統,是同一份狀態的兩種讀法:

changelog 流
依到達序 append
+A=10
+A=15
+B=3
──toTable──▸
表 · 每 key 現況
同 key 後寫覆蓋前寫
A=15
B=3
──toStream──▸
changelog 流
表上每次 upsert / delete
A=15
B=3

摺過去再攤回來,A 只剩最後一版 15——中間的 10 消失不是 duality 的 bug,是你把事實流當成了現況表。方向由箭頭承載,toTable 與 toStream 是動作,不是資料。

Stream → Table
changelog 摺成現況
  • 聚合、去重、join 的右表,本質都是「這個 key 現在是什麼」。
  • 遲到更新 = 對該 key 再寫一版;下游若是表,用 upsert 覆蓋即可。
  • tombstone(value = null)刪除 key,對應表上的 DELETE。
Table → Stream
現況攤成 changelog
  • 任何表的變更都可以再以事件形式送出,供下游作業訂閱。
  • 你已經在 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 了」,後者是「這列還在,但舊版本要撤回」。

一句話答法表是每 key 現況,流是它的 changelog;compaction 是這份 duality 在 log 上的形狀。事實留流,現況留表。
09

Lambda、Kappa 與面試折衷

批流一體不是口號:你要兩套程式合併結果,還是一套程式靠重放校正。

Lambda
Batch + Speed + Serving
  • Batch 層用完整歷史算出「正確」視圖;Speed 層用串流補低延遲增量。
  • Serving 層把兩份結果合併——這合併本身就是一組一致性問題。
  • 痛:同一套業務邏輯寫兩次,口徑容易漂;好處是批層可用任意重算工具。
Kappa
單一串流 + 重放
  • 只有一條處理路徑。發現邏輯錯了,把作業從舊 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 預設值更像做過線上作業的人。

一句話答法時間語意用 event time,關窗用 Watermark,恢復用 checkpoint,批流一體用「幾套程式、能不能重放」來選 Lambda 或 Kappa。