
作者 | 祥光
來源 |
阿裡技術公衆号一 引言
成員變更是一緻性系統實作繞不開的難題,對于提升運維能力以及服務可用性都有很大的幫助。
本文從Raft成員變更理論出發,介紹了Raft成員變更和單步成員變更的問題,其中包括Raft著名的Bug。
對于Raft成員變更的工程實作上需要考慮的問題,本文給出了一些工程實踐經驗。
二 Raft成員變更簡介
分布式系統運作過程中節點經常會出現故障,需要支援節點的動态增加和删除。
成員變更是在叢集運作過程中改變運作一緻性協定的節點,如增加、減少節點、節點替換等。成員變更過程不能影響系統的可用性。
成員變更也是一個一緻性問題,即所有節點對新成員達成一緻。但是成員變更又有其特殊性,因為在成員變更的過程中,參與投票的成員會發生變化。
如果将成員變更當成一般的一緻性問題,直接向Leader節點發送成員變更請求,Leader同步成員變更日志,達成多數派之後送出,各節點送出成員變更日志後從舊成員配置(Cold)切換到新成員配置(Cnew)。
因為各個節點送出成員變更日志的時刻可能不同,造成各個節點從舊成員配置(Cold)切換到新成員配置(Cnew)的時刻不同。可能在某一時刻出現Cold和Cnew中同時存在兩個不相交的多數派,進而可能選出兩個Leader,形成不同的決議,破壞安全性。
圖1 成員變更的某一時刻Cold和Cnew中同時存在兩個不相交的多數派
如圖1是3個節點的叢集擴充到5個節點的叢集,直接擴充可能會造成Server1和Server2構成老成員配置的多數派,Server3、Server4和Server5構成新成員配置的多數派,兩者不相交進而可能導緻決議沖突。
由于成員變更的這一特殊性,成員變更不能當成一般的一緻性問題去解決。為了解決這個問題,Raft提出了兩階段的成員變更方法Joint Consensus。
1 Joint Consensus成員變更
Joint Consensus成員變更讓叢集先從舊成員配置Cold切換到一個過渡成員配置,稱為聯合一緻成員配置(Joint Consensus),聯合一緻成員配置是舊成員配置Cold和新成員配置Cnew 的組合Cold,new,一旦聯合一緻成員配置Cold,new送出,再切換到新成員配置Cnew 。
圖2 Joint Consensus成員變更
Leader收到成員變更請求後,先向Cold和Cnew同步一條Cold,new日志,此後所有日志都需要Cold和Cnew兩個多數派的确認。Cold,new日志在Cold和Cnew都達成多數派之後才能送出,此後Leader再向Cold和Cnew同步一條隻包含Cnew的日志,此後日志隻需要Cnew的多數派确認。Cnew日志隻需要在Cnew達成多數派即可送出,此時成員變更完成,不在Cnew中的成員自動下線。
成員變更過程中如果發生Failover,老Leader當機,Cold,new中任意一個節點都可能成為新Leader,如果新Leader上沒有Cold,new日志,則繼續使用Cold,Follower上如果有Cold,new日志會被新Leader截斷,回退到Cold,成員變更失敗;如果新Leader上有Cold,new日志,則繼續将未完成的成員變更流程走完。
Joint Consensus成員變更比較通用且容易了解,但是實作比較複雜,之是以分為兩個階段,是因為對 與 的關系沒有做任何假設,為了避免 和 各自形成不相交的多數派而選出兩個Leader,才引入了兩階段方案。
如果增強成員變更的限制,假設Cold與Cnew任意的多數派交集不為空,Cold與Cnew就無法各自形成多數派,則成員變更就可以簡化為一階段。
2 單步成員變更
實作單步的成員變更,關鍵在于限制Cold與Cnew,使之任意的多數派交集不為空。方法就是每次成員變更隻允許增加或删除一個成員。
圖3 增加或删除一個成員
增加或删除一個成員時的情形,如圖3所示,可以從數學上嚴格證明,隻要每次隻允許增加或删除一個成員,Cold與Cnew不可能形成兩個不相交的多數派。是以隻要每次隻增加或删除一個成員,從Cold可直接切換到Cnew,無需過渡成員配置,實作單步成員變更。
單步成員變更一次隻能變更一個成員,如果需要變更多個成員,可以通過執行多次單步成員變更來實作。
單步成員變更理論雖然簡單,但卻埋了很多坑,實際用起來并不是那麼簡單。
三 Raft單步成員變更的問題
Raft單步成員變更的問題,最著名的莫過于Raft著名的正确性問題,另外單步成員變更還有潛在的可用性問題。
1 Raft單步成員變更的正确性問題
Raft單步變更過程中如果發生Leader切換會出現正确性問題,可能導緻已經送出的日志又被覆寫。Raft作者(Diego Ongaro)早在2015年就發現了這個問題,并且在Raft-dev詳細的說明了這個問題[1]。
下面是一個Raft單步變更出問題的例子, 初始成員配置是abcd這4節點,節點u和V要加入叢集, 如果中間出現Leader切換, 就會丢失已送出的日志:
圖4 Raft單步成員變更的正确性問題
- t0:節點abcd的成員配置為C0;
- t1 :節點abcd在Term 0選出a為Leader,b和c為Follower;
- t2:節點a同步成員變更日志Cu,隻同步到a和u,未成功送出;
- t3:節點a當機;
- t4:節點d在Term 1被選為Leader,b和c為Follower;
- t5:節點d同步成員變更日志Cv,同步到c、d、V,成功送出;
- t6:節點d同步普通日志E,同步到c、d、V,成功送出;
- t7:節點d當機;
- t8:節點a在Term 2重新選為Leader,u和b為Follower;
- t9:節點a同步本地的日志Cu給所有人,造成已送出的Cv和E丢失。
為什麼會出現這樣的問題呢?根本原因是上一任Leader的成員變更日志還沒有同步到多數派就當機了,新Leader一上任就進行成員變更,使用新的成員配置送出日志,之前上一任Leader重新上任之後可能形成另外一個多數派集合,産生腦裂,将已送出的日志覆寫,造成資料丢失。
Raft作者在發現這個問題之後,也給出了修複方法。修複方法很簡單, 跟Raft的日志Commit條件類似:新任Leader必須在目前Term送出一條日志之後,才允許同步成員變更日志。也即Leader在目前Term還未送出日志之前,不允許同步成員變更日志。
按照這個修複方法,最簡單的實作就是Leader上任後先送出一條no-op日志,然後再同步成員變更日志。這條no-op日志可以保證跟上一任Leader未送出的成員變更日志至少有一個節點交集,這樣可以發現上一任Leader的日志是舊的,進而阻止上一任Leader重新選為Leader,進而阻止了腦裂的産生。
對應上面這個例子,就是L1當選Leader後必須先送出一條no-op日志,然後才能開始同步Cv和E,以便能發現L2的日志是舊的,進而阻止L2當選Leader。
另一種方法是使用Joint Consensus成員變更,沒有這樣的正确性問題。
2 Raft單步成員變更的可用性問題
單步成員變更每次隻能增加或者減少一個成員,在做成員替換的時候需要分兩次變更,第一次變更先将新成員加入進來,第二次變更再将老成員删除,中間如果如果網絡分區,有可能會導緻服務不可用。
考慮a、b、c三個成員部署在三個機房,現在因為a發生故障要将a替換為同機房的d。按照單步成員變更,abc要先變為abcd,再變為bcd。
中間經曆的4節點abcd的狀态, 有可能在出現二分的網絡分區(ad|bc)時導緻整個叢集不可用。因為a與d位于同一機房,這種二分網絡分區的情況在實際情況中還是不容忽視的。
怎麼解決這個問題呢?一種方法是做成員替換的時候,先删除老成員,再加入新成員,即abc先變為bc,再變為bcd,這樣可以避免abcd的狀态。
另一種方法是使用Joint Consensus成員變更,abc先變為abc U bcd ,再變為bcd,也不會經曆abcd的狀态。
四 Raft成員變更的工程實踐
Raft成員變更的理論雖簡單,但實際工程實作上還是有很多地方要考慮。因為Raft單步成員變更有正确性問題及可用性問題,工程上建議盡量使用Joint Consensus成員變更,這裡主要讨論一些Joint Consensus成員變更工程實作上必須考慮的問題。
1 新成員先加入再同步資料還是先同步資料再加入
因為Raft需要嚴格保證順序,而新成員上還沒有任何資料,是以新成員加入叢集後需要先同步資料才能正常工作。工程實作時就有兩種選擇,一種是讓新成員先加入再同步資料,另一種是先給新成員同步資料,同步完成後再加入。這兩種方式各有利弊。
表1 新成員先加入再同步資料和先同步資料再加入的優缺點
新成員先加入再同步資料,成員變更可以立即完成,并且因為隻要大多數成員同意即可加入,甚至可以加入還不存在的成員,加入後再慢慢同步資料。但在資料同步完成之前新成員無法服務,但新成員的加入可能讓多數派集合增大,而新成員暫時又無法服務,此時如果有成員發生Failover,很可能導緻無法滿足多數成員存活的條件,讓服務不可用。是以新成員先加入再同步資料,簡化了成員變更,但可能降低服務的可用性。
新成員先同步資料再加入,成員變更需要背景異步進行,先将新成員作為Learner角色加入,隻能同步資料,不具有投票權,不會增加多數派集合,等資料同步完成後再讓新成員正式加入,正式加入後可立即開始工作,不影響服務可用性。是以新成員先同步資料再加入,不影響服務的可用性,但成員變更流程複雜,并且因為要先給新成員同步資料,不能加入還不存在的成員。
2 成員變更日志使用什麼配置
成員變更日志本身是為了改變成員配置,處在成員配置變更的臨界點上,是以成員變更日志使用什麼配置就很關鍵。
表2 Joint Consensus成員變更日志使用的成員配置
對于Joint Consensus成員變更,成員變更日志使用什麼配置是确定的。Cold,new日志使用聯合一緻成員配置Cold,new,需要老成員配置Cold和新成員配置Cnew兩個多數派确認才能送出,Cnew日志使用新成員配置Cnew,隻需要新成員配置Cnew的多數派确認即可送出,但Cnew日志也會同步給老成員配置Cold,主要是為了讓Cold中不在Cnew中的成員自動退出。
3 成員變更日志什麼時候生效
成員變更通過成員變更日志來完成,讓各成員對成員配置達成一緻,但成員變更日志與普通日志不同,并不一定要等到送出後Apply生效。
表3 成員變更日志的生效時機
對于Joint Consensus成員變更,成員變更日志什麼時候生效是确定的。在Leader上開始同步成員變更日志之前就需要生效,在Follower上成員變更日志持久化完成後就需要生效。成員變更日志還未送出就先生效了,是以在Leader切換後可能會復原。
4 成員變更期間日志是否需要嚴格按序送出
考慮這樣一種情況,成員變更減少了成員數量,進而減小了多數派集合,而更小的多數派更容易達成,造成成員變更之後的日志比之前的日志先達成多數派。
按照Raft論文中的commitIndex的推進算法:
If there exists an N such that N > commitIndex, a majority of matchIndex[i] ≥ N, and log[N].term == currentTerm:
set commitIndex = N
一條日志達成多數派就往前推進commitIndex至該日志,如果該日志之前有日志按照老成員配置還未達成多數派,也一并送出了。
這種情況是否會出問題呢?實際上并不會,因為成員變更之後,已經有日志使用新成員配置送出了,不在新成員配置中的節點不可能再當選Leader了,進而不會覆寫之前的日志,是以就算之前的日志按照老成員配置未達成多數派也可以安全的送出。
hashicorp raft的實作還是嚴格按序送出的,即隻有前面的日志都達成多數派之後才能送出。
5 隻有少數成員存活時怎麼恢複服務
Raft隻能在大多數成員存活的情況下才能正常工作,實際可能會遇到隻有少數成員存活的情況,這個時候要怎麼恢複服務
呢。
因為隻有少數成員存活,已經不能達成多數派,不能寫入資料,也不能做正常的成員變更。需要提供一個強制更改成員配置的接口,通過它設定每個成員的成員配置清單,便于從大多數成員故障中恢複。
比如隻剩一個成員S1存活的時候,強制更改成員配置設定成員清單為{S1},這樣形成一個隻有S1的成員清單,讓S1繼續提供讀寫服務,後續再排程其他節點通過成員變更加入。通過強制修改成員清單,可以實作最大可用模式。
五 單步成員變更的工程實踐
單步成員變更雖然不推薦在工程中使用,這裡還是總結一下單步成員變更的一些工程實踐,供研究讨論。
1 單步成員變更日志使用什麼配置
對于單步成員變更,成員變更日志是使用新成員配置 還是老成員配置Cnew呢?實際上單步成員變更日志無論使用新成員配置Cold還是老成員配置Cnew都不會破壞Cold與Cnew的多數派至少有一個節點相交,是以單步成員變更日志既可以使用新成員配置Cold也可以使用老成員配置Cnew,兩種方式各有利弊。
表4 單步成員變更日志使用老成員配置和使用新成員配置的優缺點
單步成員變更日志使用老成員配置Cold,可以避免單步成員變更的正确性問題,是以可以省略掉Leader上任後的no-op日志,同時在增加成員時可能隻需要更小的多數派集合,但在減少成員時可能需要更大的多數派集合。
單步成員變更日志使用新成員配置Cnew,需要Leader上任後先送出一條no-op日志,以避免單步成員變更的正确性問題,同時在減少成員時可能隻需要更小的多數派集合,但在增加成員時可能需要更大的多數派集合。
單步成員變更日志不管使用新成員配置還是老成員配置,最好都同步給新老成員配置中的所有成員,這樣在增加成員時可以讓新成員遲早收到通知,在減少成員時也可以讓被删除的成員收到通知而自動退出。
Raft論文中單步成員變更日志使用新成員配置Cnew,etcd中單步成員變更日志使用老成員配置Cold。
2 單步成員變更日志什麼時候生效
表5 單步成員變更日志的生效時機
對于單步成員變更,如果成員變更日志使用新成員配置,則與Joint Consensus成員變更一樣,Leader上開始同步成員變更日志之前就需要生效,在Follower上成員變更日志持久化完成後就需要生效。如果成員變更日志使用老成員配置,理論上隻需要在下一次成員變更開始之前生效即可,但實際為了讓新加入的節點盡快開始服務,一般在成員變更日志送出後就生效。
Raft論文中單步成員變更日志使用新成員配置Cnew,本地持久化完成就生效;etcd中單步成員變更日志使用老成員配置Cold,送出後再生效。
六 總結
Raft提供了Joint Consensus成員變更和單步成員變更,極大的推動了成員變更在工程中的應用。本文總結了一些Raft單步成員變更的問題,以及成員變更的工程實踐。Joint Consensus通用并且不容易踩坑,一階段成員變更坑比較多。工程上建議盡量使用Joint Consensus成員變更。
相關連結
[1]https://groups.google.com/g/raft-dev/c/t4xj6dJTP6E