一種分佈式預寫日誌系統

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 工具。

代理/緩存

我們考慮在每個域中增加一個代理/緩存。可以加速事務數據的傳遞,並降低跨域調用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章