分布式系統設計中的通用方法
之前翻譯過一篇關于分布式系統的文章 https:// lichuanyang.top/posts/3 914/ ,在各個平臺都取得了不錯的反響。因此,最近又重新整理了一下相關的知識,結合一些這一年多里新的理解,重新整理了下這篇文章。
首先我們需要明確本文要討論的分布式系統是什么,簡單的說,就是滿足多節點和有狀態這兩個條件即可。多節點很好理解,有狀態則是指這個系統要維護一些數據,不然的話,其實我們無腦的水平擴容就沒有任何問題,也就不存在分布式系統的問題了。
常見的分布式系統, 無論是mysql, cassandra, hbase這些數據庫,還是rocketmq, kafka, pulsar這樣的消息隊列,還是zookeeper之類的基礎設施,其實都滿足這兩個條件。
這些分布式系統的實現通常來說主要需要關注兩個方面:一是自己本身功能的實現,二是在分布式環境下保持良好的性能與穩定性;即便是兩個功能完全不一樣的系統,其對第二類問題的處理方式也會有很多相似之處。本文的關注重點也即在對第二類問題的處理上。
接下來,我們列舉一下分布式系統都有哪些常見目標,包括而不限于:
- 大量普通的服務器通過網絡互聯,對外作為整體提供服務;
- 隨著集群規模增長,系統整體性能表現為線性增長;
- 能夠自動容錯,故障節點自動遷移,不同節點的數據要能保持一致性;
要達成這些目標,又有哪些挑戰呢?大概有以下這些:
- 進程崩潰: 原因很多,包括硬件故障、軟件故障、正常的例行維護等等,在云環境下會有一些更加復雜的原因;進程崩潰導致的最大問題就是會丟數。出于性能的考慮,很多情況下我們不會進行同步的寫磁盤,而是會將數據暫時放在內存的緩沖區,再定期刷入磁盤。而在進程崩潰的時候,內存緩沖區中的數據顯然會丟失。
- 網絡延遲和中斷: 節點的通信變到很慢時,一個節點如何確認另一個節點是否正常;
- 網絡分區: 集群中節點分裂成兩個子集,子集內通信正常,子集之間斷開(腦裂),這時候集群要如何提供服務。
這里插一個彩蛋,在CAP理論的前提下,現實中的系統通常只有兩種模式:放棄高可用的CP模式和放棄強一致性的AP模式。為什么沒有一種放棄分區容忍性的CA模式?就是因為我們無法假設網絡通信一定正常,而一旦接受了集群變成兩個分區,再想合并回來就不現實了。
- 進程暫停:比如full gc之類的原因導致進程出現短暫的不可用后又迅速恢復,不可用期間集群有可能已經做出了相關的反應,當這個節點再恢復的時候如何維持狀態的一致性。
- 時鐘不同步和消息亂序:集群內不同節點的操作,我們希望它的順序是明確的;不同節點之間的時鐘不同步,會導致我們無法利用時間戳確保這件事。而消息的亂序就給分布式系統的處理帶來了更大的難度。
下面,我們就依次介紹,針對這些問題,都有什么處理方式。
對于進程崩潰的問題,首先要明確的是,單純實現進程崩潰下不丟數,沒有任何難度,重要的是怎么在保證系統性能的前提下達到這個目標。
首先要介紹的就是write-ahead log這種模式,服務器將每個狀態更改作為命令存儲在硬盤上的僅附加(append-only)文件中。 append操作由于是順序的磁盤寫,通常是非常快的,因此可以在不影響性能的情況下完成。 在服務器故障恢復時,可以重播日志以再次建立內存狀態。
其關鍵思路是先以一個小成本的方式寫入一份持久化數據,不一定局限于順序寫磁盤,此時就可以向client端確認數據已經寫入,不用阻塞client端的其他行為。server端再異步的去進行接下來高消耗的操作。
典型場景及變體:mysql redo log; redis aof; kafka本身 ;業務開發中的常見行為:對于耗時較高的行為,先寫一條數據庫記錄,表示這個任務將被執行,之后再異步進行實際的任務執行;
write-ahead log會附帶一個小問題,日志會越攢越多,要如何處理其自身的存儲問題呢?有兩個很自然的思路: 拆分和清理。
拆分即將大日志分割成多個小日志,由于WAL的邏輯一般都很簡單,所以其拆分也不復雜,比一般的分庫分表要容易很多。這種模式叫做 Segmented Log, 典型的實現場景就是kafka的分區。
關于清理,有一種模式叫做low-water mark(低水位模式), 低水位,即對于日志中已經可以被清理的部分的標記。標記的方式可以基于其數據情況(redolog), 也可以基于預設的保存時間(kafka),也可以做一些更精細的清理和壓縮(aof)。
再來看網絡環境下的問題,首先使用一個非常簡單的心跳(HeartBeat)模式,就可以解決節點間狀態同步的問題。一段時間內沒有收到心跳,就將這個節點視為已宕機處理。
而關于腦裂的問題,通常會使用大多數(Quorum)這種模式,即要求集群內存活的節點數要能達到一個Quorum值,(通常集群內有2f+1個節點時,最多只能容忍f個節點下線,即quorum值為f+1),才可以對外提供服務。我們看很多分布式系統的實現時,比如rocketmq, zookeeper, 都會發現需要滿足至少存活多少個節點才能正常工作,正是Quorum模式的要求。
Quorum解決了數據持久性的問題,也就是說,成功寫入的數據,在節點失敗的情況下,是不會丟失的。但是單靠這個,無法提供強一致性的保證,因為不同節點上的數據是會存在時間差的,client連接到不同節點上時,會產生不同的結果??梢酝ㄟ^主從模式(Leader and Followers) 解決一致性的問題。其中一個節點被選舉為主節點,負責協調節點間數據的復制,以及決定哪些數據對client是可見的。
高水位(High-Water Mark)模式是用來決定哪些數據對client可見的模式。一般來說,在quorum個從節點上完成數據寫入后,這條數據就可以標記為對client可見。完成復制的這條線,就是高水位。
主從模式的應用范圍實在太廣,這里就不做舉例了。分布式選舉算法很多,比如bully, ZAB, paxos, raft等。其中,paxos無論是理解還是實現難度都太大,bully在節點頻繁上下線時會頻繁的進行選舉,而raft可以說是一種穩定性、實現難度等各方面相對均衡,使用也最廣泛的一種分布式選舉算法。像elastic search, 在7.0版本里,將選主算法由bully更換為raft;kafka 2.8里,也由利用zk的ZAB協議,修改為raft.
到這兒,我們先總結一下。實際上,一個對分布式系統的操作,基本上就可以概括為下邊這么幾步:
- 寫主節點的Write-Ahead Log;
- 寫1個從節點的 WAL
- 寫主節點數據;
- 寫1個從節點數據
- 寫quorum個子節點WAL
- 寫quorum個子節點數據
其中,2-5步之間的順序不是固定的。分布式系統平衡性能和穩定性的最重要方式,實質上就是決定這幾步操作的順序,以及決定在哪個時間點向client端返回操作成功的確認信息。例如,mysql的同步復制、異步復制、半同步復制,就是典型的這種區別的場景。
關于進程暫停,造成的主要的問題場景是這樣的:假如主節點暫停了,暫停期間如果選出了新的主節點,然后原來的主節點恢復了,這時候該怎么辦。這時候,使用Generation Clock這種模式就可以,簡單的說,就是給主節點設置一個單調遞增的代編號,表示是第幾代主節點。像raft里的term, ZAB里的epoch這些概念,都是generation clock這個思路的實現。
再看看時鐘不同步問題,在分布式環境下,不同節點的時鐘之間必然是會存在區別的。在主從模式下,這種問題其實已經被最大限度的減少了。很多系統會選擇將所有操作都在主節點上進行,主從復制也是采取復制日志再重放日志的形式。這樣,一般情況下,就不用考慮時鐘的事情了。唯一可能出問題的時機就是主從切換的過程中,原主節點和新主節點給出的數就有可能存在亂序。
一種解決時鐘不同步問題的方案就是搞一個專門的服務用來做同步,這種服務叫做NTP服務。但這種方案也不是完美的,畢竟涉及到網絡操作,所以難免產生一些誤差。所以想依靠NTP解決時鐘不同步問題時,系統設計上需要能夠容忍一些非常微弱的誤差。
其實,除了強行去把時鐘對齊之外,還有一些簡單一些的思路可以考慮。首先思考一個問題,我們真的需要保證消息絕對的按照真實世界物理時間去排列嗎?其實不是的,我們需要的只是 一個自洽、可重復的確定消息順序的方式,讓各個節點對于消息的順序能夠達成一致即可。也就是說,消息不一定按照物理上的先后排列,但是不同節點排出來的應該一樣。
有一種叫Lamport Clock的技術就能達到這個目標。它的邏輯很簡單,如圖所示:
就是本機上的操作會導致本機上的stamp加1,發生網絡通信時,比如C接收到B的數據時,會比較自己當前的stamp, 和B的stamp+1, 選出較大的值,變成自己當前的戳。 這樣一個簡單的操作,就可以保證任何有相關性的兩個操作(包括出現在同一節點、有通信兩種情況)的順序在不同節點之間看來是一致的。
另外,還有一些相對簡單些的事情,也是分布式系統設計中經常要考慮的,比如怎么讓數據均勻的分布在各個節點上。對于這個問題,我們可能需要根據業務情況去找一個合適的分片key, 也可能需要找到一個合適的hash算法。另外,也有一致性哈希這種技術,讓我們控制起來更自如。
分布式系統設計中還需要重點考慮的一塊就是如何衡量系統性能,指標包括性能(延遲、吞吐量)、可用性、一致性、可擴展性等等,這些說起來都比較好理解,但要是想更完善的去衡量,尤其是想更方便的去觀測這些指標的話,也是一個很大的話題。