Replication(上):常見覆制模型&分散式系統挑戰

美團技術團隊發表於2022-11-24

分散式系統設計是一項十分複雜且具有挑戰性的事情。其中,資料複製與一致性更是其中十分重要的一環。資料複製領域概念龐雜、理論性強,如果對應的演算法沒有理論驗證大機率會出錯。如果在設計過程中,不瞭解對應理論所解決的問題以及不同理論之間的聯絡,勢必無法設計出一個合理的分散式系統。

本系列文章分上下兩篇,以《資料密集型應用系統設計(DDIA)》(下文簡稱《DDIA》)為主線,文中的核心理論講解與圖片來自於此書。在此基礎上,加入了日常工作中對這些概念的理解與個性化的思考,並將它們對映到Kafka中,跟大家分享一下如何將具體的理論應用於實際生產環境中。

1. 簡介

1.1 簡介——使用複製的目的

在分散式系統中,資料通常需要被分散在多臺機器上,主要為了達到以下目的:

  1. 擴充套件性,資料量因讀寫負載巨大,一臺機器無法承載,資料分散在多臺機器上可以有效地進行負載均衡,達到靈活的橫向擴充套件。
  2. 容錯、高可用,在分散式系統中,單機故障是常態,在單機故障下仍然希望系統能夠正常工作,這時候就需要資料在多臺機器上做冗餘,在遇到單機故障時其他機器就可以及時接管。
  3. 統一的使用者體驗,如果系統客戶端分佈在多個地域,通常考慮在多個地域部署服務,以方便使用者能夠就近訪問到他們所需要的資料,獲得統一的使用者體驗。

資料的多機分佈的方式主要有兩種,一種是將資料分片儲存,每個機器儲存資料的部分分片(Kafka中稱為Partition,其他部分系統稱為Shard),另一種則是完全的冗餘,其中每一份資料叫做一個副本(Kafka中稱為Replica),透過資料複製技術實現。在分散式系統中,兩種方式通常會共同使用,最後的資料分佈往往是下圖的樣子,一臺機器上會儲存不同資料分片的若干個副本。本系列博文主要介紹的是資料如何做複製,分割槽則是另一個主題,不在本文的討論範疇。

圖1 常見資料分佈

複製的目標需要保證若干個副本上的資料是一致的,這裡的“一致”是一個十分不確定的詞,既可以是不同副本上的資料在任何時刻都保持完全一致,也可以是不同客戶端不同時刻訪問到的資料保持一致。一致性的強弱也會不同,有可能需要任何時候不同客端都能訪問到相同的新的資料,也有可能是不同客戶端某一時刻訪問的資料不相同,但在一段時間後可以訪問到相同的資料。因此,“一致性”是一個值得單獨抽出來細說的詞。在下一篇文章中,我們將重點介紹這個詞在不同上下文之間的含義。

此時,大家可能會有疑問,直接讓所有副本在任意時刻都保持一致不就行了,為啥還要有各種不同的一致性呢?我們認為有兩個考量點,第一是效能,第二則是複雜性。

效能比較好理解,因為冗餘的目的不完全是為了高可用,還有延遲和負載均衡這類提升效能的目的,如果只一味地為了地強調資料一致,可能得不償失。複雜性是因為分散式系統中,有著比單機系統更加複雜的不確定性,節點之間由於採用不大可靠的網路進行傳輸,並且不能共享統一的一套系統時間和記憶體地址(後文會詳細進行說明),這使得原本在一些單機系統上很簡單的事情,在轉到分散式系統上以後就變得異常複雜。這種複雜性和不確定性甚至會讓我們懷疑,這些副本上的資料真的能達成一致嗎?下一篇文章會專門詳細分析如何設計演算法來應對這種複雜和不確定性。

1.2 文章系列概述

本系列博文將分為上下兩篇,第一篇將主要介紹幾種常見的資料複製模型,然後介紹分散式系統的挑戰,讓大家對分散式系統一些稀奇古怪的故障有一些感性的認識。

第二篇文章將針對本篇中提到的問題,分別介紹事務、分散式共識演算法和一致性,以及三者的內在聯絡,再分享如何在分散式系統中保證資料的一致性,進而讓大家對資料複製技術有一個較為全面的認識。此外,本系列還將介紹業界驗證分散式演算法正確性的一些工具和框架。接下來,讓我們一起開始資料複製之旅吧!

2. 資料複製模式

總體而言,最常見的複製模式有三種,分別為主從模式、多主節點模式、無主節點模式,下面分別進行介紹。

2.1 最簡單的複製模式——主從模式

簡介

對複製而言,最直觀的方法就是將副本賦予不同的角色,其中有一個主副本,主副本將資料儲存在本地後,將資料更改作為日誌,或者以更改流的方式發到各個從副本(後文也會稱節點)中。在這種模式下,所有寫請求就全部會寫入到主節點上,讀請求既可以由主副本承擔也可以由從副本承擔,這樣對於讀請求而言就具備了擴充套件性,並進行了負載均衡。但這裡面存在一個權衡點,就是客戶端視角看到的一致性問題。這個權衡點存在的核心在於,資料傳輸是透過網路傳遞的,資料在網路中傳輸的時間是不能忽略的。

圖2 同步複製與非同步複製

如上圖所示,在這個時間視窗中,任何情況都有可能發生。在這種情況下,客戶端何時算寫入完成,會決定其他客戶端讀到資料的可能性。這裡我們假設這份資料有一個主副本和一個從副本,如果主副本儲存後即向客戶端返回成功,這樣叫做非同步複製(1)。而如果等到資料傳送到從副本1,並得到確認之後再返回客戶端成功,稱為同步複製(2)。這裡我們先假設系統正常執行,在非同步同步下,如果從副本承擔讀請求,假設reader1和reader2同時在客戶端收到寫入成功後發出讀請求,兩個reader就可能讀到不一樣的值。

為了避免這種情況,實際上有兩種角度的做法,第一種角度是讓客戶端只從主副本讀取資料,這樣,在正常情況下,所有客戶端讀到的資料一定是一致的(Kafka當前的做法);另一種角度則是採用同步複製,假設使用純的同步複製,當有多個副本時,任何一個副本所在的節點發生故障,都會使寫請求阻塞,同時每次寫請求都需要等待所有節點確認,如果副本過多會極大影響吞吐量。而如果僅採用非同步複製並由主副本承擔讀請求,當主節點故障發生切換時,一樣會發生資料不一致的問題。

很多系統會把這個決策權交給使用者,這裡我們以Kafka為例,首先提供了同步與非同步複製的語義(透過客戶端的acks引數確定),另外提供了ISR機制,而只需要ISR中的副本確認即可,系統可以容忍部分節點因為各種故障而脫離ISR,那樣客戶端將不用等待其確認,增加了系統的容錯性。當前Kafka未提供讓從節點承擔讀請求的設計,但在高版本中已經有了這個Feature。這種方式使系統有了更大的靈活性,使用者可以根據場景自由權衡一致性和可用性。

主從模式下需要的一些能力

增加新的從副本(節點)

  1. 在Kafka中,我們所採取的的方式是透過新建副本分配的方式,以追趕的方式從主副本中同步資料。
  2. 資料庫所採用的的方式是透過快照+增量的方式實現。

    a.在某一個時間點產生一個一致性的快照。
    b.將快照複製到從節點。
    c.從節點連線到主節點請求所有快照點後發生的改變日誌。
    d.獲取到日誌後,應用日誌到自己的副本中,稱之為追趕。
    e.可能重複多輪a-d。

處理節點失效

從節點失效——追趕式恢復

針對從節點失效,恢復手段較為簡單,一般採用追趕式恢復。而對於資料庫而言,從節點可以知道在崩潰前所執行的最後一個事務,然後連線主節點,從該節點將拉取所有的事件變更,將這些變更應用到本地記錄即可完成追趕。

對於Kafka而言,恢復也是類似的,Kafka在執行過程中,會定期項磁碟檔案中寫入checkpoint,共包含兩個檔案,一個是recovery-point-offset-checkpoint,記錄已經寫到磁碟的offset,另一個則是replication-offset-checkpoint,用來記錄高水位(下文簡稱HW),由ReplicaManager寫入,下一次恢復時,Broker將讀取兩個檔案的內容,可能有些被記錄到本地磁碟上的日誌沒有提交,這時就會先截斷(Truncate)到HW對應的offset上,然後從這個offset開始從Leader副本拉取資料,直到認追上Leader,被加入到ISR集合中

主節點失效--節點切換

主節點失效則會稍稍複雜一些,需要經歷三個步驟來完成節點的切換。

  1. 確認主節點失效,由於失效的原因有多種多樣,大多數系統會採用超時來判定節點失效。一般都是採用節點間互發心跳的方式,如果發現某個節點在較長時間內無響應,則會認定為節點失效。具體到Kafka中,它是透過和Zookeeper(下文簡稱ZK)間的會話來保持心跳的,在啟動時Kafka會在ZK上註冊臨時節點,此後會和ZK間維持會話,假設Kafka節點出現故障(這裡指被動的掉線,不包含主動執行停服的操作),當會話心跳超時時,ZK上的臨時節點會掉線,這時會有專門的元件(Controller)監聽到這一資訊,並認定節點失效。
  2. 選舉新的主節點。這裡可以透過透過選舉的方式(民主協商投票,通常使用共識演算法),或由某個特定的元件指定某個節點作為新的節點(Kafka的Controller)。在選舉或指定時,需要儘可能地讓新主與原主的差距最小,這樣會最小化資料丟失的風險(讓所有節點都認可新的主節點是典型的共識問題)——這裡所謂共識,就是讓一個小組的節點就某一個議題達成一致,下一篇文章會重點進行介紹。
  3. 重新配置系統是新的主節點生效,這一階段基本可以理解為對叢集的後設資料進行修改,讓所有外界知道新主節點的存在(Kafka中Controller透過後設資料廣播實現),後續及時舊的節點啟動,也需要確保它不能再認為自己是主節點,從而承擔寫請求。

問題

雖然上述三個步驟較為清晰,但在實際發生時,還會存在一些問題:

  1. 假設採用非同步複製,在失效前,新的主節點與原主節點的資料存在Gap,選舉完成後,原主節點很快重新上線加入到叢集,這時新的主節點可能會收到衝突的寫請求,此時還未完全執行上述步驟的第三步,也就是原主節點沒有意識到自己的角色發生變化,還會嘗試向新主節點同步資料。這時,一般的做法是,將原主節點上未完成複製的寫請求丟掉,但這又可能會發生資料丟失或不一致,假設我們每條資料採用MySQL的自增ID作為主鍵,並且使用Redis作為快取,假設發生了MySQL的主從切換,從節點的計數器落後於主節點,那樣可能出現應用獲取到舊的自增ID,這樣就會與Redis上對應ID取到的資料不一致,出現資料洩露或丟失。
  2. 假設上面的問題,原主節點因為一些故障永遠不知道自己角色已經變更,則可能發生“腦裂”,兩個節點同時運算元據,又沒有相應解決衝突(沒有設計這一模組),就有可能對資料造成破壞。
  3. 此外,對於超時時間的設定也是個十分複雜的問題,過長會導致服務不可用,設定過短則會導致節點頻繁切換,假設本身系統處於高負載狀態,頻繁角色切換會讓負載進一步加重(團隊內部對Kafka殭屍節點的處理邏輯)。

非同步複製面臨的主要問題--複製滯後

如前文所述,如果我們使用純的同步複製,任何一臺機器發生故障都會導致服務不可寫入,並且在數較多的情況下,吞吐和可用性都會受到比較大的影響。很多系統都會採用半步複製或非同步複製來在可用性和一致性之間做權衡。

在非同步複製中,由於寫請求寫到主副本就返回成功,在資料複製到其他副本的過程中,如果客戶端進行讀取,在不同副本讀取到的資料可能會不一致,《DDIA》將這個種現象稱為複製滯後(Replication Lag),存在這種問題的複製行為所形成的資料一致性統稱為最終一致性。未來還會重點介紹一下一致性和共識,但在本文不做過多的介紹,感興趣的同學可以提前閱讀《Problems with Replication Lag》這一章節。

2.2 多主節點複製

前文介紹的主從複製模型中存在一個比較嚴重的弊端,就是所有寫請求都需要經過主節點,因為只存在一個主節點,就很容易出現效能問題。雖然有從節點作為冗餘應對容錯,但對於寫入請求實際上這種複製方式是不具備擴充套件性的。

此外,如果客戶端來源於多個地域,不同客戶端所感知到的服務相應時間差距會非常大。因此,有些系統順著傳統主從複製進行延伸,採用多個主節點同時承擔寫請求,主節點接到寫入請求之後將資料同步到從節點,不同的是,這個主節點可能還是其他節點的從節點。複製模式如下圖所示,可以看到兩個主節點在接到寫請求後,將資料同步到同一個資料中心的從節點。此外,該主節點還將不斷同步在另一資料中心節點上的資料,由於每個主節點同時處理其他主節點的資料和客戶端寫入的資料,因此需要模型中增加一個衝突處理模組,最後寫到主節點的資料需要解決衝突。

圖3 多主節點複製

使用場景

a. 多資料中心部署

一般採用多主節點複製,都是為了做多資料中心容災或讓客戶端就近訪問(用一個高大上的名詞叫做異地多活),在同一個地域使用多主節點意義不大,在多個地域或者資料中心部署相比主從複製模型有如下的優勢:

  • 效能提升:效能提升主要表現在兩個核心指標上,首先從吞吐方面,傳統的主從模型所有寫請求都會經過主節點,主節點如果無法採用資料分割槽的方式進行負載均衡,可能存在效能瓶頸,採用多主節點複製模式下,同一份資料就可以進行負載均衡,可以有效地提升吞吐。另外,由於多個主節點分佈在多個地域,處於不同地域的客戶端可以就近將請求傳送到對應資料中心的主節點,可以最大程度地保證不同地域的客戶端能夠以相似的延遲讀寫資料,提升使用者的使用體驗。
  • 容忍資料中心失效:對於主從模式,假設主節點所在的資料中心發生網路故障,需要發生一次節點切換才可將流量全部切換到另一個資料中心,而採用多主節點模式,則可無縫切換到新的資料中心,提升整體服務的可用性。

b.離線客戶端操作

除了解決多個地域容錯和就近訪問的問題,還有一些有趣的場景,其中一個場景則是在網路離線的情況下還能繼續工作,例如我們膝上型電腦上的筆記或備忘錄,我們不能因為網路離線就禁止使用該程式,我們依然可以在本地愉快的編輯內容(圖中標記為Offline狀態),當我們連上網之後,這些內容又會同步到遠端的節點上,這裡面我們把本地的App也當做其中的一個副本,那麼就可以承擔使用者在本地的變更請求。聯網之後,再同步到遠端的主節點上。

圖4 Notion介面

c.協同編輯

這裡我們對離線客戶端操作進行擴充套件,假設我們所有人同時編輯一個文件,每個人透過Web客戶端編輯的文件都可以看做一個主節點。這裡我們拿美團內部的學城(內部的Wiki系統)舉例,當我們正在編輯一份文件的時候,基本上都會發現右上角會出現“xxx也在協同編輯文件”的字樣,當我們儲存的時候,系統就會自動將資料儲存到本地並複製到其他主節點上,各自處理各自端上的衝突。

另外,當文件出現了更新時,學城會通知我們有更新,需要我們手動點選更新,來更新我們本地主節點的資料。書中說明,雖然不能將協同編輯完全等同於資料庫複製,但卻是有很多相似之處,也需要處理衝突問題。

衝突解決

透過上面的分析,我們瞭解到多主複製模型最大挑戰就是解決衝突,下面我們簡單看下《DDIA》中給出的通用解法,在介紹之前,我們先來看一個典型的衝突。

a.衝突例項

圖5 衝突例項

在圖中,由於多主節點採用非同步複製,使用者將資料寫入到自己的網頁就返回成功了,但當嘗試把資料複製到另一個主節點時就會出問題,這裡我們如果假設主節點更新時採用類似CAS的更新方式時更新時,都會由於預期值不符合從而拒絕更新。針對這樣的衝突,書中給出了幾種常見的解決思路。

b.解決思路

1. 避免衝突

所謂解決問題最根本的方式則是儘可能不讓它發生,如果能夠在應用層保證對特定資料的請求只發生在一個節點上,這樣就沒有所謂的“寫衝突”了。繼續拿上面的協同編輯文件舉例,如果我們把每個人的都在填有自己姓名錶格的一行裡面進行編輯,這樣就可以最大程度地保證每個人的修改範圍不會有重疊,衝突也就迎刃而解了。

2. 收斂於一致狀態

然而,對更新標題這種情況而言,衝突是沒法避免的,但還是需要有方法解決。對於單主節點模式而言,如果同一個欄位有多次寫入,那麼最後寫入的一定是最新的。ZK、KafkaController、KafkaReplica都有類似Epoch的方式去遮蔽過期的寫操作,由於所有的寫請求都經過同一個節點,順序是絕對的,但對於多主節點而言,由於沒有絕對順序的保證,就只能試圖用一些方式來決策相對順序,使衝突最終收斂,這裡提到了幾種方法:

給每個寫請求分配Uniq-ID,例如一個時間戳,一個隨機數,一個UUID或Hash值,最終取最高的ID作為最新的寫入。如果基於時間戳,則稱作最後寫入者獲勝(LWW),這種方式看上去非常直接且簡單,並且非常流行。但很遺憾,文章一開始也提到了,分散式系統沒有辦法在機器間共享一套統一的系統時間,所以這個方案很有可能因為這個問題導致資料丟失(時鐘漂移)。

每個副本分配一個唯一的ID,ID高的更新優先順序高於地域低的,這顯然也會丟失資料。

當然,我們可以用某種方式做拼接,或利用預先定義的格式保留衝突相關資訊,然後由使用者自行解決。

3. 使用者自行處理

其實,把這個操作直接交給使用者,讓使用者自己在讀取或寫入前進行衝突解決,這種例子也是屢見不鮮,Github採用就是這種方式。

這裡只是簡單舉了一些衝突的例子,其實衝突的定義是一個很微妙的概念。《DDIA》第七章介紹了更多關於衝突的概念,感興趣同學可以先自行閱讀,在下一篇文章中也會提到這個問題。

c.處理細節介紹

此外,在書中將要結束《複製》這一章時,也詳細介紹瞭如何進行衝突的處理,這裡也簡單進行介紹。

這裡我們可以思考一個問題,為什麼會發生衝突?透過閱讀具體的處理手段後,我們可以嘗試這樣理解,正是因為我們對事件發生的先後順序不確定,但這些事件的處理主體都有重疊(比如都有設定某個資料的值)。透過我們對沖突的理解,加上我們的常識推測,會有這樣幾種方式可以幫我們來判斷事件的先後順序。

1. 直接指定事件順序

對於事件發生的先後順序,我們一個最直觀的想法就是,兩個請求誰新要誰的,那這裡定義“最新”是個問題,一個很簡單的方式是使用時間戳,這種演算法叫做最後寫入者獲勝LWW。

但分散式系統中沒有統一的系統時鐘,不同機器上的時間戳無法保證精確同步,那就可能存在資料丟失的風險,並且由於資料是覆蓋寫,可能不會保留中間值,那麼最終可能也不是一致的狀態,或出現資料丟失。如果是一些快取系統,覆蓋寫看上去也是可以的,這種簡單粗暴的演算法是非常好的收斂衝突的方式,但如果我們對資料一致性要求較高,則這種方式就會引入風險,除非資料寫入一次後就不會發生改變。

2. 從事件本身推斷因果關係和併發

上面直接簡單粗暴的制定很明顯過於武斷,那麼有沒有可能時間裡面就存在一些因果關係呢,如果有我們很顯然可以透過因果關係知道到底需要怎樣的順序,如果不行再透過指定的方式呢?

例如:

圖6 違背因果關係示例

這裡是書中一個多主節點複製的例子,這裡ClientA首先向Leader1增加一條資料x=1,然Leader1採用非同步複製的方式,將變更日誌傳送到其他的Leader上。在複製過程中,ClientB向Leader3傳送了更新請求,內容則是更新Key為x的Value,使Value=Value+1。

原圖中想表達的是,update的日誌傳送到Leader2的時間早於insert日誌傳送到Leader2的時間,會導致更新的Key不存在。但是,這種所謂的事件關係本身就不是完全不相干的,書中稱這種關係為依賴或者Happens-before。

我們可能在JVM的記憶體模型(JMM)中聽到過這個詞,在JMM中,表達的也是多個執行緒操作的先後順序關係。這裡,如果我們把執行緒或者請求理解為對資料的操作(區別在於一個是對本地記憶體資料,另一個是對遠端的某處記憶體進行修改),執行緒或客戶端都是一種執行者(區別在於是否需要使用網路),那這兩種Happens-before也就可以在本質上進行統一了,都是為了描述事件的先後順序而生。

書中給出了檢測這類事件的一種演算法,並舉了一個購物車的例子,如圖所示(以餐廳掃碼點餐的場景為例):

圖7 掃碼點餐示例

圖中兩個客戶端同時向購物車裡放東西,事例中的資料庫假設只有一個副本。

  1. 首先Client1向購物車中新增牛奶,此時購物車為空,返回版本1,Value為[牛奶]。
  2. 此時Client2向其中新增雞蛋,其並不知道Client1新增了牛奶,但伺服器可以知道,因此分配版本號為2,並且將雞蛋和牛奶存成兩個單獨的值,最後將兩個值和版本號2返回給客戶端。此時服務端儲存了[雞蛋] 2 [牛奶]1。
  3. 同理,Client1新增麵粉,這時候Client1只認為新增了[牛奶],因此將麵粉與牛奶合併傳送給服務端[牛奶,麵粉],同時還附帶了之前收到的版本號1,此時服務端知道,新值[牛奶,麵粉]可以替換同一個版本號中的舊值[牛奶],但[雞蛋]是併發事件,分配版本號3,返回值[牛奶,麵粉] 3 [雞蛋]2。
  4. 同理,Client2向購物車新增[火腿],但在之前的請求中,返回了雞蛋,因此和火腿合併傳送給服務端[雞蛋,牛奶,火腿],同時附帶了版本號2,服務端直接將新值覆蓋之前版本2的值[雞蛋],但[牛奶,麵粉]是併發事件,因此儲存值為[牛奶,麵粉] 3 [雞蛋,牛奶,火腿] 4並分配版本號4。
  5. 最後一次Client新增培根,透過之前返回的值裡,知道有[牛奶,麵粉,雞蛋],Client將值合併[牛奶,麵粉,雞蛋,培根]聯通之前的版本號一起傳送給服務端,服務端判斷[牛奶,麵粉,雞蛋,培根]可以覆蓋之前的[牛奶,麵粉]但[雞蛋,牛奶,火腿]是併發值,加以保留。

透過上面的例子,我們看到了一個根據事件本身進行因果關係的確定。書中給出了進一步的抽象流程:

  • 服務端為每個主鍵維護一個版本號,每當主鍵新值寫入時遞增版本號,並將新版本號和寫入值一起儲存。
  • 客戶端寫主鍵,寫請求比包含之前讀到的版本號,傳送的值為之前請求讀到的值和新值的組合,寫請求的相應也會返回對當前所有的值,這樣就可以一步步進行拼接。
  • 當伺服器收到有特定版本號的寫入時,覆蓋該版本號或更低版本號的所有值,保留高於請求中版本號的新值(與當前寫操作屬於併發)。

有了這套演算法,我們就可以檢測出事件中有因果關係的事件與併發的事件,而對於併發的事件,仍然像上文提到的那樣,需要依據一定的原則進行合併,如果使用LWW,依然可能存在資料丟失的情況。因此,需要在服務端程式的合併邏輯中需要額外做些事情。

在購物車這個例子中,比較合理的是合併新值和舊值,即最後的值是[牛奶,雞蛋,麵粉,火腿,培根],但這樣也會導致一個問題,假設其中的一個使用者刪除了一項商品,但是union完還是會出現在最終的結果中,這顯然不符合預期。因此可以用一個類似的標記位,標記記錄的刪除,這樣在合併時可以將這個商品踢出,這個標記在書中被稱為墓碑(Tombstone)。

2.3 無主節點複製

之前介紹的複製模式都是存在明確的主節點,從節點的角色劃分的,主節點需要將資料複製到從節點,所有寫入的順序由主節點控制。但有些系統乾脆放棄了這個思路,去掉了主節點,任何副本都能直接接受來自客戶端的寫請求,或者再有一些系統中,會給到一個協調者代表客戶端進行寫入(以Group Commit為例,由一個執行緒積攢所有客戶端的請求統一傳送),與多主模式不同,協調者不負責控制寫入順序,這個限制的不同會直接影響系統的使用方式。

處理節點失效

假設一個資料系統擁有三個副本,當其中一個副本不可用時,在主從模式中,如果恰好是主節點,則需要進行節點切換才能繼續對外提供服務,但在無主模式下,並不存在這一步驟,如下圖所示:

圖8 Quorum寫入處理節點失效

這裡的Replica3在某一時刻無法提供服務,此時使用者可以收到兩個Replica的寫入成功的確認,即可認為寫入成功,而完全可以忽略那個無法提供服務的副本。當失效的節點恢復時,會重新提供讀寫服務,此時如果客戶端向這個副本讀取資料,就會請求到過期值。

為了解決這個問題,這裡客戶端就不是簡單向一個節點請求資料了,而是向所有三個副本請求,這時可能會收到不同的響應,這時可以透過類似版本號來區分資料的新舊(類似上文中併發寫入的檢測方式)。這裡可能有一個問題,副本恢復之後難道就一直讓自己落後於其他副本嗎?這肯定不行,這會打破一致性的語義,因此需要一個機制。有兩種思路:

  1. 客戶端讀取時對副本做修復,如果客戶端透過並行讀取多個副本時,讀到了過期的資料,可以將資料寫入到舊副本中,以便追趕上新副本。
  2. 反熵查詢,一些系統在副本啟動後,後臺會不斷查詢副本之間的資料diff,將diff寫到自己的副本中,與主從複製模式不同的是,此過程不保證寫入的順序,並可能引發明顯的複製滯後。

讀寫Quorum

上文中的例項我們可以看出,這種複製模式下,要想保證讀到的是寫入的新值,每次只從一個副本讀取顯然是有問題的,那麼需要每次寫幾個副本呢,又需要讀取幾個副本呢?這裡的一個核心點就是讓寫入的副本和讀取的副本有交集,那麼我們就能夠保證讀到新值了。

直接上公式:$w+r>N$ 。其中N為副本的數量,w為每次並行寫入的節點數,r為每次同時讀取的節點數,這個公式非常容易理解,就不做過多贅述。不過這裡的公式雖然看著比較直白也簡單,裡面卻蘊含了一些系統設計思考:

image.png

Quorum一致性的侷限性

看上去這個簡單的公式就可以實現很強大的功能,但這裡有一些問題值得注意:

  • 首先,Quorum並不是一定要求多數,重要的是讀取的副本和寫入副本有重合即可,可以按照讀寫的可用性要求酌情考慮配置。
  • 另外,對於一些沒有很強一致性要求的系統,可以配置w+r <= N,這樣可以等待更少的節點即可返回,這樣雖然有可能讀取到一箇舊值,但這種配置可以很大提升系統的可用性,當網路大規模故障時更有機率讓系統繼續執行而不是由於沒有達到Quorum限制而返回錯誤。
  • 假設在w+r>N的情況下,實際上也存在邊界問題導致一些一致性問題:

    • 首先假設是Sloppy Quorum(一個更為寬鬆的Quorum演算法),寫入的w和讀取的r可能完全不相交,因此不能保證資料一定是新的。
    • 如果兩個寫操作同時發生,那麼還是存在衝突,在合併時,如果基於LWW,仍然可能導致資料丟失。
    • 如果寫讀同時發生,也不能保證讀請求一定就能取到新值,因為複製具有滯後性(上文的複製視窗)。
    • 如果某些副本寫入成功,其他副本寫入失敗(磁碟空間滿)且總的成功數少於w,那些成功的副本資料並不會回滾,這意味著及時寫入失敗,後續還是可能讀到新值。

雖然,看上去Quorum複製模式可以保證獲取到新值,但實際情況並不是我們想象的樣子,這個協議到最後可能也只能達到一個最終的一致性,並且依然需要共識演算法的加持。

2.4 本章小結

以上我們介紹了所有常見的複製模式,我們可以看到,每種模式都有一定的應用場景和優缺點,但是很明顯,光有複製模式遠遠達不到資料的一致性,因為分散式系統中擁有太多的不確定性,需要後面各種事務、共識演算法的幫忙才能去真正對抗那些“稀奇古怪”的問題。

到這裡,可能會有同學就會問,到底都是些什麼稀奇古怪的問題呢?相比單機系統又有那些獨特的問題呢?下面本文先來介紹分散式系統中的幾個最典型的挑戰(Trouble),讓一些同學小小地“絕望”一下,然後我們會下一篇文章中再揭曉答案。

3. 分散式系統的挑戰

這部分存在的意義主要想讓大家理解,為什麼一些看似簡單的問題到了分散式系統中就會變得異常複雜。順便說一聲,這一章都是一些“奇葩”現象,並沒有過於複雜的推理和證明,希望大家能夠較為輕鬆愉悅地看完這些內容。

3.1 部分失效

這是分散式系統中特有的一個名詞,這裡先看一個現實當中的例子。假設老闆想要處理一批檔案,如果讓一個人做,需要十天。但老闆覺得有點慢,於是他靈機一動,想到可以找十個人來搞定這件事,然後自己把工作安排好,認為這十個人一天正好乾完,於是向他的上級信誓旦旦地承諾一天搞定這件事。他把這十個人叫過來,把任務分配給了他們,他們彼此建了個微信群,約定每個小時在群裡彙報自己手上的工作進度,並強調在晚上5點前需要透過郵件提交最後的結果。於是老版就去愉快的喝茶去了,但是現實卻讓他大跌眼鏡。

首先,有個同學家裡訊號特別差,報告進度的時候只成功報告了3個小時的,然後老闆在微信裡問,也收不到任何回覆,最後結果也沒法提交。另一個同學家的表由於長期沒換電池,停在了下午四點,結果那人看了兩次表都是四點,所以一點都沒著急,中間還看了個電影,慢慢悠悠做完交上去了,他還以為老闆會表揚他,提前了一小時交,結果實際上已經是晚上八點了。還有一個同學因為前一天沒睡好,效率極低,而且也沒辦法再去高強度的工作了。結果到了晚上5點,只有7個人完成了自己手頭上的工作。

這個例子可能看起來並不是非常恰當,但基本可以描述分散式系統特有的問題了。在分散式的系統中,我們會遇到各種“稀奇古怪”的故障,例如家裡沒訊號(網路故障),不管怎麼叫都不理你,或者斷斷續續的理你。另外,因為每個人都是透過自己家的表看時間的,所謂的5點需要提交結果,在一定程度上舊失去了參考的絕對價值。因此,作為上面例子中的“老闆”,不能那麼自信的認為一個人幹工作需要10天,就可以放心交給10個人,讓他們一天搞定。

我們需要有各種措施來應對分派任務帶來的不確定性,回到分散式系統中,部分失效是分散式系統一定會出現的情況。作為系統本身的設計人員,我們所設計的系統需要能夠容忍這種問題,相對單機系統來說,這就帶來了特有的複雜性。

3.2 分散式系統特有的故障

不可靠的網路

對於一個純的分散式系統而言,它的架構大多為Share Nothing架構,即使是存算分離這種看似的Share Storage,它的底層儲存一樣是需要解決Share Nothing的。所謂Nothing,這裡更傾向於叫Nothing but Network,網路是不同節點間共享資訊的唯一途徑,資料的傳輸主要透過乙太網進行傳輸,這是一種非同步網路,也就是網路本身並不保證發出去的資料包一定能被接到或是何時被收到。這裡可能發生各種錯誤,如下圖所示:

圖9 不可靠的網路

  1. 請求丟失
  2. 請求正在某個佇列中等待
  3. 遠端節點已經失效
  4. 遠端節點無法響應
  5. 遠端節點已經處理完請求,但在ack的時候丟包
  6. 遠端接收節點已經處理完請求,但回覆處理很慢

本文認為,造成網路不可靠的原因不光是乙太網和IP包本身,其實應用本身有時候異常也是造成網路不可靠的一個誘因。因為,我們所採用的節點間傳輸協議大多是TCP,TCP是個端到端的協議,是需要傳送端和接收端兩端核心中明確維護資料結構來維持連線的,如果應用層發生了下面的問題,那麼網路包就會在核心的Socket Buffer中排隊得不到處理,或響應得不到處理。

  1. 應用程式GC。
  2. 處理節點在進行重的磁碟I/O,導致CPU無法從中斷中恢復從而無法處理網路請求。
  3. 由於記憶體換頁導致的顛簸。

這些問題和網路本身的不穩定性相疊加,使得外界認為的網路不靠譜的程度更加嚴重。因此這些不靠譜,會極大地加重上一章中的 複製滯後性,進而帶來各種各樣的一致性問題。

應對之道

網路異常相比其他單機上的錯誤而言,可能多了一種不確定的返回狀態,即延遲,而且延遲的時間完全無法預估。這會讓我們寫起程式來異常頭疼,對於上一章中的問題,我們可能無從知曉節點是否失效,因為你發的請求壓根可能不會有人響應你。因此,我們需要把上面的“不確定”變成一種確定的形式,那就是利用“超時”機制。這裡引申出兩個問題:

  1. 假設能夠檢測出失效,我們應該如何應對?

    a. 負載均衡需要避免往失效的節點上發資料(服務發現模組中的健康檢查功能)。
    b. 如果在主從複製中,如果主節點失效,需要出發選舉機制(Kafka中的臨時節點掉線,Controller監聽到變更觸發新的選舉,Controller本身的選舉機制)。
    c. 如果服務程式崩潰,但作業系統執行正常,可以透過指令碼通知其他節點,以便新的節點來接替(Kafka的殭屍節點檢測,會觸發強制的臨時節點掉線)。
    d. 如果路由器已經確認目標節點不可訪問,則會返回ICMP不可達(ping不通走下線)。

  2. 如何設定超時時間是合理的?

很遺憾地告訴大家,這裡面實際上是個權衡的問題,短的超時時間會更快地發現故障,但同時增加了誤判的風險。這裡假設網路正常,那麼如果端到端的ping時間為d,處理時間為r,那麼基本上請求會在2d+r的時間完成。但在現實中,我們無法假設非同步網路的具體延遲,實際情況可能會更復雜。因此這是一個十分靠經驗的工作。

3.2 不可靠的時鐘

說完了“訊號”的問題,下面就要說說每家的“鐘錶”——時鐘了,它主要用來做兩件事:

  1. 描述當前的絕對時間
  2. 描述某件事情的持續時間

在DDIA中,對於這兩類用途給出了兩種時間,一類成為牆上時鐘,它們會返回當前的日期和時間,例如clock_gettime(CLOCK_REALTIME)或者System.currentTimeMills,但這類反應精確時間的API,由於時鐘同步的問題,可能會出現回撥的情況。因此,作為持續時間的測量通常採用單調時鐘,例如clock_gettime(CLOCK_MONOTONIC) 或者System.nanoTime。高版本的Kafka中把請求的相應延遲計算全部換成了這個API實現,應該也是這個原因。

這裡時鐘同步的具體原理,以及如何會出現不準確的問題,這裡就不再詳細介紹了,感興趣的同學可以自行閱讀書籍。下面將介紹一下如何使用時間戳來描述事件順序的案例,並展示如何因時鐘問題導致事件順序判斷異常的:

圖10 不可靠的時鐘

這裡我們發現,Node1的時鐘比Node3快,當兩個節點在處理完本地請求準備寫Node2時發生了問題,原本ClientB的寫入明顯晚於ClientA的寫入,但最終的結果,卻由於Node1的時間戳更大而丟棄了本該保留的x+=1,這樣,如果我們使用LWW,一定會出現資料不符合預期的問題。

由於時鐘不準確,這裡就引入了統計學中的置信區間的概念,也就是這個時間到底在一個什麼樣的範圍裡,一般的API是無法返回類似這樣的資訊的。不過,Google的TrueTime API則恰恰能夠返回這種資訊,其呼叫結果是一個區間,有了這樣的API,確實就可以用來做一些對其有依賴的事情了,例如Google自家的Spanner,就是使用TrueTime實現快照隔離。

如何在這艱難的環境中設計系統

上面介紹的問題是不是挺“令人絕望”的?你可能發現,現在時間可能是錯的,測量可能是不準的,你的請求可能得不到任何響應,你可能不知道它是不是還活著......這種環境真的讓設計分散式系統變得異常艱難,就像是你在100個人組成的大部門裡面協調一些工作一樣,工作量異常的巨大且複雜。

但好在我們並不是什麼都做不了,以協調這件事為例,我們肯定不是武斷地聽取一個人的意見,讓我們回到學生時代。我們需要評選一位班長,肯定我們都經歷過投票、唱票的環節,最終得票最多的那個人當選,有時可能還需要設定一個前提,需要得票超過半數。

對映到分散式系統中也是如此,我們不能輕易地相信任何一臺節點的資訊,因為它有太多的不確定,因此更多的情況下,在分散式系統中如果我們需要就某個事情達成一致,也可以採取像競選或議會一樣,大家協商、投票、仲裁決定一項提議達成一致,真相由多數人商議決定,從而達到大家的一致和統一,這也就是後面要介紹的分散式共識協議。這個協議能夠容忍一些節點的部分失效,或者莫名其妙的故障帶來的問題,讓系統能夠正常地執行下去,確保請求到的資料是可信的。

下面給出一些實際分散式演算法的理論模型,根據對於延遲的假設不同,這裡介紹三種系統模型。

1. 同步模型

該模型主要假設網路延遲是有界的,我們可以清楚地知道這個延遲的上下界,不管出現任何情況,它都不會超出這個界限。

2. 半同步模型(大部分模型都是基於這個假設)

半同步模型認為大部分情況下,網路和延遲都是正常的,如果出現違背的情況,偏差可能會非常大。

3. 非同步模型

對延遲不作任何假設,沒有任何超時機制。

而對於節點失效的處理,也存在三種模型,這裡我們忽略惡意謊言的拜占庭模型,就剩下兩種。

1.崩潰-終止模型(Crash-Stop):該模型中假設一個節點只能以一種方式發生故障,即崩潰,可能它會在任意時刻停止響應,然後永遠無法恢復。

2.崩潰-恢復模型:節點可能在任何時刻發生崩潰,可能會在一段時間後恢復,並再次響應,在該模型中假設,在持久化儲存中的資料將得以儲存,而記憶體中的資料會丟失。

而多數的演算法都是基於半同步模型+崩潰-恢復模型來進行設計的。

Safety and Liveness

這兩個詞在分散式演算法設計時起著十分關鍵的作用,其中安全性(Safety)表示沒有意外發生,假設違反了安全性原則,我們一定能夠指出它發生的時間點,並且安全性一旦違反,無法撤銷。而活性(Liveness)則表示“預期的事情最終一定會發生”,可能我們無法明確具體的時間點,但我們期望它在未來某個時間能夠滿足要求。

在進行分散式演算法設計時,通常需要必須滿足安全性,而活性的滿足需要具備一定的前提。

7. 總結

以上就是第一篇文章的內容,簡單做下回顧,本文首先介紹了複製的三種常見模型,分別是主從複製、多主複製和無主複製,然後分別介紹了這三種模型的特點、適用場景以及優缺點。接下來,我們用了一個現實生活中的例子,向大家展示了分散式系統中常見的兩個特有問題,分別是節點的部分失效以及無法共享系統時鐘的問題,這兩個問題為我們設計分散式系統帶來了比較大的挑戰。如果沒有一些設計特定的措施,我們所設計的分散式系統將無法很好地滿足設計的初衷,使用者也無法透過分散式系統來完成自己想要的工作。

以上這些問題,我們會下篇文章《Replication(下):事務,一致性與共識》中逐一進行解決,而事務、一致性、共識這三個關鍵詞,會為我們在設計分散式系統時保駕護航。

8. 作者簡介

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

閱讀美團技術團隊更多技術文章合集

前端 | 演算法 | 後端 | 資料 | 安全 | 運維 | iOS | Android | 測試

| 在公眾號選單欄對話方塊回覆【2021年貨】、【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可檢視美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請傳送郵件至tech@meituan.com申請授權。

相關文章