如何平衡存儲系統的一致性和可用性?
在分布式存儲系統中,讓系統中多個實例的狀態保持一致,是一個比較難處理的問題。尤其是當系統出現故障時,系統能否始終保持一致性,很大程度上影響了系統的可用性和數據的可靠性。
典型的由不一致導致的重大事故是這樣的:正常情況下,系統通過某種數據同步機制保持各實例上狀態的一致性,當發生實例宕機、網絡分區等故障時,這種同步機制無法正常工作,一致性被打破。
這種情況下,出現了多份不一致的狀態數據,系統很難自動去判斷到底哪份狀態數據才是“正確的”,也就沒有辦法自動恢復。更糟糕的是,一旦這種不一致的狀態被其它系統讀取,錯誤的狀態將被傳遞到其它系統中,造成不可預期的結果。
這種復雜的數據錯誤,即使人工處理也是非常難恢復,往往恢復時間需要幾小時或幾天,嚴重情況下甚至于無法恢復。
可以看出,在故障情況下仍然保持一致性,是系統能快速從故障中恢復的前提條件,有助于提升系統的可用性。但為了保證一致性,在數據更新時,往往需要協調參與的各個模塊,確保它們同步更新。比如,使用各種分布式事務。
但這會導致這些模塊在可用性上緊密耦合在一起,反而降低了系統的可用性。這種場景下,可用性和一致性又存在矛盾。
本文從高可用視角來重新審視數據一致性問題,討論如何在可用性和一致性上取得相對的平衡。
01 如無必要,勿增副本
在考慮如何平衡一致性與可用性之前,最重要的是要意識到,在分布式系統中解決一致性問題需要付出非常大的代價,這些代價可能包括:可用性降低、性能下降、用戶體驗變差或者是極大的增加了系統的復雜度。
因此,不要人為制造一致性難題。但是,很多情況下,因為缺少這方面的意識,我們無意間為系統制造了本無必要的一致性難題,然后又付出了巨大的代價去解決這個難題,得不償失。
為系統中的狀態數據設計多個副本的情況并不罕見,常見的多副本設計包括:
- 以不同格式或數據結構存儲多個副本。
- 在不同類型的外部存儲中存儲多個副本。
- 在本地磁盤或內存中緩存數據的副本。
以上這些都是我們時常會用到的設計模式,難道說它們都是“不好的設計”么?
當然不是這樣的。
架構設計是平衡的藝術,當架構師選擇某種設計或架構時,一定要充分了解當前選擇的優勢和代價,確保優點是我們所需要的,代價是我們能接受的。這樣的設計才是在當前場景下最優的選擇。
為數據增加副本會帶來一致性難題,開發者需要為此付出巨大的代價去維護數據一致性。所以,在設計過程中需要慎重考慮,為系統增加副本所帶來的收益和付出的代價,二者相比是不是值得做出這樣的選擇。
我們需要避免的是,在設計過程中未經仔細思考隨意增加副本的行為。
以下是幾個常見的錯誤示例:
- 僅僅是為了寫代碼的時候更方便地讀取數據,就隨意增加副本。比如,為了便于查詢,將數據庫中A表中的部分字段,在B表中也保存一份。
- 系統中存在多個外部存儲,為了讀寫方便,在每個外部存儲都保存一份數據副本。比如,集群的元數據保存在ZooKeeper中,為了方便管理控制臺操作,也在MySQL中保存一份同樣的數據。
- 不考慮系統的性能實際要求,為了讓系統速度更快一些,在Redis和內存中緩存數據。
02 一致性與可用性的矛盾
在現有硬件技術條件下,對分布式系統中每個節點更新操作,總會有先后,不可能做到絕對的“同時”,也就無法保證系統的多個副本在“任何時刻”狀態都相同。
因此,這里我們討論的一致性是,系統作為一個整體對外部所表現出的一致性。換句話說就是,分布式系統內部可以存在不一致的狀態,但只要這種不一致的狀態對外部是不可見的,那就可以認為這個系統具備一致性。
在分布式系統中,既要保證高可用又要保證一致性是幾乎不可能實現的。我們把分布式系統抽象成最簡單的模型:一個只有兩個有狀態節點系統。然后在這個最簡模型下來分析一致性問題:如何保證這兩個節點上的狀態,在任何時刻都是相同的?
即使在這樣一個最簡模型下,保持一致性仍然面臨下面的3個難題。
第一個難題是,如何處理更新操作失敗的情況。
要保持兩個節點上狀態的一致性,理論上需要每次更新狀態時同步更新兩個節點上的狀態。如果某一個節點上的更新操作失敗了,系統將變成如下不一致的狀態:一個節點更新成功,而另外一個節點更新失敗。
在這種情況下,還要保持系統的一致性,就需要將這種不一致狀態隔離在系統內部,不能讓外部系統感知,并且盡快修復不一致的狀態。
要修復這種不一致狀態,一般有兩種方法,分別是重試和回滾。
- 重試指的是,讓失敗的節點重新執行更新操作。如果重試成功,系統將重新回到一致的狀態。
- 回滾指的是,讓之前更新成功的節點執行回滾操作,回到更新前的狀態,也可以讓系統重新回到一致狀態。
但重試和回滾的實現代價都很大。
通過重試來解決一致性的前提是,被重試的更新操作必須具備冪等性和原子性。
- 冪等性,可以保證多次重試同一個更新操作不會改變狀態的正確性;
- 原子性,則可以避免在更新具有復雜數據結構的狀態失敗時,只更新了部分狀態的尷尬局面。
如果系統的狀態不是保存在關系型數據庫中,要實現冪等性和原子性其實很不容易。
實現回滾同樣要保證原子性,此外為了能將狀態恢復到更新之前,需要在執行更新操作之前記錄原始狀態,系統還要考慮如何處理回滾失敗的問題。
第二個難題是,如何在其中一個節點不可用的情況下保證系統一致性。
當系統其中的一個節點不可用時,另外一個節點仍然可以提供讀寫服務。當故障節點恢復后,理論上只要把狀態數據從可用節點同步到之前故障的節點上,系統就可以重新回到一致性狀態了。而在現實中實現好數據同步,既要做到快速同步,又要保證不重不漏,難度和代價都比較大。
最簡單的方法是全量數據同步,清空故障節點上的狀態數據,然后將可用節點上的狀態數據全部復制到故障節點上。全量同步相對比較耗時,如果數據量比較大,就必須采用增量同步的方法。
而增量同步,則需要精準地界定出哪些數據屬于“增量數據”。這對于大多數采用多線程并行處理請求的服務來說,幾乎不可能實現。同時,另一個不得不考慮的極端情況是,如果在一段時間內兩個節點交替多次出現不可用的情況,系統將很難判定哪個節點上的狀態才是“正確可信的狀態”,也就無法恢復系統的一致性狀態。
第三個難題是,如何在網絡分區情況下保證系統的一致性。
網絡分區,指的是由于網絡設備故障,造成網絡分裂為多個獨立的區域。典型場景是兩個機房間的網絡中斷,這兩個機房就形成了兩個互不聯通的分區。
假設發生了網絡分區,系統的兩個節點恰巧分別位于不同分區,這種情況下,雖然沒有節點不可用,但節點間無法通信,也就無法保證系統一致性。如果系統不能容忍“不一致”,唯一的辦法就是在網絡分區期間停止對外提供服務,也就是說需要犧牲“可用性”。
上面我們討論的情況,就是著名的CAP理論的一種典型場景:在網絡分區的情況下,一致性和可用性只能二選其一。
鑒于一致性與可用性存在沖突,以及實現一致性的代價過高這兩個原因,在設計分布式系統時,放棄對嚴格一致性的約束,讓系統去適應相對寬松一致性,從而在一致性、可用性和性能上取得相對可接受的平衡,是更加理性的選擇。
所謂“寬松一致”,是在隔離性和性能等方面適當放寬要求后的一系列降級版一致性。相對的,我們之前討論的一致性,也被稱為“強一致”。最終一致是普遍采用的一種寬松一致。
比如上面的例子,在網絡分區的情況下,如果可以接受最終一致,則系統仍然可以在其中的一個分區提供讀寫服務,另一個分區提供只讀服務,極大增強系統的可用性。只要待網絡故障結束后,再通過單向數據同步即可恢復系統一致性。
03 在一致性與可用性之間保持平衡
犧牲強一致后,當系統故障時,由于系統存在多個副本,就比較容易繼續維持可用性。無論是發生網絡故障還是服務器宕機,只要調用端還能訪問某個存活的副本,系統仍然可以提供服務。
BASE給出了一種平衡一致性和可用性的策略,這種策略適用范圍廣泛,實現難度不大,在一致性和可用性上都有不錯的表現。BASE是“基本可用(Basically Available)”“軟狀態(Soft State)”和“最終一致(Eventually Consistent)”這三個詞的縮寫。
其中:
- 基本可用是對可用性的妥協,指的是在故障時,系統以響應時間變長、部分功能不可用或者部分請求失敗為代價,換取整個系統仍然可以提供基本的服務能力。
- 軟狀態和最終一致則是對一致性的妥協。具體地說,就是犧牲了原子性和隔離性,允許系統內出現外部可見的“中間狀態”,但需要在短時間內恢復為一致狀態,達成最終一致。
在多個組件構成的分布式系統中,如果某個組件在設計上降低了可用性和一致性的等級,依賴這個組件的其它組件或外部服務為了能夠兼容這種降級設計,往往需要付出額外的代價。因此,設計者需要針對系統的實際情況來權衡決策,謹慎降級可用性和一致性。基本可用不等于不可用,最終一致也不等于不一致。
接下來介紹實踐BASE理論的常用方法和常見誤區。
“最終一致”允許不一致的中間狀態被外部可見,但需要在短時間內恢復為一致狀態。這里面的“短時間”能否量化呢?
要回答這個問題,我們需要分系統正常和故障二種情況來分別討論。
在系統正常時,達成最終一致的時間要求是“在系統外部幾乎不可感知”,具體來說應該與需要同步狀態的節點之間的網絡時延差不多。比如,如果系統的節點都部署在同一個數據中心內,達成最終一致的時延不應超過幾個毫秒;對于一個全球部署的系統,達成最終一致的時延可能需要幾十至幾百毫秒。
在系統發生網絡分區故障時,為了盡可能保證系統的可用性,需要進一步犧牲達成最終一致的時延,最長可能需要等到故障恢復后系統才能達成最終一致。
▲圖1 系統故障時需要更長的時間達成最終一致
犧牲一致性需要守住兩個底線:防止腦裂和要保證單調讀寫。
我們首先來討論底線一:防止腦裂。
例如,傳統MySQL主從結構中,如果主庫宕機,或者網絡分區導致無法訪問主庫,也不應該去更新從庫中的數據,否則在故障結束后,系統面對主庫和從庫二份不一樣的數據,是無法自動恢復的。這種情況被稱為“腦裂(Split-brain)”,出現腦裂后,理論上系統的一致性不可恢復。
工程實踐中,一般都需要人工介入,借助數據的業務屬性(比如,同一訂單支付操作一定早于發貨操作,則可以判斷“已發貨”狀態是比“已支付”更新的狀態),才有可能完成數據的一致性修復。
特別注意的是,不應該以狀態更新的時間戳來判斷狀態數據的新舊并用于恢復一致性。狀態數據中記錄的時間戳來自客戶端或服務端應用所在的多個節點,而現有的時間同步技術所能保證的誤差(10~500ms)過大,所以用時間戳來判斷狀態新舊極其不可靠。人工恢復腦裂的代價往往是“部分數據丟失”和“更長的故障恢復時長”。
那么,如何防止腦裂呢?
在我看來,關鍵是確保故障后能夠恢復最終一致。其前提則是,系統需要具備足夠的信息,以判斷出最新的狀態。然后才能將所有副本的狀態都恢復至這一狀態。在系統故障時,即使為了保證可用性,也不應該違反更新操作的一致性約束。
這里,“更新操作的一致性約束”指的是,系統為了保證一致性,而對狀態更新操作施加的約束條件。比如,最簡單的主從模式下,只能通過主副本更新狀態,無論任何原因無法更新主副本,那就要讓本次更新失敗,犧牲更新操作的可用性。
Paxos等一致性協議,采用了多數派(Quorum)機制保證更新操作的一致性。簡單地說,就是每次更新操作必須在超過半數的副本上達成一致才算更新成功,如果在系統故障時,更新請求不能達成多數派一致,也必須讓本次更新失敗。
接下來,我們討論單調讀寫。
最終一致系統在故障時,為了保證系統持續可用,應允許客戶端從任意一個尚可訪問的節點上讀取狀態數據。盡管這個時候,客戶端讀到的可能并非最新狀態。對于絕大多數系統來說,短時間內讀到一個并非最新狀態都是可接受的。
先來看第一個例子。小明用手機銀行給小華轉了100元,當小明完成了轉賬操作后,實際上這筆錢已經轉入到小華的賬戶。如果這個時候因為系統故障,小華的手機銀行上顯示尚未到賬,然后過了一段時間之后才顯示到賬,也并非是完全不可接受。
然后我們再來看第二個例子,同樣還是以小明給小華轉賬來說明。如圖二所示,在一個只有主從二副本的最終一致性系統中,轉賬成功后主副本的狀態已更新,小明轉給小華的錢已到賬,小華的賬戶余額是100元。但由于同步延遲,從副本中轉賬還未到賬,小華的賬戶余額還是0元。
假設小華第一次查詢賬戶的請求被分配到主副本上,App顯示余額100元。小華再次查詢,這次查詢請求被分配到了從副本上,App顯示余額0元!剛到賬的錢沒了!
對小明來說也可能出現類似的問題,轉賬成功后再查詢賬戶,如果這個查詢請求被分配到了從副本上(這在配置了讀寫分離的數據庫集群上是默認的行為),發現賬戶余額并沒有減少,小明以為轉賬沒成功,再次發起了轉賬,結果多轉了100元。
以上這兩種情況,對外部系統來說無法判斷讀到的狀態是否準確,顯然是不可接受的。
▲圖2 狀態時序錯亂問題
要避免這兩個問題,就需要保證在客戶端視角的一致性。所謂單調讀寫,要求對每一個客戶端來說,每次讀到的狀態不能比上次一讀寫到的狀態更舊。簡單的說就是“不能時序錯亂”。實現單調讀寫有兩種常用的方法。
第一種方法是通過保持會話(Sticky Session)的方式,讓同一個客戶端的請求總是由與之建立會話的那個特定的服務端節點(副本)處理。客戶端只與服務端一個節點交互,自然就不會出現“時序錯亂”的問題。
保持會話的方式實現比較簡單,很多網關都內置了保持會話的功能。如果系統是通過網關對外提供服務,則可以直接使用。即使系統沒有使用網關,只要在客戶端首次連接成功時,返回服務端節點的唯一標識(ID)或URL給客戶端,后續客戶端就可以用這個ID或URL繼續訪問同一個服務端節點了。
但保持會話這種實現方式的問題是,在系統故障時需要降級。如果客戶端連不上會話中的那個服務端節點,只能選擇去連接其它服務端節點創建新的會話。這個會話切換的過程中,仍然存在時序錯亂的可能性。
幸運的是時序錯亂只可能發生在會話切換過程中,而會話切換只在系統故障時才發生,發生概率很低。而且,客戶端是可以感知到會話切換,從而主動從業務邏輯上做一些補償。此外,因為需要維持會話,無法使用負載均衡策略,系統的彈性(Elasticity)將受到很大的限制,容易出現熱點問題,并且擴縮容也會受到會話的限制。
另一種方法是,通過記錄和比較狀態的版本號來實現單調讀寫。
系統需要為狀態數據維護一個版本號系統,狀態版本號是狀態的一部分,并且要確保每次狀態更新,對應版本號都單調遞增。這個狀態版本號的目的是,標記狀態更新的先后順序,在英文中也稱為Ephoc或者Logical timestamps。
客戶端需要記錄上一次讀寫狀態的版本號,然后在每一次讀取狀態之前比對本次版本號和上次版本號,如果本次版本號不小于上次版本號,就可以認為本次讀取的狀態是可信的。否則,需要丟棄本次讀取結果,等待一會兒或者連接其它服務端重試,以獲取新版本的狀態數據。通過狀態版本號的方式實現單調讀寫,可以完美地保證客戶端視角的一致性,但服務端的實現則更加復雜。
04 小結
我們來回顧下核心內容。
在分布式系統中,平衡可用性和一致性是一個難題,因此在設計過程中,需要避免未經仔細思考而隨意增加副本的行為。
我們推薦設計者在設計系統一致性時能夠兼容最終一致,這樣可以極大提升系統在面臨故障時保持高可用的難度,在一致性和可用性上取得相對較好的平衡。但系統最終一致也不等于不一致,需要防止系統出現腦裂,并通過單調讀寫保證客戶端視角的一致性。
關于作者:李玥,美團基礎技術部高級技術專家,極客時間《后端存儲實戰課》《消息隊列高手課》等專欄作者。曾在浪潮集團、當當網、京東零售等公司任職。從事互聯網電商行業基礎架構領域的架構設計和研發工作多年,曾多次參與雙十一和618電商大促。專注于分布式存儲、云原生架構下的服務治理、分布式消息和實時計算等技術領域,致力于推進基礎架構技術的創新與開源。