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

就是本機上的操作會導致本機上的stamp加1,發(fā)生網(wǎng)絡通信時,比如C接收到B的數(shù)據(jù)時,會比較自己當前的stamp, 和B的stamp+1, 選出較大的值,變成自己當前的戳。 這樣一個簡單的操作,就可以保證任何有相關性的兩個操作(包括出現(xiàn)在同一節(jié)點、有通信兩種情況)的順序在不同節(jié)點之間看來是一致的。
另外,還有一些相對簡單些的事情,也是分布式系統(tǒng)設計中經(jīng)常要考慮的,比如怎么讓數(shù)據(jù)均勻的分布在各個節(jié)點上。對于這個問題,我們可能需要根據(jù)業(yè)務情況去找一個合適的分片key, 也可能需要找到一個合適的hash算法。另外,也有一致性哈希這種技術,讓我們控制起來更自如。
分布式系統(tǒng)設計中還需要重點考慮的一塊就是如何衡量系統(tǒng)性能,指標包括性能(延遲、吞吐量)、可用性、一致性、可擴展性等等,這些說起來都比較好理解,但要是想更完善的去衡量,尤其是想更方便的去觀測這些指標的話,也是一個很大的話題。































