過去幾篇文章 (例如分散式系統系列) 我們一直在討論 "多個節點之間怎麼達成共識" 這件事,從 Raft 到拜占庭容錯,主題都圍繞著「如何讓一群電腦對某件事達成一致」.但今天我想退一步,談一個更基礎的問題:在任何分散式系統的底層,都有一個更原始的需求,單一台機器怎麼確保自己寫進去的資料,不會因為突然斷電就消失?
這個問題的答案,從資料庫的 WAL (Write-Ahead Log) 出發,延伸出了事件溯源 (Event Sourcing)這個想法,最後甚至演化成了像 Kafka 這種影響整個業界的系統設計.這條技術演進的脈絡其實非常漂亮,今天我們就來把它連起來看.
程式當掉,資料怎麼辦?
我們先從一個場景談起.想像你在網銀界面操作轉帳,從你的帳戶轉 1000 元給朋友.後台的伺服器收到指令後,準備執行兩個動作:
- 把你的帳戶餘額從 5000 改成 4000
- 把朋友的帳戶餘額從 3000 改成 4000
如果在動作 1 和動作 2 之間,伺服器突然斷電了會怎樣? 你的錢沒了,但朋友也沒收到.這 1000 元就憑空消失了.
這是一個典型的「原子性 (atomicity)」問題.我們需要的是要嘛全部成功,要嘛全部失敗的保證.但現實的硬體不會給你這種保證 —— 寫入磁碟是一個 byte 一個 byte 寫的,斷電可能發生在任何時間點,而且系統重啟之後,我們甚至不知道斷電之前到底寫到了哪裡.
那資料庫是怎麼處理這個問題的呢? 答案就是 WAL.
WAL (Write-Ahead Log) 的核心想法
WAL 的概念講起來很簡單,但是非常聰明.它說:在你真的去改資料之前,先把你「打算要改什麼」這件事,寫到一個 log 檔案裡.
具體流程是這樣:
- 把「我要從 A 扣 1000,加到 B」這筆紀錄寫到 log 檔
- 確保 log 檔已經 flush 到磁碟 (也就是真的寫進去了,不只是停留在 OS 的 buffer 裡)
- 真正去修改 A 和 B 的餘額
- 修改完成後,標記這筆 log 為「已完成」
為什麼這樣做可以解決問題? 我們把斷電可能發生的時間點一個一個檢查:
- 斷電發生在步驟 1 之前:什麼都還沒做,重啟後當作沒這回事.
- 斷電發生在步驟 2 之前:log 還沒成功 flush 到磁碟,重啟後系統根本看不到這筆 log,也是當作沒這回事.使用者那邊會收到「轉帳失敗」的回應,可以重試.
- 斷電發生在步驟 3 中間 (例如只扣了 A 還沒加到 B):重啟時系統會掃過 log,發現這筆操作的狀態是「已記錄、未完成」,於是把它接著做完.這個動作叫做 recovery.
- 斷電發生在步驟 4 之前 (A 和 B 其實已經改好了,但還沒標記 log 為完成):這時候 recovery 該怎麼辦? 它不能無腦地「再做一次」,否則 A 就會被多扣一次錢.關鍵在於每筆資料的修改都會附帶一個編號 (例如 Log Sequence Number),recovery 時系統會比對「資料目前的編號」跟「log 裡的編號」,如果發現資料其實已經套用過了,就只需要補上「標記為完成」這個動作,不會重做扣款.
這個機制的核心精神是:先把意圖寫下來,再去執行.只要意圖 (也就是 log) 保存住了,即使執行中途出問題,下次開機都可以根據 log 跟資料的對照,把系統修復到一個一致的狀態.這個事先寫下的 log 就是 Write-Ahead Log.
順帶提一個容易被忽略的技術細節:上面提到的「flush 到磁碟」,對應到作業系統其實是 fsync 這個系統呼叫.普通的 write() 呼叫只是把資料交給 OS 的 buffer,遇到斷電仍然會丟失;只有 fsync 完成後,儲存系統才會真正承諾資料已經 durable.這也是整個 transaction commit 流程中最昂貴的一步.現代資料庫常會用 group commit 的技巧,把短時間內多筆 transaction 的 fsync 合併成一次 (你想想,5 筆 transaction 各自 fsync 5 次,跟一批一起 fsync 1 次,後者快多了),來平攤 durability 的成本.這也是 throughput 與 durability latency 之間經典的 tradeoff.
幾乎所有主流的資料庫都用了 WAL 的概念.PostgreSQL 直接就叫做 WAL,MySQL InnoDB 把它叫做 redo log,SQLite 也有 WAL mode 可以開.這已經是資料庫工程師的標配技術.
如果我們只保留 log 呢?
讀到這邊你可能會發現一個有趣的事實:WAL 裡其實已經包含了重建資料需要的所有資訊.如果我把所有的 log 都保留下來,從第一筆 log 開始一條一條重新執行,理論上我就能算出當前的資料狀態.
換句話說:當前的資料狀態,只是所有歷史操作累積起來的結果.
這句話聽起來很簡單,但它藏著一個非常深刻的觀念翻轉:真正 durable 的東西不是當前狀態,而是歷史,當前狀態只是歷史重播到此刻為止的快照而已.若用一個程式來表達,就像下面:
state = replay(history)
這個視角的翻轉是接下來幾個技術 (事件溯源、Kafka、甚至我們系列前面講過的 Raft) 的共同基礎.先把這句話放心上,後面會反覆看到它.
事件溯源 (Event Sourcing)
事件溯源 (Event Sourcing) 的核心思想就是:不要儲存當前的狀態,是儲存所有改變狀態的事件.當你需要當前狀態時,把所有事件從頭重播一次來計算出來.
我們用一個具體的例子來比較一下傳統 CRUD 跟事件溯源的差異.假設我們要做一個線上購物車的功能.
傳統 CRUD 的做法:資料庫裡有個購物車表格,每個使用者一筆紀錄,紀錄了目前購物車有什麼商品.使用者加商品、改數量、刪商品,都是直接 UPDATE 這筆紀錄.歷史資訊隨著每次 UPDATE 就消失了.
事件溯源的做法:資料庫裡只有一個 events 表,記錄一連串的事件:
- 9:00 使用者 A 把商品 X 加入購物車
- 9:05 使用者 A 把商品 X 的數量改成 2
- 9:10 使用者 A 把商品 Y 加入購物車
- 9:15 使用者 A 把商品 X 從購物車移除
要知道使用者 A 目前的購物車有什麼? 把上面這些事件依序重播,就能算出當前的狀態.注意這跟 WAL 在概念上完全一樣,只是 WAL 的 log 是給資料庫內部用的,而事件溯源的 log 是業務層級的事件.
事件溯源帶來幾個好處:
1. 完整的歷史紀錄:你能清楚知道任何時間點的狀態,因為所有變化都被記下來了.什麼時候被改的、被誰改的、改之前是什麼、改之後是什麼,全部一目了然.這對於需要嚴格審計的場景 (金融、醫療) 來說是極大的優勢.
2. 時光旅行 (time travel):想知道昨天下午三點的購物車是什麼狀態? 把昨天下午三點以前的事件重播一次就好.這在除錯或回溯資料時超級好用.
3. 多種視角:同一份事件流,可以重播出不同的視圖.例如同樣的訂單事件,可以重播出「使用者的訂單清單」、「商品的銷售統計」、「物流的出貨清單」等不同的資料表,給不同的功能使用.這個觀念在業界有個專有名詞叫做 CQRS (Command Query Responsibility Segregation),常常跟事件溯源搭配使用.
但事件溯源也有它的代價,這些代價在 production 系統裡常常被低估.
Schema 演進的長期負擔:events 一旦寫下去就是 immutable,所以五年前定義的 event schema,五年後系統還是得能讀懂它.這代表你必須長期維護 backward compatibility、做 event versioning、規劃 migration 策略.新人加入專案時,也得理解所有歷史版本的 schema.這在大型系統裡是一個持續性的維護負擔,常常被低估.
重播成本與 snapshot 機制:如果系統累積了上億筆 events,每次重建狀態都從第一筆開始重播是不切實際的.Production 系統幾乎一定要搭配 snapshot 機制 (定期把計算好的狀態存下來,下次重播只要從最近的 snapshot 開始接著算就好).這帶來額外的複雜度:snapshot 多久做一次? 怎麼確保 snapshot 跟 event log 之間的一致性? 這些都要好好設計.
CQRS 的最終一致性:事件溯源常常搭配 CQRS 一起用,意思是 寫 跟 讀 走不同的資料模型.寫進去的是 events,讀的時候則是從另一份 projection table 讀.這兩邊通常是 async 同步的,導致使用者可能會以為 我剛改了,但讀出來還是舊的 這種狀況.要處理 stale read、ordering、retry semantics 等等問題,整個系統的複雜度遠高於傳統 CRUD.
思維轉換的成本:習慣 CRUD 的工程師要花一段時間適應這種思維.事件一旦寫下去就不能修改 (要改的話只能再寫一筆「補正事件」),這對很多人來說是反直覺的.
從單機到分散式:Kafka 的登場
到目前為止我們講的 WAL 和事件溯源,都還是單機的概念.但這套以 log 為核心的思想,放到分散式系統裡有更大的威力.
設想這個場景:你有一個電商系統,使用者下訂單之後,需要做以下這些事情:
- 通知庫存系統扣庫存
- 通知物流系統準備出貨
- 通知會計系統記帳
- 通知通知系統發 email 給使用者
- 通知數據分析系統更新統計
如果這些都直接呼叫對方的 API (同步呼叫),會有很多問題.任何一個系統慢,整個下單流程都會被拖累.任何一個系統當掉,下單就失敗了.新增一個訂閱者 (例如新增一個推薦系統),就要去改下單系統的程式碼.萬一某個系統突然 traffic 暴增處理不來,訊息可能就遺失了.
如果我們把事件溯源的思想套到這個情境呢? 訂單系統不直接呼叫別人,而是把「訂單成立了」這件事當作一個事件,發布到某個地方.其他系統就各自從這個地方讀取事件,自己處理.
這就是 Kafka 的設計核心.Kafka 本質上就是一個分散式的 commit log.
Kafka 為什麼長這樣
Kafka 的核心抽象其實非常簡單:
- 生產者 (Producer) 把訊息 append 到一個 log 裡
- 消費者 (Consumer) 從 log 裡讀訊息
- log 本身按順序儲存,每個訊息有一個 offset (位置編號)
- 訊息不會被刪除 (除非過期),所以可以被反覆讀取與重播
注意這個結構跟資料庫 WAL 的概念有多像! 差別只是:
- WAL 是給單一資料庫內部用的,目的是 crash recovery
- Kafka 是給多個獨立系統共用的,目的是 decouple 系統
但 append-only log 這個本質完全一樣.這也是為什麼 Kafka 在學術上常被稱為 distributed commit log.
Kafka 的這種設計帶來幾個很厲害的特性:
1. 多消費者獨立進度:這是 Kafka 設計上最關鍵的特色之一,跟傳統訊息佇列 (像是 RabbitMQ) 有本質上的差異.我們用前面電商下單的情境來具體比較.假設訂單系統發出一筆「訂單成立」的訊息,庫存、物流、會計、通知四個下游系統都需要處理這筆訊息.
傳統訊息佇列的做法是:訊息放進佇列裡,誰來「取」訊息誰就拿到,拿到之後訊息就從佇列裡消失了.那如果四個系統都要看到這筆訊息怎麼辦? 通常需要設計成「多個佇列」或 pub/sub 模式 —— 訂單系統要把同一筆訊息複製成四份,分別塞到四個佇列裡.如果哪天又新增第五個系統 (例如風控),訂單系統的程式碼就要再改一次.
Kafka 的做法則不一樣:訊息只寫一次,存在 log 裡.每個消費者各自記錄自己讀到哪裡了 (這個位置叫做 offset,可以理解成「書籤」).庫存系統的書籤在第 100 筆,物流系統的書籤在第 95 筆,會計系統因為比較慢還在第 80 筆,互不影響.新增風控系統? 直接讓它從第 0 筆開始讀就好,訂單系統的程式碼一行都不用改.
這就是 被讀 與 被拿走 的差別.Kafka 的訊息像是貼在公佈欄上的公告,誰都可以走過去看,每個人記住自己看到哪一則就好.傳統佇列的訊息則像是發給特定收件人的信,拿走就沒了.
2. 重播能力:因為訊息不會被刪掉,新加入的消費者可以從頭開始重播所有歷史.想新增一個資料分析的下游? 直接從第 0 筆 offset 開始讀就好,連歷史資料都自動補齊了.這簡直就是事件溯源在分散式系統上的完美實踐.
3. 自然的解耦:生產者只管把事件 append 進去,不需要知道有誰會讀.消費者要訂閱新主題就自己加,生產者完全不用改.這在大型系統的演化上是巨大的優勢.
4. 順序保證:因為是 append-only,同一個 partition 內的訊息順序是保證的.這對很多業務邏輯 (例如「先下訂單再付款」這種因果關係) 很重要.
Kafka 的原始作者之一 Jay Kreps 在他著名的文章 The Log: What every software engineer should know about real-time data's unifying abstraction 裡,把這個觀察講得非常透徹:log 不只是資料庫的內部實作細節,它其實是一個非常 fundamental 的抽象,可以用來統一處理整個分散式系統的資料流.這篇文章強烈推薦讀一下.
回到系列開頭:Raft 也是一個 log 系統
看到這裡,如果你有讀過本系列前面講過的 Raft 文章,可能會有一種熟悉感.Raft 是用來在多個節點之間達成共識的演算法.但有沒有想過 Raft 到底是在「對什麼東西達成共識」呢?
答案是:log 的順序.
Raft 的每個節點內部都維護一份 log,這份 log 記錄了所有要執行的操作 (例如「把 x 設成 5」、「把 y 加 1」).Leader 收到 client 的請求後,會把這個操作 append 到自己的 log,然後複製給所有 Follower.當大多數節點都 ack 之後,這筆 log 才被視為 committed.接著每個節點各自按照 log 的順序執行操作,計算出自己手上的 state.
注意這個流程跟單機 WAL 有多像! 差別只在於:
- WAL 是一台機器內部的事,保證的是 crash recovery
- Raft 把同樣的 log 概念複製到多台機器上,保證的是跨節點的 state consistency
更精準地說:Raft 真正在解決的問題並不是「選 Leader」,而是「讓所有節點對 log 的順序達成一致」.只要所有節點看到相同順序的 log,按照同樣的順序重播,最後算出來的 state 就一定一致.這個架構在文獻上有個正式名稱,叫做 State Machine Replication.
所以你會發現,整個系列文章其實一直在繞著同一個核心概念走:
- 單機 WAL:在一台機器上保證有序、可持久化的歷史
- Event Sourcing:把這個概念抬到應用層,以業務事件的形式記錄歷史
- Kafka:把 log 變成跨系統共用的基礎設施
- Raft:讓多個節點對 log 的順序達成共識
看似毫不相干的四個技術,骨子裡都是同一個抽象:append-only ordered log.差別只在於它們處理「有序性」的範圍,WAL 處理一台機器內部的有序性,Raft 處理多台機器之間的有序性.這也是為什麼 distributed systems 領域有一句很有名的話:分散式系統的本質問題不是「儲存」,而是「ordering」.當你能保證所有節點看到相同順序的事件,後面所有事情都好辦.
整理一下
我們把 WAL、事件溯源、Kafka、Raft 這四個概念放在一起比較:
| 概念 | 抽象層級 | 解決的問題 | 誰會用 |
|---|---|---|---|
| WAL | 資料庫內部 | 單機 crash recovery | 幾乎所有現代資料庫 |
| Event Sourcing | 應用層 | 保留完整歷史、可重播狀態 | 需強審計性的系統 (金融、醫療) |
| Kafka | 分散式系統基礎設施 | 系統解耦、跨系統事件流 | 大型分散式架構 |
| Raft | 分散式共識 | 多節點對 log 順序達成一致 | 需要強一致性的分散式系統 (etcd, CockroachDB) |
四者的層級不同,但底下的核心思想是同一個:用 append-only ordered log 取代直接修改狀態.
從 WAL、Event Sourcing、Kafka 到 Raft,這條技術演進的脈絡其實在反覆強化同一個觀念:真正 durable 的不是當前狀態,而是歷史,當前狀態只是歷史的 cache (state = replay(history)).這四個技術名字完全不同、出現在系統的不同層級,但骨子裡都是同一個抽象 —— append-only ordered log;前幾篇談的 "多節點達成共識" 與今天談的 "單節點資料不遺失" 其實只是同一個核心抽象在不同層級的展現,前者讓多個節點對 log 順序達成一致,後者確保每個節點都能可靠地重播歷史.當然這種設計也有它的代價 (儲存成本、查詢複雜度、schema 演進負擔),不是每個系統都需要,這跟上一篇拜占庭容錯文章的結論一樣,工程的世界沒有銀彈,理解每種設計背後的思維與取捨,才能在實際工作中做出正確的選擇.
Help it helps
重點:Client 負責查詢 Registry、選擇 Instance、直接呼叫
重點:Client 不知道 Instance 位址,由中間層負責路由,多一層 Network Hop,但 Client 邏輯簡單
重點:寫入只能透過 Leader,超過半數 (Quorum) 確認即算成功,5 台掛 2 台仍可運作 (3 > 5/2)
應用只管商業邏輯,網路通訊的一切交給 Sidecar Proxy,所有服務共用同一套基礎設施