支付平台通常會為客戶提供數位錢包(digital wallet)服務,讓他們可以把錢存在錢包裡之後使用。例如,你可以從銀行卡把錢加到數位錢包中,當你在線上購物時,你會被提供使用錢包餘額付款的選項。圖 1 顯示這個流程。
花錢並不是數位錢包提供的唯一功能。對於像 PayPal 這類支付平台,我們可以直接把錢轉到同一支付平台上其他人的錢包。
與銀行對銀行的轉帳相比,數位錢包之間的直接轉帳更快,最重要的是,通常不會收取額外手續費。
圖 2 顯示一次跨錢包的餘額轉帳操作。
假設我們被要求設計一個支援跨錢包餘額轉帳操作的數位錢包應用後端。在面試開始時,我們會問釐清需求的問題,把需求確認清楚。
Step 1 - Understand the Problem and Establish Design Scope#
應徵者:我們是否只需專注於兩個數位錢包之間的餘額轉帳操作?需要擔心其他功能嗎?
面試官:我們只需專注於餘額轉帳操作即可。
應徵者:系統需要支援每秒多少筆交易(TPS)?
面試官:假設 1,000,000 TPS。
應徵者:數位錢包對正確性有嚴格要求。我們是否可以假設交易性保證(transactional guarantees)[1] 已足夠?
面試官:聽起來不錯。
應徵者:我們需要證明正確性嗎?
面試官:這是個好問題。正確性通常只能在交易完成後才能驗證。一種驗證方式是把我們的內部紀錄與銀行對帳單比對。對帳的限制是它只能顯示差異,無法說明差異是如何產生的。因此,我們希望設計一個具備可重現性(reproducibility)的系統,意思是我們始終可以從一開始重播資料來重建歷史餘額。
應徵者:可用性需求是 99.99% 嗎?
面試官:聽起來不錯。
應徵者:我們需要考慮外幣兌換嗎?
面試官:不用,那不在範圍內。
總結,我們的數位錢包需要支援以下功能:
- 支援兩個數位錢包之間的餘額轉帳操作。
- 支援 1,000,000 TPS。
- 可靠性至少 99.99%。
- 支援交易(transactions)。
- 支援可重現性(reproducibility)。
Back-of-the-envelope estimation#
當我們談論 TPS 時,意味著會使用交易型資料庫(transactional database)。今日,一個跑在典型資料中心節點上的關聯式資料庫每秒可支援數千筆交易。例如,參考資料 [2] 包含一些熱門交易型資料庫伺服器的效能基準。讓我們假設一個資料庫節點可支援 1,000 TPS。為了達到 100 萬 TPS,我們需要 1,000 個資料庫節點。
不過這個計算稍有不準確。每個轉帳指令需要兩個操作:從一個帳戶扣款,並把錢存入另一個帳戶。要支援每秒 100 萬次轉帳,系統實際上需要處理高達 200 萬 TPS,意味著我們需要 2,000 個節點。
表 1 顯示當「每節點 TPS」(單一節點可處理的 TPS)變動時所需的節點總數。假設硬體相同,單一節點每秒能處理的交易越多,所需的節點總數就越少,意味著硬體成本越低。
我們的設計目標之一是提升單一節點能處理的交易數量。
| Per-node TPS | Node Number |
|---|---|
| 100 | 20,000 |
| 1,000 | 2,000 |
| 10,000 | 200 |
表 1 每節點 TPS 與節點數量的對應
Step 2 - Propose High-Level Design and Get Buy-In#
本節中,我們將討論以下內容:
- API 設計
- 三個高層次設計
- 簡單的記憶體解法
- 基於資料庫的分散式交易解法
- 具備可重現性的事件溯源(event sourcing)解法
API Design#
我們使用 RESTful API 慣例。本次面試我們只需要支援一個 API:
| API | 詳細說明 |
|---|---|
| POST /v1/wallet/balance_transfer | 從一個錢包轉帳餘額到另一個錢包 |
請求參數:
| 欄位 | 說明 | 型別 |
|---|---|---|
| from_account | 借記帳戶 | string |
| to_account | 貸記帳戶 | string |
| amount | 金額 | string |
| currency | 貨幣型別 | string (ISO 4217 [3]) |
| transaction_id | 用於去重的 ID | uuid |
範例回應 body:
{
"Status": "success"
"Transaction_id": "01589980-2664-11ec-9621-0242ac130002"
}值得一提的是,「amount」欄位的資料型別是「string」,而非「double」。我們在 Payment System 章節已說明過理由。
實務上許多人仍選擇使用 float 或 double 表示數字,因為幾乎每個程式語言與資料庫都支援。只要我們了解失去精度的潛在風險,這是合理的選擇。
In-memory sharding solution#
錢包應用為每個使用者帳戶維護帳戶餘額。一個適合表示這個 <user, balance> 關係的資料結構是 map,也稱為 hash table(map)或 key-value 儲存。
對於記憶體儲存,一個熱門選擇是 Redis。單一個 Redis 節點不足以處理 100 萬 TPS。我們需要建立一個 Redis 節點叢集,並把使用者帳戶平均分散到節點之間。這個過程稱為分區(partitioning)或分片(sharding)。
要把 key-value 資料分散到 N 個分區,我們可以計算 key 的 hash 值並除以 N,餘數就是分區的目標。下面的虛擬碼顯示分片過程:
String accountID = "A";
Int partitionNumber = 7;
Int myPartition = accountID.hashCode() % partitionNumber;分區數量與所有 Redis 節點的位址可以儲存在一個集中地點。我們可以使用 Zookeeper [4] 作為高可用的設定儲存方案。
此方案的最後一個元件是處理轉帳指令的服務。我們稱它為錢包服務(wallet service),它有幾項關鍵職責:
- 接收轉帳指令
- 驗證轉帳指令
- 如果指令有效,更新轉帳所涉及兩位使用者的帳戶餘額。在叢集中,帳戶餘額很可能在不同的 Redis 節點上
錢包服務是**無狀態(stateless)**的,容易水平擴展。圖 3 顯示記憶體解法。
在這個範例中,我們有 3 個 Redis 節點。有三位客戶 A、B 與 C。他們的帳戶餘額平均分散在這三個 Redis 節點上。範例中有兩個錢包服務節點處理餘額轉帳請求。當其中一個錢包服務節點收到把 1 美元從客戶 A 轉到客戶 B 的轉帳指令時,它會向兩個 Redis 節點發出兩個指令。對於包含客戶 A 帳戶的 Redis 節點,錢包服務從帳戶扣除 1 美元;對於客戶 B,錢包服務在帳戶上加 1 美元。
應徵者:在這個設計中,帳戶餘額分散在多個 Redis 節點上。Zookeeper 用於維護分片資訊。無狀態的錢包服務使用分片資訊定位客戶的 Redis 節點,並相應地更新帳戶餘額。
面試官:這個設計可以運作,但不符合我們的正確性需求。錢包服務為每筆轉帳更新兩個 Redis 節點,無法保證兩次更新都會成功。例如,如果錢包服務節點在第一次更新完成之後、第二次更新完成之前當機,將導致不完整的轉帳。這兩次更新需要在單一原子交易中完成。
Distributed transactions#
Database sharding#
我們如何讓對兩個不同儲存節點的更新具有原子性?第一步是把每個 Redis 節點換成交易型關聯式資料庫節點。圖 4 顯示其架構。這次,客戶 A、B、C 被分區到 3 個關聯式資料庫,而非 3 個 Redis 節點。
只使用交易型資料庫只解決了部分問題。如上一節提到的,一筆轉帳指令很可能需要更新兩個不同資料庫中的兩個帳戶。我們無法保證兩個更新操作會在完全相同的時間被處理。如果錢包服務在更新第一個帳戶餘額後立即重啟,我們如何確保第二個帳戶也會被更新?
Distributed transaction: two-phase commit#
在分散式系統中,一筆交易可能涉及多個節點上的多個程序。要讓交易具備原子性,**分散式交易(distributed transaction)**可能就是答案。實作分散式交易有兩種方式:低階解法與高階解法。我們將分別檢視。
低階解法依賴資料庫本身。最常用的演算法稱為兩階段提交(two-phase commit, 2PC)。如其名,它有兩個階段,如圖 5 所示。
- 協調者(coordinator),在我們的情境中就是錢包服務,正常地對多個資料庫執行讀寫操作。如圖 5 所示,資料庫 A 與 C 都被鎖定。
- 當應用即將提交交易時,協調者要求所有資料庫**準備(prepare)**交易。
- 在第二階段,協調者從所有資料庫收集回覆並執行:
- 如果所有資料庫都回覆「yes」,協調者要求所有資料庫提交它們所收到的交易。
- 如果任何資料庫回覆「no」,協調者要求所有資料庫中止交易。
它是低階解法,因為 prepare 步驟需要對資料庫交易做特殊修改。例如,X/Open XA [6] 標準協調異質資料庫達成 2PC。
2PC 最大的問題是效能不佳,因為等待其他節點訊息時鎖可能被持有很長一段時間。2PC 的另一個問題是協調者可能成為單點故障,如圖 6 所示。
Distributed transaction: Try-Confirm/Cancel (TC/C)#
TC/C 是一種補償交易(compensating transaction) [7],包含兩個步驟:
- 在第一階段,協調者要求所有資料庫為交易保留資源。
- 在第二階段,協調者從所有資料庫收集回覆:
- 如果所有資料庫都回覆「yes」,協調者要求所有資料庫確認操作,這就是 Try-Confirm 流程。
- 如果任何資料庫回覆「no」,協調者要求所有資料庫取消操作,這就是 Try-Cancel 流程。
2PC 的兩個階段被包在同一筆交易中,但 TC/C 的每個階段都是獨立的交易。
TC/C 範例
用真實世界的例子說明 TC/C 的運作會更容易理解。假設我們要從帳戶 A 轉 1 美元到帳戶 C。表 2 摘要 TC/C 在每個階段的執行方式。
| Phase | Operation | A | C |
|---|---|---|---|
| 1 | Try | Balance change: -$1 | Do nothing |
| 2 | Confirm | Do nothing | Balance change: +$1 |
| Cancel | Balance change: +$1 | Do Nothing |
表 2 TC/C 範例
讓我們假設錢包服務是 TC/C 的協調者。在分散式交易開始時,帳戶 A 的餘額有 1 美元,帳戶 C 的餘額為 0 美元。
第一階段:Try
在 Try 階段,作為協調者的錢包服務向兩個資料庫發送兩個交易指令:
- 對於包含帳戶 A 的資料庫,協調者啟動一個本地交易,將 A 的餘額減少 1 美元。
- 對於包含帳戶 C 的資料庫,協調者給它一個 NOP(no operation,無操作)。為了讓範例可適用於其他情境,假設協調者向此資料庫發送一個 NOP 指令。資料庫對 NOP 指令什麼都不做,並總是以成功訊息回覆協調者。
Try 階段如圖 7 所示,粗線表示交易持有的鎖。
第二階段:Confirm
如果兩個資料庫都回覆「yes」,錢包服務啟動下一個 Confirm 階段。
帳戶 A 的餘額已在第一階段更新。錢包服務不需要變更其餘額。然而,帳戶 C 在第一階段尚未從帳戶 A 收到 1 美元。在 Confirm 階段,錢包服務必須在帳戶 C 的餘額上加 1 美元。
Confirm 流程如圖 8 所示。
第二階段:Cancel
如果第一個 Try 階段失敗呢?在上述範例中我們假設帳戶 C 上的 NOP 操作總是成功,但實務上它可能失敗。例如,帳戶 C 可能是非法帳戶,監管機構規定不能有任何資金流入或流出此帳戶。在此情況下,分散式交易必須被取消,我們必須清理。
因為帳戶 A 的餘額已在 Try 階段的交易中被更新,錢包服務不可能取消一筆已完成的交易。它能做的是啟動另一筆交易來反轉 Try 階段交易的效果,也就是把 1 美元加回帳戶 A。
由於帳戶 C 在 Try 階段未被更新,錢包服務只需要對帳戶 C 的資料庫送出一個 NOP 操作。
Cancel 流程如圖 9 所示。
2PC 與 TC/C 的比較
表 3 顯示 2PC 與 TC/C 之間有許多相似之處,但也有差異。在 2PC 中,當第二階段開始時,所有本地交易都尚未完成(仍被鎖定);在 TC/C 中,當第二階段開始時,所有本地交易都已完成(已解鎖)。
換句話說,2PC 的第二階段是關於完成一筆未完成的交易,例如中止或提交;而在 TC/C 中,第二階段是在發生錯誤時,使用反向操作來抵消先前的交易結果。下表摘要它們的差異。
| 第一階段 | 第二階段:成功 | 第二階段:失敗 | |
|---|---|---|---|
| 2PC | 本地交易尚未完成 | 提交所有本地交易 取消所有本地交易 | 取消所有本地交易 |
| TC/C | 所有本地交易都已完成(提交或取消) | 必要時執行新的本地交易 | 反轉已提交交易的副作用,又稱為「undo」 |
表 3 2PC vs TC/C
TC/C 也稱為以補償方式實現的分散式交易。它是高階解法,因為補償(也稱為「undo」)是在業務邏輯中實作的。
- 優點:與資料庫無關,只要資料庫支援交易,TC/C 就能運作。
- 缺點:我們必須在應用層的業務邏輯中管理細節並處理分散式交易的複雜性。
Phase status table
我們還沒回答先前問的問題:如果錢包服務在 TC/C 中途重啟會怎樣?當它重啟時,先前的所有操作歷史可能都會遺失,系統可能不知道如何恢復。
解法很簡單。我們可以把 TC/C 的進度作為**階段狀態(phase status)**儲存在交易型資料庫中。階段狀態至少包含以下資訊:
- 分散式交易的 ID 與內容。
- 每個資料庫的 Try 階段狀態。狀態可以是「尚未發送」、「已發送」與「已收到回應」。
- 第二階段的名稱,可以是「Confirm」或「Cancel」,可由 Try 階段的結果計算得出。
- 第二階段的狀態。
- 亂序旗標(out-of-order flag,稍後在「Out-of-Order Execution」一節說明)。
我們應該把 phase status 表放在哪?通常我們把 phase status 存在包含被扣款錢包帳戶的資料庫中。更新後的架構圖如圖 10 所示。
Unbalanced state
你有注意到,在 Try 階段結束時,缺少 1 美元嗎(圖 11)?
假設一切順利,在 Try 階段結束時,帳戶 A 被扣了 1 美元,帳戶 C 維持不變。A 與 C 的帳戶餘額總和將是 0 美元,比 TC/C 開始時少。它違反了會計的基本規則:交易後總和應保持不變。
好消息是,TC/C 仍維持交易性保證。TC/C 由幾個獨立的本地交易組成。因為 TC/C 是由應用驅動的,應用本身能看到這些本地交易之間的中間結果。另一方面,資料庫交易或 2PC 版本的分散式交易是由資料庫維護的,對高階應用是不可見的。
在分散式交易執行期間,總是存在資料差異。這些差異對我們可能是透明的,因為較低階的系統(例如資料庫)已經修正了差異。如果沒有,我們必須自己處理(例如 TC/C)。
不平衡狀態如圖 11 所示。
Valid operation orders
Try 階段有三種選擇:
| Try 階段選擇 | 帳戶 A | 帳戶 C |
|---|---|---|
| 選擇 1 | -$1 | NOP |
| 選擇 2 | NOP | +$1 |
| 選擇 3 | -$1 | +$1 |
表 4 Try 階段選擇
三種選擇看起來都合理,但有些是無效的。
- 選擇 2:如果 Try 階段在帳戶 C 上成功,但在帳戶 A 上失敗(NOP),錢包服務需要進入 Cancel 階段。可能有人會插隊把那 1 美元從帳戶 C 移走。之後當錢包服務試圖從帳戶 C 扣除 1 美元時,發現什麼都不剩,這違反了分散式交易的交易性保證。
- 選擇 3:如果 1 美元同時從帳戶 A 扣除並加到帳戶 C,會引入很多複雜情況。例如,1 美元被加到帳戶 C,但從帳戶 A 扣款失敗。在這種情況下我們該怎麼辦?
因此,選擇 2 與選擇 3 都是有缺陷的選擇,只有選擇 1 是有效的。
Out-of-order execution
TC/C 的一個副作用是亂序執行(out-of-order execution)。用例子說明會更容易。
我們重用上述把 1 美元從帳戶 A 轉到帳戶 C 的例子。如圖 12 所示,在 Try 階段,對帳戶 A 的操作失敗並回傳失敗給錢包服務,錢包服務於是進入 Cancel 階段,並對帳戶 A 與帳戶 C 都送出 cancel 操作。
假設處理帳戶 C 的資料庫有一些網路問題,它在收到 Try 指令之前就先收到 Cancel 指令。在這種情況下,沒有東西可以取消。
亂序執行如圖 12 所示。
要處理亂序操作,每個節點都被允許在沒有收到 Try 指令的情況下 Cancel 一筆 TC/C,並對現有邏輯做以下強化:
- 亂序的 Cancel 操作會在資料庫中留下一個旗標,表示它已看到 Cancel 操作,但尚未看到 Try 操作。
- Try 操作被強化,會永遠檢查是否存在亂序旗標,若存在則回傳失敗。
這就是為什麼我們在「Phase Status Table」一節中為 phase status 表加上了亂序旗標。
Distributed transaction: Saga#
Linear order execution
還有另一種熱門的分散式交易解法稱為 Saga [8]。Saga 是微服務架構中的事實上標準。Saga 的概念很簡單:
- 所有操作以序列方式排列。每個操作是其自身資料庫上的獨立交易。
- 操作從第一個執行到最後一個。當一個操作完成後,下一個操作被觸發。
- 當一個操作失敗時,整個流程從目前操作以反向順序開始回滾到第一個操作,使用補償交易。所以如果一筆分散式交易有 n 個操作,我們需要準備 2n 個操作:n 個用於正常情況,另外 n 個用於回滾期間的補償交易。
用例子說明會更容易理解。圖 13 顯示把 1 美元從帳戶 A 轉到帳戶 C 的 Saga 工作流。上方水平線顯示正常的執行順序。兩條垂直線顯示系統在發生錯誤時應該做什麼。當遇到錯誤時,轉帳操作被回滾,並客戶端收到錯誤訊息。如同我們在「Valid operation orders」一節所述,必須先扣款再加款。
我們如何協調這些操作?有兩種方式:
- 編舞(Choreography)。在微服務架構中,所有參與 Saga 分散式交易的服務透過訂閱其他服務的事件來完成自己的工作。所以這是完全去中心化的協調。
- 編排(Orchestration)。一個單一的協調者指示所有服務以正確的順序執行各自的工作。
選擇哪種協調模式取決於業務需求與目標。編舞解法的挑戰是服務以完全非同步的方式溝通,因此每個服務都必須維護一個內部狀態機(state machine),以理解當其他服務發出事件時應該做什麼。當服務眾多時,這會變得難以管理。
編排解法處理複雜性的能力較佳,因此通常是數位錢包系統中偏好的解法。
TC/C 與 Saga 的比較
TC/C 與 Saga 都是應用層級的分散式交易。表 5 摘要它們的相似之處與差異。
| TC/C | Saga | |
|---|---|---|
| 補償動作 | 在 Cancel 階段 | 在 rollback 階段 |
| 中央協調 | 是 | 是(編排模式) |
| 操作執行順序 | 任意 | 線性 |
| 並行執行可能性 | 是 | 否(線性執行) |
| 可看到部分不一致狀態 | 是 | 是 |
| 應用或資料庫邏輯 | 應用 | 應用 |
表 5 TC/C vs Saga
實務上我們應該用哪一個?答案取決於延遲需求。如表 5 所示,Saga 中的操作必須以線性順序執行,但 TC/C 中可以並行執行。所以決定取決於幾個因素:
- 如果沒有延遲需求,或服務數量很少(例如我們的轉帳例子),任何一個都可以選。如果我們想跟上微服務架構的趨勢,就選 Saga。
- 如果系統對延遲敏感且包含許多服務/操作,TC/C 可能是更好的選擇。
應徵者:為了讓餘額轉帳具有交易性,我們把 Redis 換成關聯式資料庫,並使用 TC/C 或 Saga 實作分散式交易。
面試官:很好!分散式交易解法可以運作,但有些情況下它運作不佳。例如,使用者可能在應用層輸入錯誤的操作,這時我們指定的金額可能不對。我們需要一種方式來追溯問題根源並稽核所有帳戶操作。我們如何做到?
Event sourcing#
Background#
在現實中,數位錢包提供商可能會被稽核。這些外部稽核員可能會問一些有挑戰性的問題,例如:
- 我們知道任何給定時間點的帳戶餘額嗎?
- 我們如何知道歷史與目前的帳戶餘額是正確的?
- 在程式碼變更後,我們如何證明系統邏輯仍是正確的?
一種可以系統化回答這些問題的設計哲學是事件溯源(event sourcing),這是領域驅動設計(Domain-Driven Design, DDD) [9] 中發展出來的技術。
Definition#
事件溯源中有四個重要術語:
- Command(指令)
- Event(事件)
- State(狀態)
- State machine(狀態機)
Command
Command 是來自外界的預期動作。例如,如果我們想把 1 美元從客戶 A 轉到客戶 C,這個轉帳請求就是一個 command。
在事件溯源中,所有事物都有順序非常重要。所以 commands 通常被放入 FIFO(先進先出)佇列中。
Event
Command 是一種意圖(intention)而非事實(fact),因為某些 commands 可能無效且無法被滿足。例如,如果轉帳後帳戶餘額會變成負數,轉帳操作就會失敗。
Command 在做任何事之前必須被驗證。一旦 command 通過驗證,它就是有效的且必須被滿足。滿足的結果稱為 event。
Command 與 event 之間有兩個主要差別:
- Events 必須被執行,因為它們代表已驗證的事實。實務上我們通常使用過去式來表示 event。如果 command 是「把 1 美元從 A 轉到 C」,對應的 event 會是「已把 1 美元從 A 轉到 C」(transferred)。
- Commands 可能包含隨機性或 I/O,但 events 必須是確定性的(deterministic)。Events 代表歷史事實。
事件產生過程有兩個重要性質:
- 一個 command 可能產生任意數量的 events,可能產生零個或多個 events。
- 事件產生可能包含隨機性,意思是無法保證一個 command 總是產生相同的 event(s)。事件產生可能包含外部 I/O 或亂數。我們會在本章末再回頭詳細探討這個性質。
Events 的順序必須遵循 commands 的順序。所以 events 同樣存在 FIFO 佇列中。
State
State 是 event 被套用時將被改變的東西。在錢包系統中,state 是所有客戶帳戶的餘額,可以用 map 資料結構表示。Key 是帳戶名稱或 ID,value 是帳戶餘額。Key-value 儲存通常用來儲存 map 資料結構。關聯式資料庫也可以視為 key-value 儲存,其中 key 是主鍵,value 是表的列。
State machine
State machine 驅動事件溯源流程。它有兩個主要功能:
- 驗證 commands 並產生 events。
- 套用 event 以更新 state。
事件溯源要求 state machine 的行為必須是確定性的。因此,state machine 本身永遠不應包含任何隨機性。例如,它永遠不應使用 I/O 從外界讀取任何隨機東西,或使用任何亂數。當它把 event 套用到 state 時,應該總是產生相同的結果。
圖 14 顯示事件溯源架構的靜態檢視。state machine 負責把 command 轉換為 event,並套用該 event。因為 state machine 有兩個主要功能,我們通常畫出兩個 state machines,一個用於驗證 commands,另一個用於套用 events。
如果加上時間維度,圖 15 顯示事件溯源的動態檢視。系統不斷接收 commands 並逐一處理。
Wallet service example#
對錢包服務而言,commands 就是餘額轉帳請求。這些 commands 被放入 FIFO 佇列中。command 佇列的一個熱門選擇是 Kafka [10]。command 佇列如圖 16 所示。
讓我們假設 state(帳戶餘額)儲存在關聯式資料庫中。state machine 以 FIFO 順序逐一檢視每個 command。對每個 command,它會檢查帳戶是否有足夠的餘額。如果有,state machine 為每個帳戶產生一個 event。例如,如果 command 是「A->$1->C」,state machine 會產生兩個 events:「A:-$1」與「C:+$1」。
圖 17 顯示 state machine 如何在 5 個步驟中運作。
- 從 command 佇列讀取 commands。
- 從資料庫讀取餘額狀態。
- 驗證 command。如果有效,為每個帳戶產生兩個 events。
- 讀取下一個 Event。
- 透過更新資料庫中的餘額來套用 Event。
Reproducibility#
事件溯源相較於其他架構最重要的優勢是可重現性(reproducibility)。
在前面提到的分散式交易解法中,錢包服務把更新後的帳戶餘額(state)存到資料庫,很難知道帳戶餘額為什麼被改變。同時,歷史餘額資訊在更新操作中遺失了。在事件溯源設計中,所有變更先以不可變的歷史形式儲存。資料庫只用來作為任何給定時間點餘額的最新檢視。
我們始終可以從一開始重播 events 來重建歷史餘額狀態。因為 event 列表是不可變的,且 state machine 邏輯是確定性的,因此可以保證每次重播產生的歷史 states 都相同。
圖 18 顯示如何透過重播 events 來重現錢包服務的 states。
可重現性協助我們回答本節開頭稽核員問的困難問題。我們在這裡重複這些問題。
- 我們知道任何給定時間點的帳戶餘額嗎?
- 我們如何知道歷史與目前的帳戶餘額是正確的?
- 在程式碼變更後,我們如何證明系統邏輯仍是正確的?
對於第一個問題,我們可以從開始重播 events,重播到我們想知道帳戶餘額的時間點。
對於第二個問題,我們可以從 event 列表重新計算帳戶餘額,以驗證其正確性。
對於第三個問題,我們可以針對 events 執行不同版本的程式碼,並驗證它們的結果是否相同。
由於稽核能力,事件溯源常被選為錢包服務的事實上標準解法。
Command-query responsibility segregation (CQRS)#
到目前為止,我們設計了錢包服務以有效率地把錢從一個帳戶移到另一個帳戶。然而,客戶仍不知道帳戶餘額是多少。需要有一種方式來發布 state(餘額資訊),讓事件溯源框架之外的客戶端能知道 state 是什麼。
直覺上,我們可以建立一個資料庫的唯讀副本(歷史 state)並與外界分享。事件溯源以稍微不同的方式回答這個問題。
事件溯源不發布 state(餘額資訊),而是發布所有 events。外界可以自行重建任何客製化的 state。這種設計哲學稱為 CQRS [11]。
在 CQRS 中,有一個 state machine 負責 state 的寫入部分,但可以有許多唯讀的 state machines,負責建構 states 的檢視。這些檢視可被用於查詢。
這些唯讀的 state machines 可以從 event 佇列衍生出不同的 state 表示。例如:
- 客戶可能想知道他們的餘額,唯讀的 state machine 可以把 state 存在資料庫中以服務餘額查詢。
- 另一個 state machine 可以為特定時間段建立 state,以協助調查可能的雙重扣款等問題。state 資訊是稽核軌跡,可協助對帳金融紀錄。
唯讀 state machines 會稍微落後,但總會追上。架構設計是**最終一致(eventually consistent)**的。
圖 19 顯示經典的 CQRS 架構。
應徵者:在這個設計中,我們使用事件溯源架構讓整個系統可重現。所有有效的業務紀錄都儲存在不可變的 Event 佇列中,可用於正確性驗證。
面試官:很好。但你提出的事件溯源架構一次只處理一個 event,而且需要與多個外部系統通訊。我們可以讓它更快嗎?
Step 3 - Design Deep Dive#
本節中,我們深入探討達成高效能、高可靠性與高擴展性的技術。
High-performance event sourcing#
在前面的範例中,我們使用 Kafka 作為 command 與 event 的儲存,使用資料庫作為 state 的儲存。讓我們探索一些優化。
File-based command and event list#
第一個優化是把 commands 與 events 儲存到本地磁碟,而非遠端儲存(如 Kafka)。這避免了跨網路的傳輸時間。event 列表使用僅可附加的資料結構。
附加是循序寫入操作,通常非常快。即便對於機械硬碟也運作良好,因為作業系統對循序讀寫做了大量優化。根據這篇 ACM Queue 文章 [12],循序磁碟存取在某些情況下可以比隨機記憶體存取更快。
第二個優化是把最近的 commands 與 events 快取在記憶體中。如前所述,我們在 commands 與 events 被持久化後立即處理它們。我們可以把它們快取在記憶體中,省去從本地磁碟載回的時間。
我們將探索一些實作細節。一種稱為 mmap [13] 的技術非常適合實作上述優化。Mmap 可以同時把資料寫到本地磁碟並把最近的內容快取在記憶體中。它把磁碟檔案映射為記憶體中的陣列。作業系統在記憶體中快取檔案的某些區段,以加速讀寫操作。對於僅附加的檔案操作,幾乎可以保證所有資料都儲存在記憶體中,速度非常快。
圖 20 顯示基於檔案的 command 與 event 儲存。
File-based state#
在先前的設計中,state(餘額資訊)儲存在關聯式資料庫中。在生產環境,資料庫通常跑在獨立的伺服器上,只能透過網路存取。類似於我們對 command 與 event 所做的優化,state 資訊也可以存到本地磁碟中。
更具體地說,我們可以使用以下兩種選擇:
- SQLite [14](基於檔案的本地關聯式資料庫)
- RocksDB [15](本地基於檔案的 key-value 儲存)
選擇 RocksDB 是因為它使用日誌結構合併樹(log-structured merge-tree, LSM),對寫入操作有優化。為了提升讀取效能,最近的資料會被快取。
圖 21 顯示 command、event 與 state 的基於檔案解法。
Snapshot#
一旦一切都基於檔案,讓我們考慮如何加速可重現性流程。當我們首次介紹可重現性時,state machine 每次都必須從一開始處理 events。我們可以優化的是定期停止 state machine 並把目前 state 存到檔案中,這就稱為 snapshot(快照)。
Snapshot 是歷史 state 的不可變檢視。Snapshot 一旦被儲存,state machine 就不必再從一開始重啟。它可以從 snapshot 讀取資料,驗證它停在哪裡,並從那裡恢復處理。
對於錢包服務這類金融應用,財務團隊通常要求在 00:00 拍攝 snapshot,這樣他們可以驗證當天發生的所有交易。當我們首次介紹事件溯源的 CQRS 時,解法是設置一個唯讀 state machine,從開始讀取直到指定時間為止。有了 snapshot,唯讀 state machine 只需要載入一個包含資料的 snapshot。
Snapshot 是一個巨大的二進位檔,常見的解法是把它存在物件儲存解決方案中,例如 HDFS [16]。
圖 22 顯示基於檔案的事件溯源架構。當一切都基於檔案時,系統可以充分利用電腦硬體的最大 I/O 吞吐量。
應徵者:我們可以重構事件溯源的設計,讓 command 列表、event 列表、state 與 snapshot 都儲存在檔案中。事件溯源架構以線性方式處理 event 列表,這非常適合硬碟與作業系統快取的設計。
面試官:本地基於檔案的解法效能比需要存取遠端 Kafka 與資料庫的系統更好。但有另一個問題:因為資料儲存在本地磁碟上,伺服器現在是有狀態的,並成為單點故障。我們如何提升系統的可靠性?
Reliable high-performance event sourcing#
在解釋解法之前,讓我們看看系統哪些部分需要可靠性保證。
Reliability analysis#
概念上,一個節點所做的事情都圍繞兩個概念:資料與運算。只要資料是持久的,就可以透過在另一個節點上執行相同的程式碼來輕易恢復運算結果。
我們只需要擔心資料的可靠性,因為一旦資料遺失,它就永遠遺失了。系統的可靠性主要在於資料的可靠性。
我們的系統中有四種資料類型:
- 基於檔案的 command
- 基於檔案的 event
- 基於檔案的 state
- State snapshot
讓我們仔細看看如何確保每種資料類型的可靠性。
State 與 snapshot 始終可以透過重播 event 列表來重新產生。要提升 state 與 snapshot 的可靠性,我們只需確保 event 列表具有強可靠性。
現在讓我們檢視 command。表面上,event 是從 command 產生的。我們可能認為為 command 提供強可靠性保證就足夠了。乍看之下這似乎正確,但忽略了一些重要的事情。事件產生不保證是確定性的,且可能包含隨機因素,例如亂數、外部 I/O 等。所以 command 無法保證 events 的可重現性。
現在該仔細看看 event 了。Event 代表引入 state(帳戶餘額)變更的歷史事實。Event 是不可變的,可被用來重建 state。
根據此分析,我們得出結論:event 資料是唯一需要高可靠性保證的。我們將在下一節說明如何達成。
Consensus#
為了提供高可靠性,我們需要把 event 列表複製到多個節點上。在複製過程中,我們必須保證以下性質:
- 沒有資料遺失。
- log 檔案中資料的相對順序在各節點上保持一致。
要達成這些保證,基於共識(consensus-based)的複製是個好選擇。共識演算法確保多個節點對 event 列表是什麼達成共識。讓我們以 Raft [17] 共識演算法為例。
Raft 演算法保證只要超過半數的節點在線上,這些節點上的僅可附加列表就會擁有相同的資料。例如,如果我們有 5 個節點並使用 Raft 演算法同步它們的資料,只要至少 3 個(超過半數)節點在線(如圖 23 所示),系統作為一個整體仍可以正常運作:
Raft 演算法中一個節點可以有三種不同的角色:
- Leader
- Candidate
- Follower
我們可以在 Raft 論文中找到 Raft 演算法的實作。我們在此只涵蓋高層次概念,不深入細節。在 Raft 中,叢集中最多有一個節點是 leader,其餘節點是 followers。Leader 負責接收外部 commands 並可靠地把資料複製到叢集中的各節點。
有了 Raft 演算法,只要多數節點正常運作,系統就是可靠的。例如,如果叢集中有 3 個節點,可容忍 1 個節點失敗;如果有 5 個節點,可容忍 2 個節點失敗。
Reliable solution#
有了複製,我們的基於檔案事件溯源架構就不會有單點故障。讓我們看看實作細節。圖 24 顯示具備可靠性保證的事件溯源架構。
在圖 24 中,我們設置了 3 個事件溯源節點。這些節點使用 Raft 演算法可靠地同步 event 列表。
Leader 接受來自外部使用者的 command 請求,把它們轉換為 events,並把 events 附加到本地 event 列表。Raft 演算法把新加入的 events 複製到 followers。
所有節點(包括 followers)都處理 event 列表並更新 state。Raft 演算法確保 leader 與 followers 擁有相同的 event 列表,而事件溯源保證只要 event 列表相同,所有 states 就會相同。
可靠的系統需要優雅地處理失敗,所以讓我們探索如何處理節點當機。
如果 leader 當機,Raft 演算法會自動從剩餘的健康節點中選出新的 leader。這個新選出的 leader 接手接受外部使用者的 commands。整個叢集可以保證在節點當機時繼續提供服務。
當 leader 當機時,可能在 command 列表被轉換為 events 之前就發生當機。在這種情況下,客戶端會透過逾時或收到錯誤回應注意到問題。客戶端需要把同樣的 command 重新發送給新選出的 leader。
相對地,follower 當機就容易處理得多。如果一個 follower 當機,發送給它的請求會失敗。Raft 透過無限重試直到當機節點重啟或被新的節點取代來處理失敗。
應徵者:在這個設計中,我們使用 Raft 共識演算法把 event 列表複製到多個節點上。Leader 接收 commands 並把 events 複製到其他節點。
面試官:是的,系統更可靠也更具容錯能力。但是要處理 100 萬 TPS,一台伺服器是不夠的。我們如何讓系統更具擴展性?
Distributed event sourcing#
在前一節中,我們解釋了如何實作可靠的高效能事件溯源架構。它解決了可靠性問題,但有兩個限制:
- 當數位錢包被更新時,我們希望立即收到更新結果。但在 CQRS 設計中,請求/回應流可能很慢,因為客戶端不知道數位錢包確切何時被更新,可能需要依賴定期輪詢。
- 單一 Raft 群組的容量是有限的。在某種規模下,我們需要分片資料並實作分散式交易。
讓我們看看這兩個問題如何解決。
Pull vs push#
在 pull 模型中,外部使用者定期從唯讀 state machine 輪詢執行狀態。這種模型不是即時的,且如果輪詢頻率設得太高可能會讓錢包服務過載。圖 25 顯示 pull 模型。
樸素的 pull 模型可以透過在外部使用者與事件溯源節點之間加上反向代理(reverse proxy) [18] 來改善。在這個設計中,外部使用者把 command 送到反向代理,反向代理把 command 轉發到事件溯源節點,並定期輪詢執行狀態。這個設計簡化了客戶端邏輯,但通訊仍非即時。
圖 26 顯示加上反向代理的 pull 模型。
一旦有了反向代理,我們可以透過修改唯讀 state machine 讓回應更快。如前所述,唯讀 state machine 可以有自己的行為。例如,一個行為可以是唯讀 state machine 一收到 event 就把執行狀態 push 回反向代理。這會給使用者一種即時回應的感覺。
圖 27 顯示基於 push 的模型。
Distributed transaction#
一旦每個事件溯源節點群組都採用了同步執行,我們就可以重用分散式交易解法 TC/C 或 Saga。假設我們透過把 key 的 hash 值除以 2 來分區資料。
圖 28 顯示更新後的設計。
讓我們看看在最終的分散式事件溯源架構中,轉帳如何運作。為了更容易理解,我們使用 Saga 分散式交易模型,且只解釋沒有任何回滾的快樂路徑。
轉帳操作包含 2 個分散式操作:A-$1 與 C+$1。Saga 協調者協調其執行,如圖 29 所示:
- 使用者 A 把分散式交易送給 Saga 協調者。它包含兩個操作:A-$1 與 C+$1。
- Saga 協調者在 phase status 表中建立一筆紀錄以追蹤交易狀態。
- Saga 協調者檢視操作順序,並判斷它需要先處理 A-$1。協調者把 A-$1 作為 command 送到 Partition 1,Partition 1 包含帳戶 A 的資訊。
- Partition 1 的 Raft leader 收到 A-$1 command 並把它存到 command 列表中。然後它驗證 command。如果有效,它被轉換為 event。Raft 共識演算法用於把資料同步到不同節點上。同步完成後 event(從 A 帳戶餘額扣除 1 美元)被執行。
- event 同步後,Partition 1 的事件溯源框架使用 CQRS 把資料同步到讀取路徑。讀取路徑重建 state 與執行狀態。
- Partition 1 的讀取路徑把狀態 push 回事件溯源框架的呼叫者,也就是 Saga 協調者。
- Saga 協調者從 Partition 1 收到成功狀態。
- Saga 協調者在 phase status 表中建立一筆紀錄,表示 Partition 1 中的操作成功。
- 因為第一個操作成功,Saga 協調者執行第二個操作 C+$1。協調者把 C+$1 作為 command 送到 Partition 2,Partition 2 包含帳戶 C 的資訊。
- Partition 2 的 Raft leader 收到 C+$1 command 並把它存到 command 列表中。如果有效,它被轉換為 event。Raft 共識演算法用於把資料同步到不同節點上。同步完成後 event(在 C 的帳戶上加 1 美元)被執行。
- event 同步後,Partition 2 的事件溯源框架使用 CQRS 把資料同步到讀取路徑。讀取路徑重建 state 與執行狀態。
- Partition 2 的讀取路徑把狀態 push 回事件溯源框架的呼叫者,也就是 Saga 協調者。
- Saga 協調者從 Partition 2 收到成功狀態。
- Saga 協調者在 phase status 表中建立一筆紀錄,表示 Partition 2 中的操作成功。
- 此時所有操作都成功,分散式交易完成。Saga 協調者以結果回應其呼叫者。
Step 4 - Wrap Up#
本章中,我們設計了一個能每秒處理超過 100 萬筆支付指令的錢包服務。經過粗略估算,我們得出需要數千個節點來支援這樣的負載。
各階段設計演進摘要如下:
- 第一個設計提出使用記憶體 key-value 儲存(如 Redis)的解法。這個設計的問題是資料不持久。
- 第二個設計把記憶體快取替換為交易型資料庫。為了支援多個節點,提出了不同的交易協定,例如 2PC、TC/C 與 Saga。基於交易解法的主要問題是我們無法輕易進行資料稽核。
- 接下來,介紹了事件溯源。我們先使用外部資料庫與佇列實作事件溯源,但效能不佳。我們透過把 command、event 與 state 儲存在本地節點來改善效能。
- 單一節點意味著單點故障。為了提升系統可靠性,我們使用 Raft 共識演算法把 event 列表複製到多個節點上。
- 我們做的最後一個強化是採用事件溯源的 CQRS 特性。我們加上反向代理把非同步事件溯源框架對外部使用者轉變為同步。TC/C 或 Saga 協定用於協調多個節點群組之間的 Command 執行。
恭喜你看到這裡!給自己一個鼓勵吧,做得好!
Chapter Summary#
