Chapter 10. Stream Processing Engine: The Birth of Stream Processing
《給學弟妹的矽谷 AI / Software 指南》是一個矽谷工程師自發的眾籌寫作計劃,由源來適你社群推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 >10 篇準備發布,目標 100 篇並出書,歡迎 tag 強者大神來寫!
上一章開始,我們正式進入了 Stream 的世界。
我曾這樣介紹:
進入 Data Lake 時代後,可透過 streaming 的方式即時傳輸資料。不再等待深夜批次處理,而是讓資料持續流動,即時同步到目標系統。若採用這種方式,Kafka 就成為不可或缺的工具 — — 它為即時資料傳輸搭起橋樑。
Kafka 解決的是一個關鍵問題:資料如何持續流動。但資料只是流動,本身並沒有意義。
Store 的下游,必須接上 Processing Engine,才有意思。Kafka 作為一個 Stream Store,它的下游可以有兩種典型使用方式:
- 每小時讀取一批資料,交由 Batch Job 分析;
- 或交由 Stream Processing Engine,即時處理。
前者仍然停留在「資料累積後再處理」的邏輯;後者則嘗試處理「正在發生的事情」。這兩者看似只是延遲差異,實則代表兩種完全不同的計算哲學。
在本書鋪陳的結構中:
- 在 Data Source 端,我已詳細介紹了 Database;
- 在 Data Destination 端,我介紹了 Data Warehouse、Data Lake,並初步窺探了 Lakehouse;
- 在中間件層,我介紹了 Kafka,這個承載資料流動的橋樑。
熟悉我鋪排的讀者應該不難猜到 — — 既然我們已經理解了資料如何產生、如何儲存、如何流動,那麼接下來自然要問:
如何計算正在流動的資料?
本章,我們將正式進入 Kafka 下游的核心 — — Stream Processing Engine。
我們會從 2010 年 Yahoo 推出的 S4 開始。那是一個仍然粗糙、卻首次將 streaming 作為一級公民的系統原型。緊接著是 Twitter 的 Storm,它將 streaming 帶入 production,證明這種模式可以在現實世界運作。
在這一章,讀者可以用一種相對輕鬆的心情來閱讀。S4 與 Storm 開啟了一條新的方向,是過渡階段的探索,而非最終答案。這條路最終會引領到集大成的系統 — — Flink,因此下一章更適合在精神飽滿時細細閱讀。
到這裡讀者或許有疑問:既然 S4 與 Storm 都不是最終解,那為何還需要花篇幅理解它們?
一個技術,從來都不是憑空出現的。它背後總有一些人,在某個時間點、某個環境裡,面對具體的問題與限制,做出了一些選擇。也許是某個工程師在深夜 debug 系統時發現的瓶頸,也許是一家公司在規模成長後無法再承受既有架構的壓力。
當我們試著把這些「人、事、時、地、物」拼湊起來時,S4、Storm,甚至後來的 Flink,就不再只是幾個系統名稱,而是同一條演化路徑上的不同節點。S4 回應了 batch 無法即時處理的問題;Storm 則進一步回應了可靠傳遞的需求;而 Flink,則是在三個懸而未解的問題之上,試圖給出更完整的答案。
也正是在這樣的演進脈絡中,我們才開始真正理解,Kafka 與 Streaming Processing Engine 各自扮演的角色 — — Kafka 讓資料流動起來;Streaming Processing Engine 則讓這條河五顏六色。
之所以五顏六色,不是因為工業污染,而是因為 Stream Processing Engine 能在資料流動的瞬間,重新塑形每一筆資料。它可以即時聚合、即時過濾、即時轉換,讓資料在尚未沉澱為靜態儲存之前,就已經呈現出新的樣貌。
在這個世界裡,資料在流動之中持續被理解、被修正、被更新。河流不再單調,而是一條會隨時間改變顏色的動態結構。
S4(2010)
Introduction
2010 年,Yahoo 推出了 S4(Simple Scalable Streaming System)。在 MapReduce 仍然主導資料處理思維的時代,S4 嘗試回答一個不同的問題:如果資料不是靜態地躺在儲存系統裡,而是持續流入系統,我們是否能以長期運行的方式,即時處理這些資料,而不再等待批次作業?
S4 是最早公開的分散式 Stream Processing Engine 之一。它的核心思想很簡單卻具有前瞻性:資料以事件(event)的形式持續流動,而系統中的處理單元則長期存在,隨著事件到來即時更新內部狀態並產生新的事件。
在這個設計中,資料不再是等待被讀取的檔案,而是觸發計算的動態訊號。
Definition
Event
在 S4 中,系統的基本資料單位稱為 event。官方將其定義為不可變(immutable)的資料記錄。Event 在系統中流動,並可能包含一個 key,是整個擴展模型的核心:系統透過對 key 進行雜湊(hash),決定該 event 應被送往哪個節點。
因此,key-based routing 是 S4 水平擴展能力的基礎。
Processing Element (PE)
S4 的核心抽象是 Processing Element(PE)。PE 是一個可維持內部狀態的處理單元。它長期存在,持續接收 event,更新自身狀態,並可能產生新的 event。與批次系統不同,PE 並不是一次性執行,而是常駐於系統中的狀態機。
Per-Key Stateful Computation
在 S4 的模型中,每一個 key 對應一個 PE instance。相同 key 的 event 會被路由到同一個 PE,使得該 PE 可以維護與該 key 相關的內部狀態。
這種設計等同於一種 per-key stateful computation。每個 key 都擁有自己的狀態空間,形成一個小型的獨立狀態機。這種 per-key 分割機制,使得系統既可擴展,又能維持狀態一致性(在單一節點範圍內)。
Stream
Event 在 PE 之間以 stream 的形式傳遞。官方將 stream 定義為 event 的序列。然而,S4 並未建立嚴格的時間語意模型。Event 可以攜帶時間資訊,但系統本身並未制度化 event time、watermark 或 window 等概念。
這意味著時間相關的邏輯必須由應用程式自行實作。
Application Graph
一個 S4 應用由多個 Processing Element(PE)所組成,這些 PE 透過 stream 相互連接,形成一個持續運行的處理圖。與後來更形式化的拓撲模型相比,S4 的應用圖相對簡單,但其核心思想已十分清晰:計算不再是一次性的批次執行,而是一個長期存在的狀態轉移過程。
從結構上來看,這樣的處理圖在實務上常被畫成 DAG(Directed Acyclic Graph),資料沿著節點之間的邊持續流動。然而,將 S4 僅僅理解為一個 DAG,容易忽略它更本質的設計。
S4 的官方模型是一種 event-driven graph。每個 PE 接收 event、更新內部狀態,並產生新的 event,再透過 key-based routing 傳遞給下一個 PE。這種運作方式,使得整個系統更接近 Actor model 與 message passing 的組合,而非一個靜態的計算圖。
因此,S4 的重點在於兩個核心機制:
- 以 key 為中心的 state 管理,讓每個 PE 都能持續記住並更新自己的狀態。
- 同時透過 event 在系統中流動,讓計算一步一步往前推進。
Use Case: Top K Word Count in S4
這張 Application Graph 展示了一個典型的 S4 應用:在整個系統中持續計算出現次數最多的前 K 個單詞。這不是一次性的批次運算,而是一個隨資料流動而不斷更新的狀態問題。資料持續進來,計數持續改變,Top K 也隨時間演化。整個流程被組織為一個由 Processing Element(PE)構成的有向圖。
在圖中,我們看到 PE1 到 PE8 這樣的標記,它們只是用來表示在分散式環境中被複製與部署的多個 PE instance。讀者真正必須關心的是每一層 PE 的角色與它們之間的連接關係。
例如,最上層的 QuoteSplitterPE 負責接收原始句子,將其拆分成單詞;中間一層的 WordCountPE 依據 key(單詞)維護各自的計數;再往下的 SortPE 則對局部資料進行排序;最後由 MergePE 將多個局部結果整合為全域的 Top K。這些邏輯上的角色,才是應用圖的核心。
至於像 PE2、PE3、PE4 這樣的節點,代表的是同一種類型的 PE 被平行部署多個 instance。這種 fan-out(平行度)通常是由開發者在設定中決定的,用來分散負載。在 S4 的模型中,這個數量並不會隨系統自動調整,而是需要在部署時事先配置,因此某種程度上是「寫死」的。系統會根據 event 的 key 做 hash,將資料路由到對應的 PE instance,確保相同 key 的資料會被送到同一個節點。
如果從實務角度來看,使用 S4 開發一個應用,大致可以分為三個步驟。
第一步,定義資料的流動方式,也就是設計 event 的結構與 key。key 的選擇會直接影響狀態如何被切分與分散。
第二步,要在各個 Processing Element 裡定義兩件事情:一是「我會接收哪些 event」,二是「我處理完之後會發出哪些 event」。
乍看之下,當開發者在定義「某種 event 應該被哪個 PE 接收」時,似乎已經在描述一張計算圖。從這個角度來看,將 S4 的應用畫成 DAG,並沒有錯。但關鍵差異在於:這張圖並不是一個被明確宣告、也不是一個會被編譯(compile)與最佳化的拓撲(topology)。
在 S4 中,並不存在一個集中式的 compile 階段,去驗證整個應用是否構成一個嚴格的 DAG。開發者也不需要先定義完整的圖結構。相反地,系統只關心 event 如何在不同的 PE 之間流動。至於這些 event 最終會串成什麼樣的結構,是在系統運行過程中,由 event 的流動與 key-based routing 自然形成的。
換句話說,圖是執行的結果,而不是設計的起點。這個結構通常可以被觀察為一個 DAG,但它並不是由系統在執行前所明確建構與驗證的。
第三步,設定系統的平行度與部署方式,包括每種 PE 要啟動多少個 instance,以及它們如何分布在不同節點上。
整體而言,開發者需要思考的並不是「資料在哪裡被處理」,而是「資料應該如何流動,以及每一個節點應該做什麼」。至於資料如何被分發、節點如何被調度,則交由系統來負責。
Summary of S4
S4 在 2014 年從 Apache 的孵化計畫中正式退場(retired)。至此,讓我們來檢視它的歷史地位。
當大數據的價值開始被企業真正重視,MapReduce 那種定期、批次式的處理模式逐漸顯得遲緩。業務需求開始要求更低延遲的回應,資料不再只是用來做歷史分析,而是要反映「正在發生的事情」。在這樣的背景下,流式計算首次走上舞台中央。
S4 正是這個轉折點的產物。Yahoo 嘗試用它來實現低延遲的即時資料處理,例如廣告與搜尋資料的即時回饋,以及線上 A/B 測試。S4 的設計理念與 MapReduce 在精神上有某種延續:開發者只需實作 Processing Element 的業務邏輯,而無需關心分散式系統如何運行。資料分發、節點調度與基本容錯機制由框架負責。
在架構上,S4 將業務邏輯與底層的網路協定、資料路由與負載分散機制拆分開來,形成可插拔(pluggable)的系統設計。整體流程被建構為一個有向圖,每個節點是一個 Processing Element,每條邊是一條事件流。每個 PE 被部署在某個處理節點上,負責執行業務邏輯並維護記憶體中的狀態。計算結果可以定期發布到外部儲存系統,使其對外可用。
然而,S4 的設計也顯得相當原始。它採用完全對稱、去中心化的分散式架構,試圖避免單點故障,但也因此缺乏靈活的動態擴展能力。當流量激增時,系統更傾向於採取服務降級,而非動態擴容。
在容錯層面,S4 僅處理節點層級的失效。當節點故障時,系統可以在其他硬體上重新啟動對應的處理元素,但已存在於記憶體中的狀態可能已經遺失。對於節點之間的資料傳輸,系統也未提供端到端的傳遞保證。
換句話說,S4 成功證明了一件事:流式計算可以被建構成一個可擴展的分散式系統。但它尚未回答更深層的問題:當狀態與資料同時流動時,如何在故障發生之後仍然確保計算結果的正確性?
S4 的種種限制,都成為 Storm 和之後系統的改進方向。
Storm(2011)
Introduction
如果說 S4 證明了流式計算是可能的,那麼 Storm 則試圖讓它變得可靠。
2011 年,Twitter 公開了 Storm。當時 Twitter 面臨的問題十分具體:使用者產生的訊息流量龐大,熱門話題、推薦系統、廣告排序等應用都需要在極低延遲下持續更新結果。單純依賴批次系統已無法滿足這樣的需求。
Storm 的出現,並不是要重新定義計算模型,而是要解決一個更迫切的問題 — — 如何在分散式環境下,可靠地處理無界資料流。
Definition
Storm 由以下幾個核心組件構成。
Spout
Spout 是資料來源。它負責從外部系統讀取或接收資料,並將資料轉化為 Tuple 持續發送進系統。Spout 本身不負責計算邏輯,而是扮演輸入端角色。與 S4 將所有節點統一為 Processing Element 不同,Storm 明確將資料來源抽象為獨立組件。在概念上,Spout 可以對應到 S4 中的無鍵 PE,但在模型層面,它被清楚區分為「產生資料」而非「處理資料」的單元。
Tuple
Tuple 是 Storm 中最小的資料單位。官方定義為一個帶有命名欄位的值列表。開發者在宣告時為欄位命名,但在實際傳輸過程中,系統只需傳輸欄位的位置與值。這種設計使得資料結構定義與傳輸過程分離。在概念上,Tuple 對應於 S4 的 Event。
Stream
Stream 是無界的 Tuple 序列。資料在 Topology 中透過 Stream 傳遞,並由系統分散到多個節點並行處理。Stream 並不代表一個有限集合,而是一個持續存在的資料流。
Bolt
Bolt 是執行計算邏輯的節點。它可以接收一個或多個輸入 Stream,並產生一個或多個輸出 Stream。所有業務邏輯 — — 例如轉換、過濾、聚合或將結果寫入外部系統 — — 都在 Bolt 中實作。
乍看之下,Bolt 與 S4 的 Processing Element(PE)非常相似。然而,兩者在抽象層面存在關鍵差異。
S4 在模型中明確定義 per-key Processing Element。每個 key 對應一個 PE instance,狀態與 key 綁定。而在 Storm 中,Bolt 並不天然具備 per-key 語意。若需要將相同 key 的 Tuple 發送至同一個 Bolt 任務,必須透過 grouping 機制(例如 fields grouping)來實現。換言之,per-key 行為在 Storm 中是一種配置結果,而不是模型內建抽象。
Get Ryan Fu’s stories in your inbox
Join Medium for free to get updates from this writer.
此外,PE 在 S4 中被視為 stateful 的核心單元;而 Bolt 是否維護狀態完全取決於開發者實作。Storm 並未在模型層制度化狀態管理。
Topology
Topology 是 Storm 中最關鍵的抽象。與 S4 不同,這張圖不再是執行過程中逐漸形成的結構,而是由開發者在系統啟動前明確定義。
開發者需要清楚描述:
- 有哪些 Spout 與 Bolt
- 它們之間如何連接
- 資料應該如何在不同節點之間流動(透過 grouping)
在這個過程中,Topology 會在提交(submit)到叢集前經過基本的驗證,確保整體結構與設定是合法且可執行的。換句話說,這張圖不只是設計概念,而是一個會被系統接受並執行的計算描述。
在大多數情況下,Topology 被設計為一個有向無環圖(DAG)。這樣的限制,使資料能夠沿著單一方向流動,也讓系統在追蹤資料處理狀態與進行錯誤恢復時,能維持相對簡單的模型。這一點與 Storm 的 ack 機制與 tuple tree 設計密切相關,後續會再詳細說明。
不過,這並不代表 Storm 完全無法表達具有回饋(feedback)的計算。透過較高階的 API(例如 Trident)或結合外部系統,開發者仍然可以實現帶有「環」的邏輯流程。但在核心模型中,Storm 仍然傾向將計算限制在 DAG 之內,以換取更可控的可靠性與系統行為。
想進一步思考的讀者或許會問:什麼樣的情況,會需要一個帶有 feedback 的計算圖?
答案通常出現在那些「結果會影響下一步計算」的場景。例如圖演算法中反覆更新節點權重、機器學習中持續調整模型參數,或是即時推薦系統中,根據使用者最新行為不斷修正排序結果。這類問題的共通點在於:計算並不是單向推進的,而是需要將上一輪的輸出重新納入下一輪的輸入,形成一種持續迭代的過程。
簡而言之,DAG 所描述的是一種「資料一路往前流動」的世界;而一旦引入 feedback,計算就不再只是流動,而是開始「回頭修正自己」。這種模型在表達能力上更為強大,但同時也會大幅增加系統在狀態管理與錯誤恢復上的複雜度。
而 Word Count 這個用例的圖是一個典型的 DAG。
Use Case: Word Count
Storm 官方文件中最常見的示例,是對 Tweet 進行即時單詞統計。我們可以從這個例子,理解 Storm 的可靠傳遞機制。
整個 Topology 的起點是一個 TweetSpout。它從外部資料流中讀取 Tweet,將每一條 Tweet 作為一個 Tuple 發送進系統。這個最初由 Spout 發出的 Tuple,被視為整棵計算樹的根。
接著,Tuple 被隨機分派到 ParseTweetBolt。這個 Bolt 將 Tweet 解析為單詞,並為每個單詞產生新的 Tuple,發送給下游的 WordCountBolt。不同的單詞會透過 fields grouping 被送往不同的 WordCountBolt 任務,因此整個計算過程呈現分散式並行。
在這個過程中,任何一個下游 Tuple 若未成功處理,都意味著整體處理失敗。Storm 的設計並不是局部補救,而是從 Spout 重新發送整條根 Tuple。這正是 Storm 所謂的 tuple tree 模型:所有由 Spout 發出的 Tuple,與其衍生出的所有子 Tuple,共同構成一棵邏輯上的樹。
為了追蹤這棵樹是否處理完成,Storm 引入了 Acker 機制。
The XOR-Based Ack Mechanism
在 Storm 中,每一個由 Spout 發出的 Tuple,都會被分配一個 64 位元的 message-id。我們可以想像系統中有一個 Spout_1,它從外部資料來源讀取一筆資料,例如一條 Tweet,並將其封裝成一個 Tuple 發送出去。假設這筆資料的 message-id 是 M。
當 Spout_1 發送這個 Tuple 的同時,系統會通知 Acker 開始追蹤這筆資料。此時,Acker 會建立一筆紀錄:
M → checksum = M
這裡可以把 checksum 想成一個「尚未完成工作的總和」。一開始只有這一筆資料,因此 checksum 就等於 M。本質上,Acker 的任務就是持續更新這個值,直到所有相關工作都完成為止。
接下來,這個 Tuple 進入第一個 Bolt,例如 ParseTweetBolt_1。這個 Bolt 將一句話拆解成多個單詞,並產生多個新的 Tuple,例如 [“the”], [“fox”], [“jumps”] 等。這些新 Tuple 都會帶著同一個 root message-id M。
當 ParseTweetBolt_1 發送這些新 Tuple 時,它會通知 Acker:「我產生了新的工作」。同時,當它完成對原始 Tuple 的處理時,也會再通知 Acker:「我已經處理完了這一段」。
後續的 Bolt,例如 WordCountBolt_1、WordCountBolt_2,也會做同樣的事情:
每當它們
- 發送新的 Tuple(代表新增工作)
- 或完成一個 Tuple 的處理(代表完成工作)
都會通知 Acker。
Acker 並不儲存整棵 Tuple tree,也不記錄每個節點之間的關係。它只做一件事情:用 XOR(異或)運算,把這些「新增工作」與「完成工作」累積到 checksum 上。
這個機制之所以成立,關鍵在於 XOR 的一個性質:
A XOR A = 0
也就是說,一個工作被「加入」一次、再被「完成」一次,對 checksum 的影響會互相抵消。因此,你可以把 checksum 理解為系統中「還沒完成的工作」的總體表示。只要還有任何一個衍生出的 Tuple 尚未完成處理,checksum 就不會是 0。一旦整棵 Tuple tree 上的所有 Tuple 都完成處理,所有的 XOR 最終會抵消為 0。此時,Acker 會通知 Spout_1:
message-id = M 的這筆資料,已經被完整處理。
這樣的設計還有一個重要的工程優勢。對於每一筆 root message-id,Acker 並不需要儲存整棵計算樹。它只需要記錄兩個 8 bytes 的數值,就可以代表整棵分散式計算流程的狀態:
- message-id(8 bytes)
- checksum(8 bytes)
換句話說,Acker 是用一個非常小的固定大小狀態,來追蹤一個可能非常龐大、跨多個節點的計算過程。
最後,Storm 會為每一筆 root Tuple 設定一個 timeout(預設約數十秒)。如果在這段時間內,checksum 一直沒有回到 0,代表仍然有某些工作沒有完成 — — 可能是某個 Bolt 處理失敗,或資料在系統中遺失。在這種情況下,Storm 會判定這筆資料處理失敗,並通知 Spout_1 重新發送這筆資料,讓整個流程重新執行一次。
整體而言,這個機制的本質可以用一句話概括:Storm 用一個簡單的 checksum,來代表整棵計算樹「還沒完成的部分」,當它變成 0,就代表整筆資料處理完成。
這是一種極為工程化、同時也相當優雅的設計:不用保存複雜的資料結構,只透過位元運算,就能追蹤整個分散式系統中的計算進度。
Delivery Guarantees
然而,這套 Ack 機制所提供的保證是 at-least-once。
若任何一個下游 Tuple 未成功處理,Storm 會重新發送整條根 Tuple。這意味著其他已成功處理的分支也可能被重複執行。以 Top K 的例子來說,若某個單詞處理失敗,整句 Tweet 可能被重新統計,造成重複計數。
若關閉 ack 機制,Storm 便成為 at-most-once 系統。上游發送後不再追蹤是否成功處理,可能導致資料遺失。
What are Still Missing in Kafka + Storm
如果我們將 Kafka 與 Storm 組合在一起,一個負責資料的持續流動,一個負責資料的即時處理,乍看之下,似乎已經具備了一個完整的流式資料處理系統。然而,當這樣的組合真正進入業務場景時,很快就會遇到一個根本性的問題:資料是否被正確地處理。
所謂正確,指的是精確一次、狀態與時間這三個面向。
Exactly Once
Kafka 確保資料能夠被持續傳遞,而 Storm 負責即時處理這些資料。Storm 透過 ack 機制提供了 at-least-once 的處理保證,確保從 Kafka 讀取的資料最終會被處理。然而,這並不保證資料只被處理一次。一旦發生失敗與重試,整條 Tuple chain 可能被重新執行,導致計算結果被重複更新。
這正是 Exactly Once 困難之處。它不僅要求資料不遺失,還要求整個計算過程在面對失敗時,對外呈現的結果等價於只執行一次。這意味著系統必須同時處理訊息重送、狀態更新與外部副作用的一致性,而這在分散式環境中並沒有簡單解法。
這個問題在計費相關的應用中尤為關鍵。例如廣告點擊計費、雲端服務用量計費或金融交易處理,每一筆事件都直接對應金額的增減。一旦某筆資料被重複處理,就可能造成重複扣款或錯誤計費。相較之下,資料遺失在這類場景中也同樣不可接受,系統必須同時避免兩者發生。這也使得 exactly-once 不再只是理論上的理想,而是實務上不可妥協的需求。
Persistent State Management
這個問題在 Kafka + Storm 的組合中會更加明顯。
Kafka 負責保存資料流,讓資料可以被重放(replay);Storm 則負責即時處理這些資料。然而,處理過程中的狀態,卻只存在於 Storm 的計算節點之中,通常保存在記憶體裡。
這與 MapReduce 的世界形成鮮明對比。MapReduce 建立在 batch 模式之上,輸入資料固定,計算有明確邊界。當任務失敗時,只需重新執行整個 job,即可得到一致結果,因為狀態本質上是可重建的。
但在 Kafka + Storm 的架構下,資料是無界的,計算是長期存在的,狀態持續演化。一旦某個 Bolt 所在的節點失效,該節點上的狀態可能直接遺失。即使 Kafka 能夠重新提供資料,系統也無法準確恢復先前的計算狀態。
因此,僅僅依靠 Kafka 的資料重放與 Storm 的訊息重送是不夠的。我們需要將計算節點所依賴的狀態資訊持久化下來,使系統在故障後能夠恢復到一致狀態,而不是重新開始。這不僅是容錯的需求,同時也是支援線上擴容與狀態遷移的基礎。
換句話說,在 Kafka + Storm 的架構中,state 並沒有被視為一等公民。
Time Window
然而,即使 Kafka 解決了資料流動,Storm 解決了資料處理與可靠傳遞,仍然存在一個更根本的問題:
在一條無界的資料流中,我們如何界定「一次計算應該涵蓋的範圍」?
Kafka 持續提供資料流,而 Storm 持續處理資料,但兩者都沒有回答這個問題。
在 batch 系統中,這個問題並不明顯,因為整個資料集本身就隱含了一個時間邊界。但在 Kafka + Storm 的 streaming 架構中,資料沒有開始,也沒有結束。任何聚合操作,都必須先定義其觀察範圍。
這正是時間窗口(time window)的概念。時間窗口將無界的資料流切分為一段一段有界的區間,使得統計與聚合具有明確語意。
進一步地,我們還必須決定時間的定義方式:是以資料產生的時間(event time)為準,還是以系統接收到資料的時間(processing time)為準。
event time 通常用於對「真實世界行為」敏感的業務場景,例如廣告點擊、交易紀錄或使用者行為分析。在這些情境中,資料可能延遲到達,但仍需依照實際發生時間進行統計。
processing time 則適用於對即時反應要求較高、但允許一定誤差的場景,例如即時監控、系統告警或 dashboard 顯示。這類應用更關心的是「系統現在看到什麼」,而不是「事件真正發生在什麼時候」。資料一旦被系統接收到,就立即參與計算,而不會等待可能延遲到達的歷史資料來修正結果。
例如,在監控系統中,我們希望即時知道目前每秒的請求數是否過高。如果某些資料因為網路延遲晚了幾秒才到達,通常不會回頭修正已經顯示的監控數據,而是直接忽略這些延遲事件。這種情況下,使用 processing time 能夠提供更低延遲、也更簡單的實作。
不同的窗口定義,如固定窗口(tumbling window)、滑動窗口(sliding window)或會話窗口(session window),各自對應不同的業務需求。
tumbling window 將時間切分為不重疊的區間,例如每五分鐘統計一次請求數,常用於報表與計費場景。sliding window 允許窗口之間重疊,例如每分鐘更新過去五分鐘的統計結果,適用於即時趨勢分析與監控系統。session window 則根據事件之間的間隔動態決定邊界,例如將一段時間內連續的使用者行為視為同一個 session,常用於使用者行為分析。
在 S4 與 Storm 時代,這三種窗口已經以各種形式存在於工程實作中,只是尚未被統一命名,也缺乏明確的模型。直到 Google 在 2015 年提出 Dataflow 模型,這些分散的實務經驗才首次被系統化整理,並被納入一個統一的計算框架之中。Dataflow 明確定義了 window 作為切分無界資料流的核心抽象,並將不同類型的 window 視為計算語意的一部分,而不再只是應用程式中的實作細節。
綜合來看,即使我們擁有 Kafka 與 Storm,一個能夠傳遞資料,一個能夠處理資料,我們仍然缺少三個關鍵要素:Exactly Once 的處理語意、狀態的持久化管理,以及時間窗口的內建模型。
Summary
從 at-least-once 到 exactly-once,是 streaming 領域的關鍵里程碑。而通往這個里程碑的路徑,在 storage 與 processing 兩個層面上分別展開。
在 Stream Storage 的層面,這條路徑由 Early Kafka 演進到 Modern Kafka,逐步建立起可靠的資料持久化與重放機制。
而在 Stream Processing Engine 的層面,這個里程碑則由 Flink 跨越。Flink 不僅補上了 Storm 所缺乏的 exactly-once 語意,還進一步將 state 視為一等公民,建立了完整的 Persistent State Management,並將 Time Window 納入計算模型之中。
換句話說,Kafka 解決了「資料如何可靠地流動」,而 Flink 則開始回答「資料應該如何被正確地計算」。
因此,在下一章,我們自然要問:當我們同時面對精確一次、狀態與時間這三個核心挑戰時,一個完整的 Stream Processing Engine,究竟是如何被建構出來的?
(To be continued …)
Appendix
- [極客時間] 大数据经典论文解读 25 | 从S4到Storm(一):当分布式遇上实时计算
- [極客時間] 大数据经典论文解读 26 | 从S4到Storm(二):位运算是个好东西
- [極客時間] 大数据经典论文解读 29 | Dataflow(一):正确性、容错和时间窗口
- Streaming 101: The world beyond batch: https://www.oreilly.com/radar/the-world-beyond-batch-streaming-101/
- Streaming 102: The world beyond batch: https://www.oreilly.com/radar/the-world-beyond-batch-streaming-102/
- Storm @ Twitter(Nathan Marz, 2014):https://classpages.cselabs.umn.edu/Spring-2018/csci8980/Papers/StreamProcessing/Storm.pdf
- Apache Storm 官方文件:Guaranteeing message processing: https://storm.apache.org/releases/2.6.0/Guaranteeing-message-processing.html








