一種分散式預寫日誌系統

charlieroro發表於2021-07-27

Waltz 一種分散式預寫日誌系統

本文講述了一種分佈預寫式日誌系統Waltz,文中介紹了在實現預寫式日誌系統時遇到的問題及其解決方案,可以為類似的需求提供一定的啟發。

譯自:Waltz: A Distributed Write-Ahead Log

簡介

Waltz 是一種分散式預寫式日誌(WAL)系統,一開始它被設計為WePay系統上的貨幣交易賬簿,但後續延申到需要序列化一致性的分散式系統場景中。Waltz 與現有的日誌系統(如Kafka)類似,接收/持久化/傳遞 由很多服務 產生/消費 的事務資料。但與其他系統不同的是,Waltz 提供了一種在分散式應用中序列化一致性的機制。它會在事務提交到日誌前進行衝突檢測(這也是為什麼需要自己實現的原因,它對應用有一定的侵入性)。Waltz 作為單一的事實源頭(而非資料庫),可以實現以日誌為中心的系統架構。

背景

資料庫

隨著WePay系統的增長,需要處理的流量和功能點也越來越多。我們將一個大型服務分割成多個合理的小服務來更好地管理系統。每個服務通常都有各自的資料庫,為了隔離性,不會在服務間共享資料庫。

當出現如網路故障、處理故障和機器故障時,並不需要保證所有資料庫的一致性。服務間通過網路進行互動,互動通常會更新兩端的資料庫。而故障可能會導致資料庫之間的不一致。大部分不一致可以通過守護程式進行修復,如週期性地進行檢修操作,但並不是所有的操作都可以自動執行,有時也需要人工接入。

此外,使用資料庫副本來進行容錯。我們使用MySQL的非同步複製功能,當主域下線後,會切換域,備用域會接手處理,這樣就可以繼續處理支付業務。多域複製也有自己的問題,主資料庫的更新並不會立即反映到備資料庫中,兩者之間總會存在延遲,且複製延遲也是常態。無法保證新的資料庫中包含所有需要更新的資料,也無法保證這些資料能夠正常同步。

流處理

我們在很多地方引入了非同步處理,並期望推遲那些不需要立即保持一致的更新操作,這樣做可以使事務處理變得輕量化,並提升響應和吞吐量。我們使用面向流處理的Kafka來實現這些功能。在一個服務更新自己的資料庫的同時,將訊息寫入Kafka。然後在消費Kafka訊息的同時,該服務或其他服務會非同步執行其他資料庫的更新操作。這種方式行得通,但缺點是一個服務必須寫入兩個獨立的儲存系統:資料庫和Kafka,此外仍然需要檢修。

基本思想

Waltz 中記錄的日誌既不是從資料庫捕獲的資料變更輸出,也不是來自應用的次級輸出,而是系統狀態轉換的主要資訊(可以理解為第一時間獲得的資料變更)。它不同於圍繞資料庫系統構建的典型事務系統(資料庫作為事實源頭)。在新的模型中,日誌作為了事實源頭(主要資訊),而資料庫則衍生自日誌(次級資訊)。現在的挑戰是如何保證資料庫中的所有資料與日誌保持一致,以及保證序列化的資料的準確性。

如何保證資料庫和日誌的一致性?保證最終一致性相對比較簡單,因為日誌中記錄的事務是有序且不可變的。如果應用以相同的順序應用到自身的資料庫中,那麼結果也是明確的。下面描述了基本的思想:

  1. 應用構造事務訊息,訊息中包含對期望變更的資料的描述
  2. 應用將其傳送到Waltz,此時應用還沒有更新資料庫
  3. Waltz接收到事務訊息後,將其持久化到Waltz日誌
  4. Waltz將事務訊息返回給應用
  5. 應用接收到事務訊息,並將資料變更應用到其資料庫

下面是一個應用從資料庫中讀取V=x,並更新到V=y的例子

一種分散式預寫日誌系統

Waltz 日誌包含所有的資料變更。通過Waltz的訊息(事務資料)來更新應用資料庫。因此,Waltz 是主要資訊的所有者、事實的源頭。而服務的資料庫為衍生的資訊,可以認為是Waltz 日誌物化後的檢視。

這樣使得應用能夠容錯。如果一個應用在步驟5之前失敗了,此時Waltz 中已經持久化了事務資訊,但應用無法更新其資料庫,Waltz 將會在重啟應用程式之後再次傳送事務訊息。應用在接收到來自Waltz 的剩餘的事務訊息之後恢復資料庫。

這種設計使得資料複製和共享非常簡單。Waltz 允許多個客戶端讀取和寫入相同的日誌。可以通過應用Waltz 的訊息進行資料複製,且根據應用的需要,相同的事務資料可以用於不同的目的,而無需變更其他應用。它允許在不增加溝通和協調複雜度的前提下,將一個服務劃分為更小的服務。

這聽起來很不錯,但如果考慮一下在可能嘗試並行進行資料更新的分散式環境中時,就會意識到保證資料的完整性並沒有那麼容易。這種場景下多個客戶端可能會提交衝突的事務。如果不理會一致性,對所有訊息做持久化的話,將必須依賴後處理來解決這些衝突。可能會使用一個資料庫進行去重和完整性校驗。最終可能會拒絕錯誤的訊息,並向上遊服務通知訊息的處理狀態,併產生一個新的"已清理"日誌。這會增加系統設計的複雜度。並增加資源消耗和延遲。最終仍然無法保證後處理資料庫和"已清理"日誌的一致性。問題又回到了起點。這是使用現有日誌系統無法解決的主要難點,這也是為什麼我們要實現自己的日誌系統,Waltz,可以在第一時間防止發生日誌與事務記錄不一致的情況。

現有日誌系統的難點

在進入細節前,我們展示一下現有使用簡單的key-value儲存作為日誌系統的難點。

讀-修改-寫的難點

為了使日誌作為事實源頭,需要在更新key-value儲存之前寫入日誌。服務將新資料發往日誌系統,並在接收到日誌的新訊息之後,將新資料存到KV儲存中。假設新資料通過對key-value儲存中現有資料的計算而來,那麼如何保證更新的正確性?為了正確更新,必須讀取最新的資料。但問題是由於存在延遲,KV儲存中的資料可能無法反映日誌中最新的更新。

假設有一個簡單的計數器服務,它將結果儲存在KV儲存中:

  1. 應用發生一個INCREMENT 到服務
  2. 服務讀取當前KV儲存中的值
  3. 服務傳送"當前值+1"到日誌
  4. 在接收到日誌的新訊息後,服務更新KV儲存中的計數器值

當服務接同時接收到另一個INCREMENT 請求時會發生競爭。如果在服務完成第一個請求的步驟4前處理了第一個請求的步驟2,則第一個請求會被第二個請求覆蓋。最終,兩個INCREMENT 請求只增加了一次。

一種分散式預寫日誌系統

實現約束的難度

在上述場景中,你可能認為訊息不應該記錄新計算的結果,而應該是差值,如"+1"。由於服務以單一執行緒的方式消費日誌訊息,且由於服務接收到的是兩個"+1"訊息,因此可以正確計算計數器的值。現在假設需要在計數器值上實現一個限制,如"計數器值不能為負"。此時問題又來了,由於服務沒有一個可靠的途徑瞭解到真實的當前值(由於競爭),因此無法可靠地實現該限制條件。

一種分散式預寫日誌系統

重複訊息

重複訊息是一個大問題。你不會期望在單次採購時,支付系統中記錄了重複的付款。如果一個日誌寫入失敗,則需要應用重試。然而應用無法知道哪個寫入環節出現了問題。訊息可能也可能不會持久化到日誌。相同的訊息僅會被日誌系統採納一次。換句話說,日誌系統需要冪等。使用現有日誌系統的簡單方案是給訊息附帶一個唯一的Id,並過濾掉重複的訊息。永久保留對所有唯一ID的對映將是一個巨大的負擔。這類系統通常會使用保留策略來降低資料量。保留策略週期通常會足夠長,以確保不可能發生誤刪。但"不可能"並不可靠。如何保證冪等?

我們的方案

Waltz 通過一種熟知的方法,樂觀鎖來解決上述問題。

樂觀鎖

應用可以在事務訊息中附帶鎖。一個鎖包含鎖ID和模式。鎖IDs是應用定義的。實際中會指派給某些實體,如支付或賬戶等。但Waltz 並不知道IDs代表什麼。應用可以決定鎖的粒度。Waltz 支援兩種鎖模式,READ和WRITE。READ模式意味著事務基於一個鎖ID代表的實體的狀態。WRITE模式意味著事務會根據實體的當前狀態來更新狀態。

在解釋Waltz 中的樂觀鎖的工作方式之前,我們需要描述Waltz 中的一些關鍵概念,事務ID、客戶端高水位標記、鎖表、鎖高水位標記以及鎖相容性測試。

事務ID是一個分配給成功持久化的事務的(唯一的)64位整數ID。在提交一個新的事務後,會增加事務ID。事務ID在Waltz 的樂觀鎖中扮演重要角色。

客戶端高水位標記是客戶端應用應用到其資料庫的最大事務ID。

客戶端傳遞給日誌系統的客戶端高水位標記 應該大於或等於鎖高水位標記,此時表示客戶端的資料比日誌系統的資料新,可以更新日誌系統的資料。反之則表示客戶端的資料比日誌系統的資料舊,無法更新覆蓋。

Waltz 內部管理著鎖表,它是一個鎖ID到事務ID的對映。當鎖的事務訊息處於WRITE模式時,鎖表會返回一個給定鎖ID對應的最新事務ID,稱為鎖高水位標記(對映實際是一個大小固定的隨機資料結構,給出給定鎖ID的最後一次成功的事務的預估事務ID)。預估的事務ID應該等於或大於真實的事務ID。

通過比較客戶端高水位標記和鎖高水位標記來執行鎖相容性測試。對於一個給定的鎖ID,如果客戶端高水位標記等於或大於鎖高水位標記時,則說明鎖是相容的。

當處理WRITE模式的訊息附帶一個鎖ID時,將會發生如下步驟:

  1. 客戶端傳送一條事務訊息,包含客戶端高水位標記
  2. Waltz 使用一個鎖ID接收該訊息
  3. Waltz 查詢鎖表,並執行鎖相容性測試
  4. 如果測試失敗,Waltz 會拒絕該訊息
  5. 如果測試成功,Waltz 會分配一個新的事務ID,並將訊息寫入日誌。
    1. 如果寫入失敗,Waltz 不會更新鎖表
    2. 如果寫入成功,Waltz 會使用新的事務ID更新鎖表

鎖相容性測試失敗意味著什麼?當失敗時,客戶端高水位標記會低於鎖高水位標記。意味著應用還沒有消費這條更新鎖高水位標記的事務。因此,事務由舊資料構成,不能接收該事務。

可以使用樂觀鎖探測前面討論的競爭條件。假設兩個客戶端在相同的時間使用相同的寫鎖傳送了訊息。一個有趣的場景是當這兩個客戶端的高水位標記相同且同時相容鎖高水位標記時,當Waltz 服務首先處理其中一條訊息時,它會通過相容性測試(因為其客戶端高水位標記與鎖高水位標記相同)。在提交後,鎖表項會更新到新的事務ID。此時第二個訊息將會失敗,因為鎖高水位標記高於客戶端高水位標記。

一種分散式預寫日誌系統

限制和要求

樂觀鎖能很好地適應我們的場景,但並不意味著它是一個萬能的解決方案。需要對應用設計作特定的限制和要求。

我們的場景中不存在長期的事務。一個事務必須打包到一個單獨的Waltz 訊息中。一個事務不能跨多個訊息。這並不意味著一個事務侷限為一個單獨的資料操作。一個應用可以在一條訊息中包含多個資料操作(作為一個原子操作)。當一個應用消費這類訊息時,該訊息會對映為在單個SQL事務中執行的多個DML語句。

我們要求一個應用有一個如SQL資料庫這樣的事務資料儲存。資料庫作為Waltz 事務日誌物化後的檢視。應用消費來自Waltz 的事務訊息,根據應用的需求,該訊息可能會也可能不會應用到資料庫中。Waltz 不會強制任何特定的資料庫模式,應用可以定義自己的模式。此外應用資料庫必須儲存高水位標記(服務消費的最大事務ID)。

其他常規分散式系統的東西

叢集

Waltz 是一個分散式系統。一個Waltz 叢集包含服務節點,儲存節點和客戶端。客戶端跑在應用程式中。一個服務節點作為客戶端和儲存節點之間的代理和快取。一個客戶端會向服務節點傳送事務訊息,然後服務節點將其寫入到多個儲存節點中(為了持久和容錯)。可以使用ZooKeeper來管理叢集,由ZooKeepe來跟蹤服務程式。Zookeeper也可以作為共享副本狀態的後設資料的儲存。

一種分散式預寫日誌系統

分割槽

Waltz 日誌使用分割槽來保證可擴充套件性。由應用來控制事務到分割槽的關係。分割槽使用獨立的日誌,每個分割槽使用獨立的鎖。

服務節點負責協調對儲存節點的寫入操作。每個服務節點負責一個分割槽子集。每個服務節點會負責一個分割槽。當一個服務節點出故障後,Waltz 會自動將失敗的服務節點的分割槽重新分配給剩餘的服務節點,並啟動恢復處理。客戶端也會感知到分割槽變更,這樣後續會向正確的服務進行寫操作。

複製協議

Waltz 使用仲裁寫入(quorum write)來進行日誌複製。當一個主儲存節點確認寫入成功後會提交一個事務,仲裁寫入無法構建一個一致的分散式系統。Waltz 使用Zookeeper進行leader選舉,生成唯一ID、故障檢測和後設資料儲存等。此外,Waltz實現了一個類似Multi-Paxos和Raft 的協議來保證儲存節點中日誌的一致性。

對於每個分割槽,會選舉一個服務作為分割槽的所有者,負責分割槽的讀寫。使用ZooKeeper來選舉分割槽所有者。儲存節點被動參與協議,它們不需要跟ZooKeeper進行互動,由分割槽所有者(服務)決定它們的動作。

我們在ZooKeeper中儲存了少量關於儲存狀態的後設資料(用於恢復)。在服務分配到分割槽或發生故障時,服務會執行恢復流程。在恢復完成前,客戶端的所有寫請求都將被阻塞。Waltz服務僅會在同步的副本中路由寫請求,並在後臺繼續修復非同步的副本。

未完成的特性和後續工作

我們的需求是將所有事務作為不可變歷史進行儲存,因此我們沒有一個日誌保留策略,不會刪除老的記錄。類似地,我們也沒有基於日誌的壓縮功能(如kafka)。我們設定沒有表項key的概念。在儲存節點中儲存所有的事務記錄並不經濟,因此我們需要一種方式來方便對老的記錄進行歸檔。

Topics

Waltz 沒有Kafka的topic概念。Waltz 是一個單topic系統。目前還不支援多topic功能。我們使用獨立的叢集來對topic進行隔離。

工具

目前已經有一個CLI工具,待實現GUI 工具。

代理/快取

我們考慮在每個域中增加一個代理/快取。可以加速事務資料的傳遞,並降低跨域呼叫。

相關文章