Replication(下):事務,一致性與共識

語言: CN / TW / HK

總第531

2022年 第048篇

本文主要介紹事務、一致性以及共識,首先會介紹它們怎麼在分散式系統中起作用,然後將嘗試描述它們之間的內在聯絡,讓大家瞭解,在設計分散式系統時也是有一定的“套路”可尋。最後,會介紹業界驗證分散式演算法的一些工具和框架,希望能夠對大家有所幫助或者啟發。

  • 1. 前文回顧

  • 2. 本文簡介

  • 3. 事務&外部一致性

    • 3.1 事務的產生

    • 3.2 不厭其煩——ACID特性

    • 3.3 事務按操作物件的劃分&&安全的提交重試

    • 3.4 弱隔離級別

    • 3.5 本章小結

  • 4. 內部一致性與共識

    • 4.1 複製滯後性的問題

    • 4.2 內部一致性概述

    • 4.3 舉一反三:分散式系統中的內部一致性

    • 4.4 我們口中的“強一致性”——線性一致性

    • 4.5 什麼時候需要依賴線性化?

    • 4.6 實現線性化系統

    • 4.7 共識

    • 4.8 本章小結

  • 5. 再談分散式系統

  • 6. 士別三日,當刮目相看——再看Kafka

  • 7. 分散式系統驗證框架

1. 前文回顧

在上一篇中,我們主要介紹了分散式系統中常見的複製模型,並描述了每一種模型的優缺點以及使用場景,同時闡述了分散式系統中特有的一些技術挑戰。首先,常見的分散式系統複製模型有3種,分別是主從模型、多主模型以及無主模型。此外,複製從客戶端的時效性來說分為同步複製&&非同步複製,非同步複製具有滯後性,可能會造成資料不一致,因為這個不一致,會帶來各種各樣的問題。

此外,第一篇文章用了“老闆安排人幹活”的例子比喻了分散式系統中特有的挑戰,即 部分失效 以及 不可靠的時鐘 問題。這給分散式系統設計帶來了很大的困擾。似乎在沒有機制做保證的情況下,一個樸素的分散式系統什麼事情都做不了。

在上一篇的最後,我們對分散式系統系統模型做了一些假設,這些假設對給出後面的解決方案其實是非常重要的。首先針對部分失效,是我們需要對系統的超時進行假設,一般我們假設為半同步模型,也就是說一般情況下延遲都非常正常,一旦發生故障,延遲會變得偏差非常大。另外,對於節點失效,我們通常在設計系統時假設為崩潰-恢復模型。最後,面對分散式系統的兩個保證Safty和Liveness,我們優先保證系統是Safety,也就是安全;而Liveness( 活性 )通常在某些前提下才可以滿足。

2. 本文簡介

通過第一篇文章,我們知道了留待我們解決的問題有哪些。那麼這篇文章中,將分別根據我們的假設去解決上述的挑戰。這些保證措施包括事務、一致性以及共識。接下來講介紹它們的作用以及內在聯絡,然後我們再回過頭來審視一下Kafka複製部分的設計,看看一個實際的系統在設計上是否真的可以直接使用那些套路,最後介紹業界驗證分散式演算法的一些工具和框架。接下來,繼續我們的資料複製之旅吧!

3. 事務&外部一致性

說到事務,相信大家都能簡單說出個一二來,首先能本能做出反應出的,應該就是所謂的“ACID”特性了,還有各種各樣的隔離級別。是的,它們確實都是事務需要解決的問題。

在這一章中,我們會更加有條理地理解下它們之間的內在聯絡,詳細看一看事務究竟要解決什麼問題。在《DDIA》一書中有非常多關於資料庫事務的具體實現細節,但本文中會弱化它們,畢竟本文不想詳細介紹如何設計一款資料庫,我們只需探究問題的本身,等真正尋找解決方案時再去詳細看設計,效果可能會更好。下面我們正式開始介紹事務。

3.1 事務的產生

系統中可能會面臨下面的問題:

  1. 程式依託的作業系統層,硬體層可能隨時都會發生故障( 包括一個操作執行到一半時 )。

  2. 應用程式可能會隨時發生故障( 包括操作執行到一半時 )。

  3. 網路中斷可能隨時會發生,它會切斷客戶端與服務端的連結或資料庫之間的連結。

  4. 多個客戶端可能會同時訪問服務端,並且更新統一批資料,導致資料互相覆蓋( 臨界區 )。

  5. 客戶端可能會讀到過期的資料,因為上面說的,可能操作執行一半應用程式就掛了。

假設上述問題都會出現在我們對於儲存系統( 或者資料庫 )的訪問中,這樣我們在開發自己應用程式的同時,還需要額外付出很大代價處理這些問題。事務的核心使命就是嘗試幫我們解決這些問題,提供了從它自己層面所看到的安全性保證,讓我們在訪問儲存系統時只專注我們本身的寫入和查詢邏輯,而非這些額外複雜的異常處理。而說起解決方式,正是通過它那大名鼎鼎的 ACID 特性來進行保證的。

3.2 不厭其煩——ACID特性

這四個縮寫所組成的特性相信大家已形成本能反應,不過《DDIA》一書中給出的定義確實更加有利於我們更加清晰地理解它們間的關係,下面將分別進行說明:

A:原子性(Atomicity) :原子性實際描述的是同一個客戶端對於多個操作之間的限制,這裡的原子表示的是不可分割,原子性的效果是,假設有操作集合{A,B,C,D,E},執行後的結果應該和單個客戶端執行一個操作的效果相同。從這個限制我們可以知道:

  1. 對於操作本身,就算髮生任何故障,我們也不能看到任何這個操作集中間的結果,比如操作執行到C時發生了故障,但是事務應該重試,直到我們需要等到執行完之後,要麼我們應該恢復到執行A之前的結果。

  2. 對於操作作用的服務端而言,出現任何故障,我們的操作不應該對服務端產生任何的副作用,只有這樣客戶端才能安全的重試,否則,如果每次重試都會對服務端產生副作用,客戶端是不敢一直安全的重試的。

因此,對於原子性而言,書中描述說的是能在執行發生異常時丟棄,可以直接終止,且不會對服務端產生任何副作用,可以安全的重試,原子性也成為“可終止性”。

C:一致性(Consistency) :這個名詞有太多的過載,也就是說它在不同語境中含義會截然不同,但可能又有聯絡,這就可能讓我們陷入混亂,比如:

  1. 資料複製時,副本間具有一致性,這個一致性應該指上一章中提到的不同副本狀態的一致。

  2. 一致性Hash,這是一種分割槽演算法,個人理解是為了能夠在各種情況下這個Hash演算法都可以以一致的方式發揮作用。

  3. CAP定理中的一致性指的是後面要介紹的一個特殊的內部一致性,稱為“線性一致性”。

  4. 我們稍後要介紹ACID中的一致性,指的是程式的某些“不變式”,或“良好狀態”。

我們需要區分不同語境中一致性所表達含義的區別,也希望大家看完今天的分享,能更好地幫助大家記住這些區別。話說回來,這裡的一致性指的是對於資料一組特定陳述必須成立,即“不變式”,這裡有點類似於演算法中的“迴圈不變式”,即當外界環境發生變化時,這個不變式一定需要成立。

書中強調,這個裡面的一致性更多需要使用者的應用程式來保證,因為只有使用者知道所謂的不變式是什麼。這裡舉一個簡單的小例子,例如我們往Kafka中append訊息,其中有兩條訊息內容都是2,如果沒有額外的資訊時,我們也不知道到底是客戶端因為故障重試發了兩次,還是真的就有兩條一模一樣的資料。

如果想進行區分,可以在使用者程式消費後走自定義的去重邏輯,也可以從Kafka自身出發,客戶端傳送時增加一個“發號”環節標明訊息的唯一性( 高版本中Kafka事務的實現大致思路 )這樣引擎本身就具備了一定的自己設定“不變式”的能力。不過如果是更復雜的情況,還是需要使用者程式和呼叫服務本身共同維護。

I:隔離性(Isolation) :隔離性實際上是事務的重頭戲,也是門道最多的一環,因為隔離性解決的問題是多個事務作用於同一個或者同一批資料時的併發問題。一提到併發問題,我們就知道這一定不是個簡單的問題,因為併發的本質是時序的不確定性,當這些不確定時序的作用域有一定衝突( Race )時就可能會引發各種各樣的問題,這一點和多執行緒程式設計是類似的,但這裡面的操作遠比一條計算機指令時間長得多,所以問題會更嚴重而且更多樣。

這裡給一個具體的例項來直觀感受下,如下圖展示了兩個客戶端併發的修改DB中的一個counter,由於User2的get counter發生的時刻在User1更新的過程中,因此讀到的counter是個舊值,同樣User2更新也類似,所以最後應該預期counter值為44,結果兩個人看到的counter都是43( 類似兩個執行緒同時做value++ )。

一個完美的事務隔離,在每個事務看來,整個系統只有自己在工作,對於整個系統而言這些併發的事務一個接一個的執行,也彷彿只有一個事務,這樣的隔離成為“可序列化( Serializability )”。當然,這樣的隔離級別會帶來巨大的開銷,因此出現了各種各樣的隔離級別,進而滿足不同場景的需要。後文會詳細介紹不同的隔離級別所解決的問題。

圖1 隔離性問題導致更新丟失

D:永續性(Durability) : 這個特性看似比較好理解,就一點,只要事務完成,不管發生任何問題,都不應該發生資料丟失。從理論上講,如果是單機資料庫,起碼資料已被寫入非易失性儲存( 至少已落WAL ),分散式系統中資料被複制到了各個副本上,並受到副本Ack。但實際情況下,也未必就一定能保證100%的永續性。這裡面的情況書中有詳細的介紹,這裡就不做重複的Copy工作了,也就是說事務所保證的永續性一般都是某種權衡下的結果。

上面四個特性中,實際上對於隔離性的問題,可能是問題最多樣的,也是最為複雜的。因為一味強調“序列化”可能會帶來不可接受的效能開銷。因此,下文將重點介紹一些比可序列化更弱的隔離級別。

3.3 事務按操作物件的劃分&&安全的提交重試

在介紹後面內容前,有兩件事需要事先做下強調,分別是事務操作的物件以及事務的提交與重試,分為單物件&&多物件。

單物件寫入 :這種書中給出了兩種案例。

1. 第一個是單個事物執行一個長時間的寫入,例如寫入一個20KB的JSON物件,假設寫到10KB時斷掉會發生什麼?

a. 資料庫是否會存在10KB沒法解析的髒資料。

b. 如果恢復之後數是否能接著繼續寫入。 

c. 另一個客戶端讀取這個文件,是否能夠看到恢復後的最新值,還是讀到一堆亂碼。

2. 另一種則是類似上圖中counter做自增的功能。

這種事務的解決方法一般是通過日誌回放( 原子性 )、鎖( 隔離性 )、CAS( 隔離性 )等方式來進行保證。

多物件事務 :這類事務實際上是比較複雜的,比如可能在某些分散式系統中,操作的物件可能會跨執行緒、跨程序、跨分割槽,甚至跨系統。這就意味著,我們面臨的問題多於上一篇文章提到的那些分散式系統特有的問題,處理那些問題顯然要更復雜。有些系統乾脆把這種“鍋”甩給使用者,讓應用程式自己來處理問題,也就是說,我們可能需要自己處理因沒有原子性帶來的中間結果問題,因為沒有隔離性帶來的併發問題。當然,也有些系統實現了這些所謂的分散式事務,後文中會介紹具體的實現手段。

另一個需要特別強調的點是重試,事務的一個核心特性就是當發生錯誤時,客戶端可以安全的進行重試,並且不會對服務端有任何副作用,對於傳統的真的實現ACID的資料庫系統,就應該遵循這樣的設計語義。但在實際實踐時,如何保證上面說的能夠“安全的重試”呢?書中給出了一些可能發生的問題和解決手段:

  1. 假設事務提交成功了,但服務端Ack的時候發生了網路故障,此時如果客戶端發起重試,如果沒有額外的手段,就會發生資料重複,這就需要服務端或應用程式自己提供能夠區分訊息唯一性的額外屬性( 服務端內建的事務ID或者業務自身的屬性欄位 )。

  2. 由於負載太大導致了事務提交失敗,這是貿然重試會加重系統的負擔,這時可在客戶端進行一些限制,例如採用指數退避的方式,或限制一些重試次數,放入客戶端自己系統所屬的佇列等。

  3. 在重試前進行判斷,盡在發生臨時性錯誤時重試,如果應用已經違反了某些定義好的約束,那這樣的重試就毫無意義。

  4. 如果事務是多物件操作,並且可能在系統中發生副作用,那就需要類似“兩階段提交”這樣的機制來實現事務提交。

3.4 弱隔離級別

事務隔離要解決的是併發問題,併發問題需要討論兩個問題時序與競爭,往往由於事物之間的操作物件有競爭關係,並且又因為併發事務之間不確定的時序關係,會導致這些所操作的有競爭關係的物件會出現各種奇怪的結果。

所謂不同的隔離級別,就是試圖去用不同的開銷來滿足不同場景下對於時序要求的嚴格程度。我們可能不一定知道具體怎麼實現這些事務隔離級別,但每個隔離級別解決的問題本身我們應該非常清晰,這樣才不會在各種隔離級別和開銷中比較輕鬆的做權衡。這裡,我們不直接像書中一樣列舉隔離級別,我們首先闡述併發事務可能產生的問題,然後再去介紹每種隔離級別分別能夠解決那些問題。

髒讀

所謂髒讀,指的就是使用者能不能看到一個還沒有提交事務的結果,如果是,就是髒讀。下圖展示了沒有髒讀應該滿足什麼樣的承諾,User1的一個事務分別設定x=3、y=3,但在這個事務提交之前,User2在呼叫get x時,需要返回2,因為此時User1並沒有提交事務。

圖2 髒讀

防止髒讀的意義:

  1. 如果是單物件事務,客戶端會看到一個一會即將可能被回滾的值,如果我需要依據這個值做決策,就很有可能會出現決策錯誤。

  2. 如果是多物件事務,可能客戶端對於不同系統做訪問時一部分資料更新,一部分未更新,那樣使用者可能會不知所措。

髒寫

如果一個客戶端覆蓋了另一個客戶端尚未提交的寫入,我們就稱這樣的現象為髒寫。

這裡同樣給個例項,對於一個二手車的交易,需要更新兩次資料庫實現,但有兩個使用者併發的進行交易,如果像圖中一樣不禁止髒寫,就可能存在銷售列表顯示交易屬於Bob但發票卻發給了Alice,因為兩個事務對於兩個資料的相同記錄互相覆蓋。

圖3 髒寫

讀偏差(不可重複讀)

直接上例子,Alice在兩個銀行賬戶總共有1000塊,每個賬戶500,現在她想從一個賬戶向另一個賬戶轉賬100,並且她想一直盯著自己的兩個賬戶看看錢是否轉成功了。不巧的是,他第一次看賬戶的時候轉賬還沒發生,而成功後只查了一個賬戶的值,正好少了100,所以最後加起來會覺得自己少了100元。

如果只是這種場景,其實只是個臨時性的現象,後面再查詢就會得到正確的值,但是如果基於這樣的查詢去做別的事情,那可能就會出現問題了,比如將這個記錄Select出來進行備份,以防DB崩潰。但不巧如果後面真的崩潰,如果基於這次查詢到的資料做備份,那這100元可能真的永久的丟失了。如果是這樣的場景,不可重複讀是不能被接受的。

圖4 讀偏差

更新丟失

這裡直接把之前那個兩個使用者同時根據舊值更新計數器的例子搬過來,這是個典型的更新丟失問題:

圖5 隔離性問題導致更新丟失

寫偏差 && 幻讀

這種問題描述的是,事務的寫入需要依賴於之前判斷的結果,而這個結果可能會被其他併發事務修改。

圖6 幻讀

例項中有兩個人Alice和Bob決定是否可以休班,做這個決定的前提是判斷當前是否有兩個以上的醫生正在值班,如果是則自己可以安全的休班,然後修改值班醫生資訊。但由於使用了快照隔離( 後面會介紹 )機制,兩個事務返回的結果全都是2,進入了修改階段,但最終的結果其實是違背了兩名醫生值班的前提。

造成這個問題的根本原因是一種成為“幻讀”的現象,也就是說兩個併發的事務,其中一個事務更改了另一個事物的查詢結果,這種查詢一般都是查詢一個聚合結果,例如上文中的count或者max、min等,這種問題會在下面場景中出現問題。

  • 搶訂會議室

  • 多人遊戲更新位置

  • 唯一使用者名稱

上面我們列舉了事務併發可能產生的問題,下面我們介紹各種隔離級別所能解決的問題。

3.5 本章小結

事務用它的ACID特性,為使用者遮蔽了一些錯誤的處理。首先,原子性為使用者提供了一個可安全重試的環境,並且不會對相應的系統產生副作用。一致性能夠在一定程度上讓程式滿足所謂的不變式,隔離性通過不同的隔離級別解決不同場景下由於事務併發導致的不同現象,不同的隔離性解決的問題不同,開銷也不同,需要使用者按需決策,最後永續性讓使用者安心的把資料寫進我們設計的系統。

總體而言,事務保證的是不同操作之間的一致性,一個極度完美的事務實現,讓使用者看上去就只有一個事務在工作,每次只執行了一個原子操作。因此,我們稱事務所解決的是操作的一致性。這一章中,我們更多談論的還是單機範圍的事務。接下來,我們會把問題閾擴大,實際上分散式系統也有這樣的問題,並且分散式系統還有類似的複製滯後問題,導致就算看似是操作的是一個物件,也存在不同的副本,這會使得我們所面對的問題更加複雜。下一章,我們重點介紹另一種一致性問題以及解決。

4. 內部一致性與共識

4.1 複製滯後性的問題

這裡我們首先回到上一篇中講的複製的滯後性,滯後性所帶來的的一個最直觀的問題就是,如果在複製期間客戶端發起讀請求,可能不同的客戶端讀到的資料是不一樣的。這裡面書中給了三種不同型別的一致性問題。我們分別來看這些事例:

圖7 複製滯後問題

第一張圖給出的是一個使用者先更新,然後檢視更新結果的事例,比如使用者對某一條部落格下做出了自己的評論,該服務中的DB採用純的非同步複製,資料寫到主節點就返回評論成功,然後使用者想重新整理下頁面看看自己的評論能引發多大的共鳴或跟帖,這是由於查詢到了從節點上,所以發現剛才寫的評論“不翼而飛”了,如果系統能夠避免出現上面這種情況,我們稱實現了“寫後讀一致性”( 讀寫一致性 )。

上面是使用者更新後檢視的例子,下一張圖則展示了另一種情況。使用者同樣是在系統中寫入了一條評論,該模組依舊採用了純非同步複製的方法實現,此時有另一位使用者來看,首先重新整理頁面時看到了User1234的評論,但下一次重新整理,則這條評論又消失了,好像時鐘出現了回撥,如果系統能夠保證不會讓這種情況出現,說明系統實現了“單調讀”一致性( 比如騰訊體育的比分和詳情頁 )。

圖8 複製滯後問題

除了這兩種情況外還有一種情況,如下圖所示:

圖9 複製滯後問題

這個問題會比前面的例子看上去更荒唐,這裡有兩個寫入客戶端,其中Poons問了個問題,然後Cake做出了回答。從順序上,MrsCake是看到Poons的問題之後才進行的回答,但是問題與回答恰好被劃分到了資料庫的兩個分割槽( Partition )上,對於下面的Observer而言,Partition1的Leader延遲要遠大於Partition2的延遲,因此從Observer上看到的是現有答案後有的問題,這顯然是一個違反自然規律的事情,如果能避免這種問題出現,那麼可稱為系統實現了“字首讀一致性”。

在上一篇中,我們介紹了一可以檢測類似這種因果的方式,但綜上,我們可以看到,由於複製的滯後性,帶來的一個後果就是系統只是具備了最終一致性,由於這種最終一致性,會大大的影響使用者的一些使用體驗。上面三個例子雖然代表了不同的一致性,但都有一個共性,就是由於複製的滯後性帶來的問題。所謂複製,那就是多個客戶端甚至是一個客戶端讀寫多個副本時所發生的的問題。這裡我們將這類一致性問題稱為“內部一致性( 記憶體一致性 )”,即表徵由於多個副本讀寫的時序存在的資料不一致問題。

4.2 內部一致性概述

實際上,內部一致性並不是分散式系統特有的問題,在多核領域又稱記憶體一致性,是為了約定多處理器之間協作。如果多處理器間能夠滿足特定的一致性,那麼就能對多處理器所處理的資料,操作順序做出一定的承諾,應用開發人員可以根據這些承諾對自己的系統做出假設。如下圖所示:

圖10 CPU結構

每個CPU邏輯核心都有自己的一套獨立的暫存器和L1、L2Cache,這就導致如果我們在併發程式設計時,每個執行緒如果對某個主存地址中變數進行修改,可能都是優先修改自己的快取,並且讀取變數時同樣是會先讀快取。這實際上和我們在分散式中多個客戶端讀寫多個副本的現象是類似的,只不過分散式系統中是操作粒度,而處理器則是指令粒度。在多處理器的記憶體一致性中,有下面幾種常見的模型。

圖11 記憶體一致性——百度百科

可以看到,這些一致性約束的核心區分點就是在產生併發時對順序的約束,而用更專業一點的詞來說,線性一致性需要的是定義“全序”,而其他一致性則是某種“偏序”,也就是說允許一些併發操作間不比較順序,按所有可能的排列組合執行。

4.3 舉一反三:分散式系統中的內部一致性

如下圖所示:

圖12 記憶體一致性

分散式中的內部一致性主要分為4大類:線性一致性-->順序一致性-->因果一致性-->處理器一致性,而從偏序與全序來劃分,則劃分為強一致性( 線性一致性 )與最終一致性。

但需要注意的是,只要不是強一致的內部一致性,但最終一致性沒有任何的偏序保障。圖中的這些一致性實際都是做了一些偏序的限制,比樸素的最終一致性有更強的保證,這裡其他一致性性的具體例項詳見《大資料日知錄》第二章,那裡面有比較明確對於這些一致性的講解,本章我們重點關注強一致。

4.4 我們口中的“強一致性”——線性一致性

滿足線性一致性的系統給我們這樣一種感覺,這系統看著只有一個副本,這樣我就可以放心地讀取任何一個副本上的資料來繼續我們的應用程式。這裡還是用一個例子來具體說明線性一致性的約束,如下圖所示:

圖13 線性一致性

這裡有三個客戶端同時操作主鍵x,這個主鍵在書中被稱為暫存器( Register ),對該暫存器存在如下幾種操作:

  1. write(x,v) =>r表示嘗試更新x的值為v,返回更新結果r。

  2. read(x) => v表示讀取x的值,返回x的值為v。

如圖中所示,在C更新x的值時,A和B反覆查詢x的最新值,比較明確的結果是由於ClientA在ClientC更新x之前讀取,所以第一次read(x)一定會為0,而ClientA的最後一次讀取是在ClientC成功更新x的值後,因此一定會返回1。而剩下的讀取,由於不確定與write(x,1)的順序( 併發 ),因此可能會返回0也可能返回1。對於線性一致性,我們做了下面的規定:

圖14 線性一致性

在一個線性一致性系統中,在寫操作呼叫到返回之前,一定有一個時間點,客戶端呼叫read能讀到新值,在讀到新值之後,後續的所有讀操作都應該返回新值。( 將上面圖中的操作做了嚴格的順序,及ClientA read->ClientB read->ClientC write-ClientA read->clientB read->clientAread )這裡為了清晰,書中做了進一步細化。在下面的例子中,又增加了一種操作:

  • cas(x, v_old, v_new)=>r  及如果此時的值時v_old則更新x的值為v_new,返回更新結果。

如圖:每條數顯代表具體事件發生的時點,線性一致性要求:如果連線上述的豎線,要求必須按照時間順序向前推移,不能向後回撥( 圖中的read(x)=2就不滿足線性化的要求,因為x=2在x=4的左側 )。

圖15 線性一致性

4.5 什麼時候需要依賴線性化?

如果只是類似論壇中評論的先後順序,或者是體育比賽頁面重新整理頁面時的來回跳變,看上去並不會有什麼致命的危害。但在某些場景中,如果系統不是線性的可能會造成更嚴重的後果。

  1. 加鎖&&選主:在主從複製模型下,需要有一個明確的主節點去接收所有寫請求,這種選主操作一般會採用加鎖實現,如果我們依賴的鎖服務不支援線性化的儲存,那就可能出現跳變導致“腦裂”現象的發生,這種現象是絕對不能接受的。因此針對選主場景所依賴的分散式鎖服務的儲存模組一定需要滿足線性一致性( 一般而言,元資料的儲存也需要線性化儲存 )。

  2. 約束與唯一性保證:這種場景也是顯而易見的,比如唯一ID、主鍵、名稱等等,如果沒有這種線性化儲存承諾的嚴格的順序,就很容易打破唯一性約束導致很多奇怪的現象和後果。

  3. 跨通道( 系統 )的時間依賴:除了同一系統中,可能服務橫跨不同系統,對於某個操作對於不同系統間的時序也需要有限制,書中舉了這樣一個例子。

圖16 跨通道線性一致性

比如使用者上傳圖片,類似後端儲存服務可能會根據全尺寸圖片生成低畫素圖片,以便增加使用者服務體驗,但由於MQ不適合傳送圖片這種大的位元組流,因此全尺寸圖片是直接發給後端儲存服務的,而擷取圖片則是通過MQ在後臺非同步執行的,這就需要2中上傳的檔案儲存服務是個可線性化的儲存。如果不是,在生成低解析度影象時可能會找不到,或讀取到半張圖片,這肯定不是我們希望看到的。

線性化不是避免競爭的唯一方法,與事務隔離級別一樣,對併發順序的要求,可能會根據場景不同有不同的嚴格程度。這也就誕生了不同級別的內部一致性級別,不同的級別也同樣對應著不同的開銷,需要使用者自行決策。

4.6 實現線性化系統

說明了線性化系統的用處,下面我們來考慮如何實現這樣的線性化系統。

根據上文對線性化的定義可知,這樣系統對外看起來就像只有一個副本,那麼最容易想到的方式就是,乾脆就用一個副本。但這又不是分散式系統的初衷,很大一部分用多副本是為了做容錯的,多副本的實現方式是複製,那麼我們來看看,上一篇分享中那些常見的複製方式是否可以實現線性系統:

  1. 主從複製( 部分能實現 ):如果使用同步複製,那樣系統確實是線性化的,但有一些極端情況可能會違反線性化,比如由於成員變更過程中的“腦裂”問題導致消費異常,或者如果我們使用非同步複製故障切換時會同時違反事務特性中的持久化和內部一致性中的線性化。

  2. 共識演算法( 線性化 ):共識演算法在後文會重點介紹,它與主從複製類似,但通過更嚴格的協商機制實現,可以在主從複製的基礎上避免一些可能出現的“腦裂”等問題,可以比較安全的實現線性化儲存。

  3. 多主複製( 不能線性化 )。

  4. 無主複製( 可能不能線性化 ):主要取決於具體Quorum的配置,對強一致的定義,下圖給了一種雖然滿足嚴格的Quorum,但依然無法滿足線性化的例子。

圖17 Quorum無法實現線性一致

實現線性化的代價——是時候登場了,CAP理論

在上一次分享中,我們講過,分散式系統中網路的不可靠性,而一旦網路斷開( P ),副本間一定會導致狀態無法達到線性一致,這時候到底是繼續提供服務但可能得到舊值( A ),還是死等網路恢復保證狀態的線性一致呢( C ),這就是著名的CAP了。

但是其實CAP理論的定義面還是比較窄的,其中C只是線性一致性,P只代表網路分割槽( 徹底斷開,而不是延遲 ),這裡面實際有相當多的折中,就可以完全滿足我們系統的需求了,所以不要迷信這個理論,還是需要根據具體的實際情況去做分析。

層層遞進——實現線性化系統

從對線性一致性的定義我們可以知道,順序的檢測是實現線性化系統的關鍵,這裡我們跟著書中的思路一步步地來看:我們怎麼能對這些併發的事務定義出它們的順序。

a. 捕捉因果關係

與上一次分享的內容類似,併發操作間有兩種型別,可能有些操作間具有天然邏輯上的因果關係,還有些則沒法確定,這裡我們首先先嚐試捕獲那些有因果關係的操作,實現個因果一致性。這裡的捕獲我們實際需要儲存資料庫( 系統 )操作中的所有因果關係,我們可以使用類似版本向量的方式( 忘記的同學,可以回看上一篇中兩個人併發操作購物車的示例 )。

b. 化被動為主動——主動定義

上面被動地不加任何限制的捕捉因果,會帶來巨大的執行開銷( 記憶體,磁碟 ),這種關係雖然可以持久化到磁碟,但分析時依然需要被載入記憶體,這就讓我們有了另一個想法,我們是否能在操作上做個標記,直接定義這樣的因果關係?

最最簡單的方式就是構建一個全域性發號器,產生一些序列號來定義操作間的因果關係,比如需要保證A在B之前發生,那就確保A的全序ID在B之前即可,其他的併發操作順序不做硬限制,但操作間在處理器的相對順序不變,這樣我們不但實現了因果一致性,還對這個限制進行了增強。

c. Lamport時間戳

上面的設想雖然比較理想,但現實永遠超乎我們的想象的複雜,上面的方式在主從複製模式下很容易實現,但如果是多主或者無主的複製模型,我們很難設計這種全域性的序列號發號器,書中給出了一些可能的解決方案,目的是生成唯一的序列號,比如:

  1. 每個節點各自產生序列號。

  2. 每個操作上帶上時間戳。

  3. 預先分配每個分割槽負責產生的序列號。

但實際上上面的方法都可能破壞因果關係的偏序承諾,原因就是不同節點間負載不同、時鐘不同、參照系不同。這裡我們的併發大神Lamport登場了,他老人家自創了一個Lamport邏輯時間戳,完美地解決了上面的所有問題。如下圖所示:

圖18 Lamport時間戳

初識Lamport時間戳,還是研究生分散式系統課上,當時聽得雲裡霧裡,完全不知道在說啥。今天再次拿過來看,有了上下文,稍微懂了一點點。簡單來說定義的就是使用邏輯變數定義了依賴關係,它給定了一個二元組<Counter, NodeId>,然後給定了一個比較方式:

  1. 先比較Counter,Counter大的後發生( 會承諾嚴格的偏序關係 )。

  2. 如果Counter相同,直接比較NodeId,大的定義為後發生( 併發關係 )。

如果只有這兩個比較,還不能解決上面的因果偏序被打破的問題,但是這個演算法不同的是,它會把這個Node的Counter值內嵌到請求的響應體中,比如圖中的A,在第二次向Node2傳送更新max請求時,會返回當前的c=5,這樣Client會把本地的Counter更新成5,下一次會增1這樣使用Node上的Counter就維護了各個副本上變數的偏序關係,如果併發往兩個Node裡寫就直接定義為併發行為,用NodeId定義順序了。

d. 我們可以實現線性化了嗎——全序廣播

到此我們可以確認,有了Lamport時間戳,我們可以實現因果一致性了,但仍然無法實現線性化,因為我們還需要讓這個全序通知到所有節點,否則可能就會無法做決策。

舉個例子,針對唯一使用者名稱這樣的場景,假設ABC同時向系統嘗試註冊相同的使用者名稱,使用Lamport時間戳的做法是,在這三個併發請求中最先提交的返回成功,其他返回失敗,但這裡面我們因為有“上帝視角”,知道ABC,但實際請求本身在傳送時不知道有其他請求存在( 不同請求可能被髮送到了不同的節點上 )這樣就需要系統做這個收集工作,這就需要有個類似協調者來不斷詢問各個節點是否有這樣的請求,如果其中一個節點在詢問過程中發生故障,那系統無法放心決定每個請求具體的RSP結果。所以最好是系統將這個順序廣播到各個節點,讓各個節點真的知道這個順序,這樣可以直接做決策。

假設只有單核CPU,那麼天然就是全序的,但是現在我們需要的是在多核、多機、分散式的情況下實現這個全序的廣播,就存在這一些挑戰。主要挑戰個人認為是兩個:

  • 多機

  • 分散式

對於多機,實際上實現全序廣播最簡單的實現方式使用主從模式的複製,讓所有的操作順序讓主節點定義,然後按相同的順序廣播到各個從節點。對於分散式環境,需要處理部分失效問題,也就是如果主節點故障需要處理主成員變更。下面我們就來看看書中是怎麼解決這個問題的。

這裡所謂的全序一般指的是分割槽內部的全序,而如果需要跨分割槽的全序,需要有額外的工作

對於全序廣播,書中給了兩條不變式:

  1. 可靠傳送:需要保證訊息做到all-or-nothing的傳送( 想想上一章 )。

  2. 嚴格有序:訊息需要按完全相同的順序發給各個節點。

實現層面

我們對著上面的不變式來談談簡單的實現思路,首先要做到可靠傳送,這裡有兩層含義:

  1. 訊息不能丟

  2. 訊息不能發一部分

其中訊息不能丟意味著如果某些節點出現故障後需要重試,如果需要安全的重試,那麼廣播操作本身失敗後就不能對系統本身有副作用,否則就會導致訊息傳送到部分節點上的問題。上一章的事務的原子性恰好就解決的是這個問題,這裡也就衍射出我們需要採用事務的一些思路,但與上面不同,這個場景是分散式系統,會發到多個節點,所以一定是分散式事務( 耳熟能詳的2PC一定少不了 )。

另外一條是嚴格有序,實際上我們就是需要一個能保證順序的資料結構,因為操作是按時間序的一個Append-only結構,恰好Log能解決這個問題,這裡引出了另一個常會被提到的技術,複製狀態機,這個概念是我在Raft的論文中看到的,假設初始值為a,如果按照相同的順序執行操作ABCDE最後得到的一定是相同的結果。因此可以想象,全序廣播最後的實現一定會用到Log這種資料結構。

e. 線性系統的實現

現在假設我們已經有了全序廣播,那麼我們繼續像我們的目標--線性化儲存邁進,首先需要明確一個問題,線性化並不等價於全序廣播,因為在分散式系統模型中我們通常採用非同步模型或者半同步模型,這種模型對於全序關係何時成功傳送到其他節點並沒有明確的承諾,因此還需要再全序廣播上做點什麼才真正能實現線性化系統。

書中仍然舉了唯一使用者名稱的例子:可以採用線性化的CAS操作來實現,當用戶建立使用者名稱時當且僅當old值為空。實現這樣的線性化CAS,直接採用全序廣播+Log的方式。

  1. 在日誌中寫入一條訊息,表明想要註冊的使用者名稱。

  2. 讀取日誌,將其廣播到所有節點並等待回覆 ( 同步複製 )。

  3. 如果表名第一次註冊的回覆來自當前節點,提交這條日誌,並返回成功,否則如果這條回覆來自其他節點,直接向客戶端返回失敗。

而這些日誌條目會以相同的順序廣播到所有節點,如果出現併發寫入,就需要所有節點做決策,是否同意,以及同意哪一個節點對這個使用者名稱的佔用。以上我們就成功實現了一個對線性CAS的寫入的線性一致性。然而對於讀請求,由於採用非同步更新日誌的機制,客戶端的讀取可能會讀到舊值,這可能需要一些額外的工作保證讀取的線性化。

  1. 線性化的方式獲取當前最新訊息的位置,即確保該位置之前的所有訊息都已經讀取到,然後再進行讀取( ZK中的sync() )。

  2. 在日誌中加入一條訊息,收到回覆時真正進行讀取,這樣訊息在日誌中的位置可以確定讀取發生的時間點。

  3. 從保持同步更新的副本上讀取資料。

4.7 共識

上面我們在實現線性化系統時,實際上就有了一點點共識的苗頭了,即需要多個節點對某個提議達成一致,並且一旦達成,不能被撤銷。在現實中很多場景的問題都可以等價為共識問題:

  • 可線性化的CAS

  • 原子事務提交

  • 全序廣播

  • 分散式鎖與租約

  • 成員協調

  • 唯一性約束

實際上,為以上任何一個問題找到解決方案,都相當於實現了共識。

兩階段提交

a. 實現

書中直接以原子提交為切入點來聊共識。這裡不過多說明,直接介紹兩階段提交,根據書中的描述,兩階段提交也算是一種共識演算法,但實際上在現實中,我們更願意把它當做實現更好共識演算法的一個手段以及分散式事務的核心實現方法( Raft之類的共識演算法實際上都有兩階段提交這個類似的語義 )。

圖19 兩階段提交

這個演算法實際上比較樸素,就是兩個階段,有一個用於收集資訊和做決策的協調者,然後經過樸素的兩個階段:

  1. 協調者向參與者傳送準備請求詢問它們是否可以提交,如果參與者回答“是”則代表這個參與者一定會承諾提交這個訊息或者事務。

  2. 如果協調者收到所有參與者的區確認資訊,則第二階段提交這個事務,否則如果有任意一方回答“否”則終止事務。

這裡一個看似非常簡單的演算法,平平無奇,無外乎比正常的提交多了個準備階段,為什麼說它就可以實現原子提交呢?這源於這個演算法中的約定承諾,讓我們繼續拆細這個流程:

  1. 當啟動一個分散式事務時,會向協調者請求一個事務ID。

  2. 應用程式在每個參與節點上執行單節點事務,並將這個ID附加到操作上,這是讀寫操作都是單節點完成,如果發生問題,可以安全的終止( 單節點事務保證 )。

  3. 當應用準備提交時,協調者向所有參與者傳送Prepare,如果這是有任何一個請求發生錯誤或超時,都會終止事務。

  4. 參與者收到請求後,將事務資料寫入持久化儲存,並檢查是否有違規等, 此時出現了第一個承諾:如果參與者向協調者傳送了“是”意味著該參與者一定不會再撤回事務

  5. 當協調者收到所有參與者的回覆後,根據這些恢復做決策,如果收到全部贊成票,則將“提交”這個決議寫入到自己本地的持久化儲存, 這裡會出現第二個承諾:協調者一定會提交這個事務,直到成功

  6. 假設提交過程出現異常,協調者需要不停重試,直到重試成功。

正是由於上面的兩個承諾保證了2PC能達成原子性,也是這個正規化存在的意義所在。

b. 侷限性

  1. 協調者要儲存狀態,因為協調者在決定提交之後需要擔保一定要提交事務,因此它的決策一定需要持久化。

  2. 協調者是單點,那麼如果協調者發生問題,並且無法恢復,系統此時完全不知道應該提交還是要回滾,就必須交由管理員來處理。

  3. 兩階段提交的準備階段需要所有參與者都投贊成票才能繼續提交,這樣如果參與者過多,會導致事務失敗概率很大。

更為樸素的共識演算法定義

看完了一個特例,書中總結了共識演算法的幾個特性:

  1. 協商一致性 :所有節點都接受相同的提議。

  2. 誠實性 :所有節點一旦做出決定,不能反悔,不能對一項提議不能有兩次不同的決議。

  3. 合法性 :如果決定了值v,這個v一定是從某個提議中得來的。

  4. 可終止性 :節點如果不崩潰一定能達成決議。

如果我們用這幾個特性對比2PC,實際上卻是可以認為它算是個共識演算法,不過這些並不太重要,我們重點還是看這些特性會對我們有什麼樣的啟發。

前三個特性規定了安全性( Safety ),如果沒有容錯的限制,直接人為指定個Strong Leader,由它來充當協調者,但就像2PC中的侷限性一樣,協調者出問題會導致系統無法繼續向後執行,因此需要有額外的機制來處理這種變更( 又要依賴共識 ),第四個特性則決定了活性( Liveness )之前的分型中說過,安全性需要優先保證,而活性的保證需要前提。這裡書中直接給出結論,想讓可終止性滿足的前提是大多數節點正確執行。

共識演算法與全序廣播

實際在最終設計演算法並落地時,並不是讓每一條訊息去按照上面4條特性來一次共識,而是直接採用全序廣播的方式,全序廣播承諾訊息會按相同的順序傳送給各個節點,且有且僅有一次,這就相當於在做多輪共識,每一輪,節點提出他們下面要傳送的訊息,然後決定下一個訊息的全序。使用全序廣播實現共識的好處是能提供比單輪共識更高的效率( ZAB, Raft,Multi-paxos )。

討論

這裡面還有一些事情可以拿出來做一些討論。首先,從實現的角度看,主從複製的模式特別適用於共識演算法,但在之前介紹主從複製時,但光有主從複製模型對解決共識問題是不夠的,主要有兩點:

  1. 主節點掛了如何確定新主

  2. 如何防止腦裂

這兩個問題實際上是再次用了共識解決。在共識演算法中,實際上使用到了epoch來標識邏輯時間,例如Raft中的Term,Paxos中的Balletnumber,如果在選舉後,有兩個節點同時聲稱自己是主,那麼擁有更新Epoch的節點當選。

同樣的,在主節點做決策之前,也需要判斷有沒有更高Epoch的節點同時在進行決策,如果有,則代表可能發生衝突( Kafka中低版本只有Controller有這個標識,在後面的版本中,資料分割槽同樣帶上了類似的標識 )。此時,節點不能僅根據自己的資訊來決定任何事情,它需要收集Quorum節點中收集投票,主節點將提議發給所有節點,並等待Quorum節點的返回,並且需要確認沒後更高Epoch的主節點存在時,節點才會對當前提議做投票。

詳細看這裡面涉及兩輪投票,使用Quorum又是在使用所謂的重合,如果某個提議獲得通過,那麼投票的節點中一定參加過最近一輪主節點的選舉。這可以得出,此時主節點並沒有發生變化,可以安全的給這個主節點的提議投票。

另外,乍一看共識演算法全都是好處,但看似好的東西背後一定有需要付出的代價:

  1. 在達成一致性決議前,節點的投票是個同步複製,這會使得共識有丟訊息的風險,需要在效能和線性一直間權衡( CAP )。

  2. 多數共識架設了一組固定的節點集,這意味著不能隨意的動態變更成員,需要深入理解系統後才能做動態成員變更( 可能有的系統就把成員變更外包了 )。

  3. 共識對網路極度敏感,並且一般採用超時來做故障檢測,可能會由於網路的抖動導致莫名的無效選主操作,甚至會讓系統進入不可用狀態。

外包共識

雖然可以根據上面的描述自己來實現共識演算法,但成本可能是巨大的,最好的方式可能是將這個功能外包出去,用成熟的系統來實現共識,如果實在需要自己實現,也最好是用經過驗證的演算法來實現,不要自己天馬行空。ZK和etcd等系統就提供了這樣的服務,它們不僅自己通過共識實現了線性化儲存,而且還對外提供共識的語義,我們可以依託這些系統來實現各種需求:

  1. 線性化CAS

  2. 操作全序

  3. 故障檢測

  4. 配置變更

4.8 本章小結

本章花費了巨大力氣講解了分散式系統中的另一種一致性問題,內部一致性,這種問題主要是因為複製的滯後性產生,首先我們介紹了這種問題的起源,然後對映到分散式系統中,對不同一致性進行分類。

對於裡面的強一致性,我們進行了詳細的探討,包括定義、使用場景以及實現等方面,並從中引出了像全序與偏序、因果關係的捕捉與定義( Lamport時間戳 )、全序廣播、2PC最後到共識,足以見得這種一致性解決起來的複雜性。

5. 再談分散式系統

至此,我們從複製這一主題出發,討論了分散式系統複製模型、挑戰、事務以及共識等問題,這裡結合兩篇文章的內容,我嘗試對分散式系統給出更細節的描述,首先描述特性和問題,然後給出特定的解決。

  • 與單機系統一樣,分散式系統同樣會有多個客戶端同時對系統產生各種操作。每個操作所涉及的物件可能是一個,也可能是多個,這些客戶端併發的操作可能會產生正確性問題。

  • 為了實現容錯,分散式系統的資料一般會有多個備份,不同副本之間通過複製實現。

  • 常見覆制模型包括:

    • 主從模式

    • 多主模式

    • 無主模式

  • 而從時效性和線性一致性出發,可分為:

    • 同步複製

    • 非同步複製

  • 非同步複製可能存在滯後問題,會引發各種內部一致性問題。

  • 分散式系統相比單機系統,具有兩個獨有的特點。

    • 部分失效

    • 缺少全域性時鐘

面對這麼多問題,如果一個理想的分散式資料系統,如果不考慮任何效能和其他的開銷,我們期望實現的系統應該是這樣的:

  1. 整個系統的資料對外看起來只有一個副本,這樣使用者並不用擔心更改某個狀態時出現任何的不一致( 線性一致性 )。

  2. 整個系統好像只有一個客戶端在操作,這樣就不用擔心和其他客戶端併發操作時的各種衝突問題( 序列化 )。

所以我們知道,線性一致性和序列化是兩個正交的分支,分別表示外部一致性中的最高級別以及內部一致性的最高級別。如果真的實現這個,那麼使用者操作這個系統會非常輕鬆。但很遺憾,達成這兩方面的最高級別都有非常大的代價,因此由著這兩個分支衍生出各種的內部一致性和外部一致性。

用Jepsen官網對這兩種一致性的定義來說,內部一致性約束的是單操作對單物件可能不同副本的操作需要滿足時間全序,而外部一致性則約束了多操作對於多物件的操作。這類比於Java的併發程式設計,內部一致性類似於volatile變數或Atomic的變數用來約束實現多執行緒對同一個變數的操作,而外部一致性則是類似於synchronize或者AQS中的各種鎖來保證多執行緒對於一個程式碼塊( 多個操作,多個物件 )的訪問符合程式設計師的預期。

圖20 一致性

但是需要注意的是,在分散式系統中,這兩種一致性也並非完全孤立,我們一般採用共識演算法來實現線性一致,而在實現共識演算法的過程中,同樣可能涉及單個操作涉及多個物件的問題,因為分散式系統的操作,往往可能是作用在多個副本上的。也就是說,類似2PC這樣的分散式事務同樣會被用來解決共識問題( 雖然書中把它也成為共識,但其實還是提供了一種類似事務原子性的操作 ),就像Java併發程式設計中,我們在synchronize方法中也可能會使用一些volatile變數一樣。

而2PC不是分散式事務的全部,可能某些跨分割槽的事務同樣需要用基於線性一致性的操作來滿足對某個物件操作的一致性。也就是說想完整的實現分散式的系統,這兩種一致性互相依賴,彼此互補,只有我們充分了解它們的核心作用,才能遊刃有餘地在實戰中應用這些看似枯燥的名詞。

6. 士別三日,當刮目相看--再看Kafka

瞭解完上面這些一致性,我們再回過頭來看看Kafka的實複製,我們大致從複製模型、內部一致性、外部一致性等角度來看。Kafka中與複製模式相關的配置大致有下面幾個:

  1. 複製因子( 副本數

  2. min.insync.replicas

  3. acks

使用者首先通過配置acks先大體知道複製模式,如果ack=1或者0,則表示完全的非同步複製;如果acks=all則代表完全的同步複製。而如果配置了非同步複製,那麼單分割槽實際上並不能保證線性一致性,因為非同步複製的滯後性會導致一旦發生Leader變更可能丟失已經提交的訊息,導致打破線性一致性的要求。

而如果選擇ack=-1,則代表純的同步複製,而此時如果沒有min.insync.replicas的限制,那樣會犧牲容錯,多副本本來是用來做容錯,結果則是有一個副本出問題系統就會犧牲掉Liveness。而min.insync.replicas引數給了使用者做權衡的可能,一般如果我們要保證單分割槽線性一致性,需要滿足多數節點正常工作,因此我們需要配置min.insync.replicas為majority。

而針對部分失效的處理,在實現複製時,kafka將成員變更進行了外包,對於資料節點而言,託管給Controller,直接由其指定一個新的主副本。而對於Controller節點本身,則將這個職責託管給了外部的線性儲存ZK,利用ZK提供的鎖於租約服務幫助實現共識以達成主節點選舉,而在高版本中,Kafka去掉了外部的共識服務,而轉而自己用共識演算法實現Controller選主,同時元資料也由原來依賴ZK變為自主的Kraft實現的線性化儲存進行自治。

而在外部一致性範疇,目前低版本Kafka並沒有類似事務的功能,所以無法支援多物件的事務,而高版本中,增加了事務的實現( 詳見 blog 。由於物件跨越多機,因此需要實現2PC,引入了TransactionCoordinator來承擔協調者,參考上面2PC的基本流程。

一個大致的實現流程基本如下:首先向協調者獲取事務ID( 後文統稱TID ),然後向參與者傳送請求準備提交,帶上這個TID,參與者現在本地做append,如果成功返回,協調者持久化決策的內容,然後執行決策,參與者將訊息真正寫到Log中( 更新LSO,與HW高水位區分 )。但是上文也講了2PC實際上是有一些問題的,首先2PC協調者的單點問題,Kafka的解決方法也比較簡單,直接利用自己單分割槽同步複製保證線性一致性的特性,將協調者的狀態儲存在內部Topic中,然後當協調者崩潰時可以立刻做轉移然後根據Topic做恢復,因為Topic本身就單分割槽而言就是個線性儲存。

另外,就是2PC的協調者本質是個主從複製的過程,由於TransactionCoordinator本來就掛靠在Broker上,所以這個選舉依然會委託給Controller,這樣就解決了2PC中的比較棘手的問題。而對於事務的隔離級別,Kafka僅實現到了“讀已提交( RC )”級別。

7. 分散式系統驗證框架

在分散式領域有兩把驗證分散式演算法的神器,其中一款是用於白盒建模的工具TLA+ TLA Homepage ,對於TLA+,個人強烈推薦看一看Lamport老人家的視訊教程 視訊教程(帶翻譯) ,或去看一看《Specifing Systems》。我們會知道,這個語言不光能定義分散式演算法,應該說是可以定義整個計算機系統,如果掌握了使用數學定義系統的能力,可以讓我們從程式碼細節中走出來,以狀態機的思維來看待系統本身,我們可能會有不一樣的感悟。TLA+的核心是通過數學中的集合論,數理邏輯和狀態搜尋來定義系統的行為。我們需要正確的對我們的系統或演算法做抽象,給出形式化的規約,然後使用TLA+進行驗證。

另一款則是黑盒 Jepsen Homepage ,其核心原理則是生成多個客戶端對一個儲存系統進行正常的讀寫操作並記錄每次操作的結果,在測試中間引入故障,最後根據檢測這些操作歷史是否符合各種一致性所滿足的規定。我們簡單看下它的架構,然後本文將大致演示它的使用方法。

圖21 Jepsen

Jepsen主要有下面幾個模組構成:

  1. DB Node( 引擎本身的節點,儲存節點 )。

  2. Control Node 控制節點,負責生成客戶端,生成操作,生成故障等,其與DB Node通常是SSH免密的。

  3. Client 客戶端用於進行正常讀寫操作。

  4. Generator 用來生成計劃。

  5. Nemesis 故障製造者。

  6. Checker 用來進行最後的一致性校驗。

我們團隊使用Jepsen測試了Kafka系統的一致性,其中Kafka客戶端與服務端的配置分別為:同步複製( ack=-1 ),3複製因子( 副本數 ),最小可用副本為2( min.insync.isr )。在該配置下,Jepsen內建的故障注入最後均通過了驗證。

8. 小結

我們的資料之旅到這裡就要告一段落了,希望大家通過我的文章瞭解常見分散式系統的核心問題,以及面對這些問題所謂的事務,一致性和共識所能解決的問題和內在聯絡,能夠在適當的時候合理的使用校驗工具或框架對我們的系統的正確性和活性進行校驗,這樣就達到兩篇系列文章的目的了。

分散式系統是個“大傢伙”,希望今後能夠跟大家一起繼續努力,先將其“庖丁解牛”,然後再“逐個擊破”,真正能夠掌控一些比較複雜的分散式系統的設計。最後感謝團隊中的小夥伴們,能將這樣的思考系統化的產出,離不開組內良好的技術分享文化和濃厚的技術氛圍,也歡迎大家加入美團技術團隊。

9. 作者簡介

仕祿,美團基礎研發平臺/資料科學與平臺部工程師。

----------  END  ----------

也許你還想看

  |  Kafka在美團資料平臺的實踐

  | 基於SSD的Kafka應用層快取架構設計與實現

  | Kafka檔案儲存機制那些事

閱讀更多

---

前端  |     演算法  |   後端  |  資料

安全  |  Android  |   iOS    |   運維  |  測試