Paxos 實現日志復制同步
這篇文章以一種易於理解的方式來解釋 Multi-Paxos 的機制。
Multi-Paxos 的是為了創建日志復制
一種實現方式是用一組基礎 Paxos 實例,每條記錄都有一個獨立的 Paxos 實例,要想這么做只需要為每個 Prepare 和 Accept 請求增加一個小標索引(index),用來選擇特定的記錄,所有的服務器為日志里的每條記錄都保有獨立的狀態。
上圖展示了一個請求的完整周期。
-
從客戶機開始,它向服務器發送所需執行的命令,它將命令發送至其中一台服務器的 Paxos 模塊。
-
這台服務器運行 Paxos 協議,讓該條命令(shl)被選擇作為日志記錄里的值。這需要它與其他服務器之間進行通信,讓所有的服務器都達成一致。
-
服務器等待所有之前的日志被選定后,新的命令將被應用到狀態機。
-
這時服務器將狀態機里的結果返回給客戶端。
這是個基本機制,后面會作詳細介紹。
Multi-Paxos 的問題
本頁介紹了 Multi-Paxos 所需解決的一些基本問題,讓 Multi-Paxos 在實際中得以正確運行。
- 對於一個請求,如何決定該使用哪條日志記錄?
- 第二個問題關於性能。如果用之前的基礎 Paxos 中描述的方式,運行會很慢。所以引入了 領導者(leader) 來降低 提議者(proposer) 之間的沖突。還可以消除幾乎絕大多數的 准備階段(Prepare) 的請求,只需要處理一輪 RPC 請求。
- 這個問題關於完整復制。如何保證所有的服務器最終都有每天記錄,每個服務器都知道被選定的日志記錄是什么。
- 客戶端協議。會介紹客戶端在服務器崩潰時,如何還能正常工作。
- 最后一個問題就是配置的變更。如何安全地給 Multi-Paxos 集群增加或移除服務器。
這里需要注意的是,基礎 Paxos 已經有非常完整地描述,而且分析也證明它是正確的。非常易於理解。但是 Multi-Paxos 確不是這樣,它的描述很抽象,有很多選擇處理方式,沒有一個是很具體的描述。而且,Multi-Paxos 並沒有為我們詳細的描述它是如何解決這些問題的。
這篇文章以一種易於理解的方式來解釋 Multi-Paxos 的機制。現在我們還沒有實現它,也還沒有證明它的正確性,所以后續解釋可能會有 bug 。但希望這些內容可以對解決問題、構建可用的 Multi-Paxos 協議有所幫助
選擇日志記錄
第一個問題是當接收到客戶端請求時如何選擇日志槽,我們以上圖中的例子來闡述如何做到這點。假設我們的集群里有三台服務器,所以 “大多數” 指的是 2 。這里展示了當客戶端發送命令(jmp)時,每台服務器上日志的狀態,客戶端希望這個請求值能在日志中被記錄下來,並被狀態機執行。
當接收到請求時,服務器 S1 上的記錄可能處於不同的狀態,服務器知道有些記錄已經被選定(1-mov,2-add,6-ret),在后面我會介紹服務器是如何知道這些記錄已經被選定的。服務器上也有一些其他的記錄(3-cmp),但此時它還不知道這條記錄已經被選定。在這個例子中,我們可以看到,實際上記錄(3-cmp)已經被選定了,因為在服務器 S3 上也有相同的記錄,只是 S1 和 S3 還不知道。還有空白記錄(4-,5-)沒有接受任何值,不過其他服務器上相應的記錄可能已經有值了。
現在來看看發生些什么:
當 jmp 請求到達 S1 后,它會找到第一個沒有被選定的記錄(3-cmp),然后它會試圖讓 jmp 作為該記錄的選定值。為了讓這個例子更具體一些,我們假設服務器 S3 已經下線。所以 Paxos 協議在服務器 S1 和 S2 上運行,服務器 S1 會嘗試讓記錄 3 接受 jmp 值,它正好發現記錄 3 內已經有值(3-cmp),這時它會結束選擇並進行比較,S1 會向 S2 發送接受(3-cmp)的請求,S2 會接受 S1 上已經接受的 3-cmp 並完成選定。這時(3-cmp)變成粗體,不過還沒能完成客戶端的請求,所以我們返回到第一步,重新進行選擇。找到當前還沒有被選定的記錄(這次是記錄 4-),這時 S1 會發現 S2 相應記錄上已經存在接受值(4-sub),所以它會再次放棄 jmp ,並將 sub 值作為它 4- 記錄的選定值。所以此時 S1 會再次返回到第一步,重新進行選擇,當前未被選定的記錄是 5- ,這次它會成功的選定 5-jmp ,因為 S1 沒有發現其他服務器上有接受值。
當下一次接收到客戶端請求時,首先被查看的記錄會是 7- 。
在這種方式下,單個服務器可以同時處理多個客戶端請求,也就是說前一個客戶端請求會找到記錄 3- ,下一個客戶端請求就會找到記錄 4- ,只要我們為不同的請求使用不同的記錄,它們都能以並行的方式獨立運行。不過,當進入到狀態機后,就必須以一定的順序來執行命令,命令必須與它們在日志內的順序一致,也就是說只有當記錄 3- 完成執行后,才能執行記錄 4- 。
提高效率
下一個需要解決的就是效率問題。在之前描述過的內容中存在兩個問題:
第一個問題就是當有多個 提議者(proposer) 同時工作時,仍然會有可能存在競爭沖突的情況,有些請求會被要求重新開始,可能大家還會記得在 基礎 Paxos 里介紹過的死鎖情況。同樣的狀況也可以在這里發生,當集群壓力過大時,這個問題會非常明顯,如果有很多客戶端並發的請求集群,所有的服務器都試圖在同一條記錄上進行值的選定,就可能會出現系統失效或系統超負荷的情況。
第二個問題就是每次客戶端的請求都要求兩輪的遠程調用,第一輪是提議的准備(Prepare)請求階段,第二輪是提議的接受(Accept)請求階段。
為了讓事情更有效率,這里會做兩處調整。首先,我們會安排單個服務器作為活動的 提議者(proposer) ,所有的提議請求都會通過這個服務器來發起。我們稱這個服務器為 領導者(leader) 。其次,我們有可能可以消除幾乎所有的准備(Prepare)請求,為了達到目的,我們可以為 領導者(leader) 使用一輪提議准備(Prepare),但是准備的對象是完整的日志,而不是單條記錄。一旦完成了准備(Prepare),它就可以通過使用接受(Accept)請求,同時創建多條記錄。這樣就不需要多次使用准備(Prepare)階段。這樣就能減少一半的 RPC 請求。
領導者(leader)選舉
選舉領導者的方式有很多,這里只介紹一種由 Leslie Lamport 建議的簡單方式。這個方式的想法是,因為服務器都有它們自己的 ID ,讓有最高 ID 的服務器作為領導者。可以通過每個服務器定期(每 T ms)向其他服務器發送心跳消息的方式來實現。這些消息包含發送服務器的 ID ,當然同時所有的服務器都會監控它們從其他服務器處收到的心跳檢測,如果它們沒有能收到某一具有高 ID 的服務器的心跳消息,這個間隔(通常是 2T ms)需要設置的足夠長,讓消息有足夠的通信傳遞時間。所以,如果這些服務器沒有能接收到高 ID 的服務器消息,然后它們會自己選舉成為領導者。也就是說,首先它會從客戶端接受到請求,其次在 Paxos 協議中,它會同時扮演 提議者(proposer) 和 接受者(acceptor) 兩種角色。如果機器能夠接收到來自高 ID 的服務器的心跳消息,它就不會作為領導者,如果它接收到客戶端的請求,那么它會拒絕這個請求,並告知客戶端與 領導者(leader) 進行通信。另外一件事是,非 領導者(leader) 服務器不會作為 提議者(proposer) ,只會作為 接受者(acceptor) 。這個機制的優勢在於,它不太可能出現兩個 領導者(leader) 同時工作的情況,即使這樣,如果出現了兩個 領導者(leader) ,Paxos 協議還是能正常工作,只是不是那么高效而已。
應該注意的是,實際上大多數系統都不會采用這種選舉方式,它們會采用基於租約的方式(lease based approach),這比上述介紹的機制要復雜的多,不過也有其優勢。
減少准備(Prepare)的 RPC 請求
另一個提高效率的方式就是減少准備請求的 RPC 調用次數,我們幾乎可以擺脫所有的准備(Prepare)請求。為了理解它的工作方式,讓我們先來回憶一下為什么我們需要准確請求(Prepare)。首先,我們需要使用提議序號來阻止老的提議,其次,我們使用准備階段來檢查已經被接受的值,這樣就可以使用這些值來替代原本自己希望接受的值。
第一個問題是阻止所有的提議,我們可以通過改變提議序號的含義來解決這個問題,我們將提議序號全局化,代表完整的日志,而不是為每個日志記錄都保留獨立的提議序號。這么做要求我們在完成一輪准備請求后,當然我們知道這樣做會鎖住整個日志,所以后續的日志記錄不需要其他的准備請求。
第二個問題有點討巧。因為在不同接受者的不同日志記錄里有很多接受值,為了處理這些值,我們擴展了准備請求的返回信息。和之前一樣,准備請求仍然返回 接受者(acceptor) 所接受的最高 ID 的提議,它只對當前記錄這么做,不過除了這個, 接受者(acceptor) 會查看當前請求的后續日志記錄,如果后續的日志里沒有接受值,它還會返回這些記錄的標志位 noMoreAccepted 。
最終如果我們使用了這種領導者選舉的機制,領導者會達到一個狀態,每個 接受者(acceptor) 都返回 noMoreAccepted ,領導者知道所有它已接受的記錄。所以一旦達到這個狀態,對於單個 接受者(acceptor) 我們不需要再向這些 接受者(acceptor) 發送准備請求,因為它們已經知道了日志的狀態。
不僅如此,一旦從集群大多數 接受者(acceptor) 那獲得 noMoreAccepted 返回值,我們就不需要發送准備的 RPC 請求。也就是說, 領導者(leader) 可以簡單地發送接受(Accept)請求,只需要一輪的 RPC 請求。這個過程會一直執行下去,唯一能改變的就是有其他的服務器被選舉成了 領導者(leader) ,當前 領導者(leader) 的接受(Accept)請求會被拒絕,整個過程會重新開始。
復制的完整性
這個問題的目的是讓所有的 接受者(acceptor) 都能完全接受到日志的最新信息。現在算法並沒有提供完整的信息。例如,日志記錄可能沒有在所有的服務器上被完整復制,所選擇的值只是在大多數服務器上被接受。但我們要保證的就是每條日志記錄在每台服務器上都被完全復制。第二個問題是,現在只有 提議者(proposer) 知道某個已被選定的特定值,知道的方式是通過收到大多數 接受者(acceptor) 的響應,但其他的服務器並不知道記錄是否已被選定。例如, 接受者(acceptor) 不知道它們存儲的記錄已被選定,所以我們還想通知所有的服務器,讓它們知道已被選定的記錄。提供這種完整信息的一個原因在於,它讓所有的服務器都可以將命令傳至它們的狀態機,然后通過這個狀態機執行這些命令。所以這些狀態機可以和領導者服務器上的狀態機保持一致。如果我沒有這么做,他們就沒有日志記錄也不知道哪個日志記錄是被選定的,也就無法在狀態機中執行這些命令。
下面會通過四步來解釋這個過程:
-
第一步,在我們達成仲裁之前不會停止接受(Accept)請求的 RPC 。也就是說如果我們知道大多數服務器已經選定了日志記錄,那么就可以繼續在本地狀態機中執行命令,並返回給客戶端。但是在后台會不斷重試這些 Accept RPC 直到獲得所有服務器的應答,由於這是后台運行的,所以不會使系統變慢。這樣就能保證在本服務器上創建的記錄能同步到其他服務器上,這樣也就提供了完整的復制。但這並沒有解決所有問題,因為也可能有其他更早的日志記錄在服務器崩潰前只有部分已復制,沒有被完整復制。
-
第二步,每台服務器需要跟蹤每個已知被選中的記錄,需要做到兩點:首先,如果服務器發現一條記錄被選定,它會為這條記錄設置 acceptedProposal 值為無窮大 ∞ 。這個標志表示當前的提議已被選定,這個無窮大 ∞ 的意義在於,永遠不會再覆蓋掉這個已接受的提議,除非獲得了另外一個有更高 ID 的提議,所以使用無窮大 ∞ 可以知道,這個提議不再會被覆蓋掉。除此之外,每台服務器還會保持一個 firstUnchosenIndex 值:這個值是表示未被標識選定的最小下標位置。這個也是已接受提議值不為無窮大 ∞ 的最低日志記錄
-
第三步, 提議者(proposer) 為 接受者(acceptor) 提供已知被選定的記錄信息,它以捎帶的方式在接受請求中提供相關的信息。每條由 提議者(proposer) 發送給 接受者(acceptor) 的請求都包括首個未被選定值的下標索引位置 firstUnchosenIndex ,換句話說 接受者(acceptor) 現在知道所有記錄的提議序號低於這個值的都已經被選定,它可以用這個來更新自己。為了解釋這個問題,我們用例子來進行說明,假設我們有一個 接受者(acceptor) 里的日志如上圖所示。在它接收到接受請求之前,日志的信息里知道的提議序號為 1、2、3、5 已經被標記為了選定,記錄 4、6 有其他提議序號,所以它們還沒有被認定是已選定的。現在假設接收到接受請求
Accept(proposal=3.4, index=8, value=v, firstUnchosenIndex=7)
它的提議序號是 3.4 ,firstUnchosenIndex 的值為 7 ,這也意味着在 提議者(proposer) 看來,所有 1 至 6 位的記錄都已經被選定, 接受者(acceptor) 使用這個信息來比較提議序號,以及日志記錄里所有已接受的提議序號,如果存在任意記錄具有相同的提議序號,那么就會標記為 接受者(acceptor) 。在這個例子中,日志記錄 6 有匹配的提議序號 3.4 ,所以 接受者(acceptor) 會標記這條記錄為已選定。之所以能這樣,是因為 接受者(acceptor) 知道相關信息。首先,因為 接受者(acceptor) 知道當前這個日志記錄來自於發送接受消息的同一 提議者(proposer) ,我們同時還知道記錄 6 已經被 提議者(proposer) 選定,而且我們還知道, 提議者(proposer) 沒有比這個日志里更新的值,因為在日志記錄里已接受的提議序號值與 提議者(proposer) 發送的接受消息中的提議序號值相同,所以我們知道這條記錄在選定范圍以內,它還是我們所能知道的, 提議者(proposer) 里可能的最新值。所以它一定是一個選定的值。所以 接受者(acceptor) 可以將這條記錄標記為已選定的。因為同時我們還接收到關於新記錄 8 的請求,所以在接收到接受消息之后,記錄 8 處提議序號值為 3.4 。
這個機制無法解決所有的問題。問題在於 接受者(acceptor) 可能會接收到來自於不同 提議者(proposer) 的某些日志記錄,這里記錄 4 可能來自於之前輪次的服務器 S5 ,不幸的是這種情況下, 接受者(acceptor) 是無法知道該記錄是否已被選定。它也可能是一個已失效過時的值。我們知道它已經被 提議者(proposer) 選定,但是它可能應該被另外一個值所取代。所以還需要多做一步。
- 第四步, 接受者(acceptor) 接收的處理日志記錄來自於舊領導者(leader),所以它不確定記錄的值是否已被選定。當 接受者(acceptor) 響應接受請求時,它會返回自己的第一個為選定值的下標 firstUnchosenIndex ,當 提議者(proposer) 接收到響應時,會將自己的 firstUnchosenIndex 與響應里的 firstUnchosenIndex 作比較,如果 提議者(proposer) 的下標位置更大,這就表明 接受者(acceptor) 的某些狀態是不確定的,這時 提議者(proposer) 會將准確的內容發送給 接受者(acceptor) ,這都可以通過一個新的 RPC 調用來完成。這是 Multi-Paxos 使用的第三種 RPC 調用,這個調用包括兩個參數,日志記錄的下標位置及該位置的值。這條消息可以讓某一特定位置的某一特定值被選定,不會再存在未知的情況,所以接受者只要將這個信息從消息中提取並更新它自己的日志記錄即可,然后返回它更新的 firstUnchosenIndex ,因為可能存在有多個未知狀態的情況,所以 提議者(proposer) 會發送多個 RPC 請求,直到最后 接受者(acceptor) 與 提議者(proposer) 的日志狀態達成一致。
這一系列機制可以保證最終,所有的服務器里的日志記錄都可以被選定,而且它們知道已被選定。在通常情況下,是不會有額外開銷的,額外的開銷僅存在與領導者被切換的情況,這個時間也非常短暫。
客戶端協議
Multi-Paxos 第五個問題是客戶端如何與系統進行交互的。如果客戶端想要發送一條命令,它將命令發送給當前集群的 領導者(leader) 。如果客戶端正好剛啟動,它並不知道哪個服務器是作為 領導者(leader) 的,這樣它會向任一服務器發送命令,如果服務器不是 領導者(leader) 它會返回一些信息,讓客戶端重試並向真正的 領導者(leader) 發送命令。一旦 領導者(leader) 收到消息, 領導者(leader) 會為命令確定選定值所處位置,在確定之后,就會將這個命令傳遞給它自己的狀態機,一旦狀態機執行命令后,它就會將結果返回給客戶端。客戶端會一直向某一 領導者(leader) 發送命令,知道它無法找到這個 領導者(leader) 為止,例如, 領導者(leader) 可能會崩潰,此時客戶端的請求會發送超時,在此種情況下,客戶端會隨便選擇任意隨機選取一台服務器,並對命令進行重試,最終集群會選擇一個新的 領導者(leader) 並重試請求,最終請求會成功得到應答。
但是這個重試機制存在問題。
如果 領導者(leader) 已經成功執行了命令,在響應前的最后一秒崩潰了?這時客戶端會嘗試在新 領導者(leader) 下重試命令,這樣就可能會導致相同的命令被執行兩次,這是不允許發生的,我們需要保證的是每個命令都僅執行一次。為了達到目的,客戶端需要為每條命令提供一個唯一的 ID ,這個 ID 可以是客戶端的 ID 以及一個序列號,這條記錄包含客戶端發送給服務器的信息,服務器會記錄這個 ID 以及命令的值,同時,當狀態機執行命令時,它會跟蹤最近的命令的信息,即最高 ID 序號,在它執行新命令之前,它會檢查命令是否已經被執行過,所以在 領導者(leader) 崩潰的情況下,客戶端會以新的 領導者(leader) 來重試,新的 領導者(leader) 可以看到所有已執行過的命令,包括舊 領導者(leader) 崩潰之前已執行的命令,這樣它就不會重復執行這條命令,只會返回首次執行的結果。
結果就是,只要客戶端不崩潰,就能獲得 exactly once 的保證,每個客戶端命令僅被集群執行一次。如果客戶端出現崩潰,就處於 at most once 的情況,也就是說客戶端的命令可能執行,也可能沒有執行。但是如果客戶端是活着的,這些命令只會執行一次。
配置變更
最后的一個問題在配置變更的情況。
這里說的系統配置信息指的是參與共識性協議的服務器信息,通常也就是服務器的 ID ,服務器的網絡地址。這些配置的重要性在於,它決定了仲裁過程,當前仲裁的大多數代表什么。如果我們改變服務器數量,那么結果也會發生變化。我們有對配置提出變更的需求,例如如果服務器失敗了,我們可能需要切換並替代這台服務器,又或我們需要改變仲裁的規模,我們希望集群更加可靠(比如從 5 台服務器提升到 7 台)。
這些變更需要非常小心,因為它會改變仲裁的規模。
另外一點就是需要保證在任何時候都不能出現兩個不重疊的多數派,這會導致同一日志記錄選擇不同值的情況。假設我們將集群內服務器從 3 台提升到 5 台時,在某些情況下,有些服務器會相信舊的配置是有效的,有些服務器會認為新配置是有效的。這可能會導致最上圖最左側的兩台服務器還以舊的配置信息進行仲裁,選擇的值是(v1),而右側的三台服務器認為新配置是有效的,所以 3 台服務器構成了大多數,這三台服務器會選擇一個不同的值(v2)。這是我們不希望發發生的。
Paxos 配置變更解決方案
Leslie Lamport 建議的 Paxos 配置變更方案是用日志來管理這些變更,當前的配置作為日志的一條記錄存儲,並與其他的日志記錄一同被復制同步。所以上圖中 1-C1、3-C2 表示兩個配置信息,其他的用來存儲普通的命令。這里有趣的是,配置所使用每條記錄是由它的更早的記錄所決定的。這里有一個系統參數 å 來決定這個更早是多早,假設 å 為 3,我們這里有兩個配置相關的記錄,C1 存於記錄 1 中,C2 存於記錄 3 中,這也意味着 C1 在 3 條記錄內不會生效,也就是說,C1 從記錄 4 開始才會生效。C2 從記錄 6 開始才會生效。所以當我們選擇記錄 1、2、3 時,生效的配置會是 C1 之前的那條配置,這里我們將其標記為 C0 。這里的 å 是在系統啟動時配置好的參數,這個參數可以用來限制同時使用的配置信息,也就是說,我們是無法在 i+å 之前選擇使用記錄 i 中的配置的。因為我們無法知道哪些服務器使用哪些配置,也無法知道大多數所代表的服務器數量。所以如果 å 的值很小時,整個過程是序列化的,每條記錄選擇的配置都是不同的,如果 å 為 3 ,也就意味着同時有三條記錄可以使用相同的配置,如果 å 大很多時,事情會變得更復雜,我們需要長時間的等待,讓新的配置生效。也就是說如果 å 的值是 1000 時,我們需要在 1000 個記錄之后才能等到這個配置生效。
Paxos 總結
參考
參考來源:
2013 Paxos lecture, Diego Ongaro
Wiki: Byzantine fault tolerance