聊聊【微服務架構】下【分佈式系統的問題】

關注Java後端技術棧

回覆“面試”獲取最新資料

回覆“加羣”邀您進技術交流羣

當前最流行的微服務架構,你知道微服務架構下分佈式系統會有哪些問題嗎?

前言

無論是 SOA 或者微服務架構,都是必須要面對和解決一些分佈式場景下的問題。如果確實用不到微服務,或者勇哥簡單的主備就解決了。那麼對於程序員來說那真是太幸福了,bug都會少很多。

可惜了,當下最流行的就是微服務架構,然而在分佈式系統中,如果想當然的去按照單服務思想編程和架構,那可能會收穫很多意想不到的“驚喜”:網絡延遲導致的重複提交、數據不一致、部分節點掛掉但是任務處理了一半等。在分佈式系統環境下編程和在單機器系統上寫軟件最大的差別就是,分佈式環境下會有很多很“詭異”的方式出錯,所以我們需要理解哪些是不能依靠的,以及如何處理分佈式系統的各種問題。

SOA架構和微服務架構的區別,首先SOA和微服務架構一個層面的東西,而對於ESB和微服務網關是一個層面的東西,一個談到是架構風格和方法,一個談的是實現工具或組件。

1.SOA(Service Oriented Architecture)“面向服務的架構”:他是一種設計方法,其中包含多個服務, 服務之間通過相互依賴最終提供一系列的功能。一個服務 通常以獨立的形式存在與操作系統進程中。各個服務之間 通過網絡調用。

2.微服務架構:其實和 SOA 架構類似,微服務是在 SOA 上做的昇華,微服務架構強調的一個重點是“業務需要徹底的組件化和服務化”,原有的單個業務系統會拆分爲多個可以獨立開發、設計、運行的小應用。這些小應用之間通過服務完成交互和集成。

微服務架構 = 80%的SOA服務架構思想 + 100%的組件化架構思想 + 80%的領域建模思想

理想和現實

微服務架構跟 SOA 一樣,也是服務化的思想。在微服務中,我們傾向於使用 RESTful 風格的接口進行通信,使用 Docker 來管理服務實例。我們的理想是希望分佈式系統能像在單個機器中運行一樣,就像客戶端應用,再壞的情況,用戶只要一鍵重啓就可以重新恢復,然而現實是我們必須面對分佈式環境下的由網絡延遲、節點崩潰等導致的各種突發情況。

在決定使用分佈式系統,或者微服務架構的時候,往往是因爲我們希望獲得更好的伸縮性、更好的性能、高可用性(容錯)。雖然分佈式系統環境更加複雜,但只要能瞭解分佈式系統的問題以及找到適合自己應用場景的方案,便能更接近理想的開發環境,同時也能獲得伸縮性、性能、可用性。

分佈式系統的可能問題

分佈式系統從結構上來看,是由多臺機器節點,以及保證節點間通信的網絡組成,所以需要關注節點、網絡的特徵。

(1)部分失敗

在分佈式環境下,有可能是節點掛了,或者是網絡斷了,如下圖:

如果系統中的某個節點掛掉了,但是其他節點可以正常提供服務,這種部分失敗,不像單臺機器或者本地服務那樣好處理。單機的多線程對象可以通過機器的資源進行協調和同步,以及決定如何進行錯誤恢復。但在分佈式環境下,沒有一個可以來進行協調同步、資源分配以及進行故障恢復的節點,部分失敗一般是無法預測的,有時甚至無法知道請求任務是否有被成功處理。

所以在開發需要進行網絡通訊的接口時(RPC 或者異步消息),需要考慮到部分失敗,讓整個系統接受部分失敗並做好容錯機制,比如在網絡傳輸失敗時要能在服務層處理好,並且給用戶一個好的反饋。

(2)網絡延遲

網絡是機器間通信的唯一路徑,但這條唯一路徑並不是可靠的,而且分佈式系統中一定會存在網絡延遲,網絡延遲會影響系統對於“超時時間”、“心跳機制”的判斷。如果是使用異步的系統模型,還會有各種環節可能出錯:可能請求沒有成功發出去、可能遠程節點收到請求但是處理請求的進程突然掛掉、可能請求已經處理了但是在 Response,可能在網絡上傳輸失敗(如數據包丟失)或者延遲,而且網絡延遲一般無法辨別。

即使是 TCP 能夠建立可靠的連接,不丟失數據並且按照順序傳輸,但是也處理不了網絡延遲。對於網絡延遲敏感的應用,使用 UDP 會更好,UDP 不保證可靠傳輸,也不會丟失重發,但是可以避免一些網絡延遲,適合處理音頻和視頻的應用。

(3)沒有共享內存、鎖、時鐘

分佈式系統的節點間沒有共享的內存,不應理所當然認爲本地對象和遠程對象是同一個對象。分佈式系統也不像單機器的情況,可以共享同一個 CPU 的信號量以及併發操作的控制;也沒有共享的物理時鐘,無法保證所有機器的時間是絕對一致的。時間的順序是很重要的,誇張一點說,假如對於一個人來說,從一個時鐘來看是 7 點起牀、8 點出門,但可能因爲不同時鐘的時間不一致,從不同節點上來看可能是 7 點出門、8 點起牀。

在分佈式環境下開發,需要我們能夠有意識地進行問題識別,以上只是舉例了一部分場景和問題,不同的接口實現,會在分佈式環境下有不同的性能、擴展性、可靠性的表現。下面會繼續上述的問題進行探討,如何實現一個更可靠的系統。

概念和處理模型

對於上述分佈式系統中的一些問題,可以針對不同的特徵做一些容錯和處理,下面主要看一下錯誤檢測以及時間和順序的處理模型。在實際處理中,一般是綜合多個方案以及應用的特點。

錯誤檢測

對於部分失敗,需要一分爲二的看待。

節點的部分失敗,可以通過增加錯誤檢測的機制,自動檢測問題節點。在實際的應用中,比如有通過 Load Balancer,自動排除問題節點,只將請求發送給有效的節點。對於需要有 Leader 選舉的服務集羣來說,可以引入實現 Leader 選舉的算法,如果 Leader 節點掛掉了,其餘節點能選舉出新的 Leader。實現選舉算法也屬於共識問題,在後續文章中會再涉及到幾種算法的實現和應用。

網絡問題:由於網絡的不確定性,比較難說一個節點是否真正的“在工作”(有可能是網絡延遲導致的錯誤),通過添加一些反饋機制可以在一定程度確定節點是否正常運行,比如:

  • 健康檢查機制,一般是通過心跳檢測來實現的,比如使用 Docker 的話,Consul、Eureka 都有健康檢查機制,當發送心跳請求發現容器實例已經無法迴應時,可以認爲服務掛掉了,但是卻很難確認在這個 Node/Container 中有多少數據被正確的處理了。

  • 如果一個節點的某個進程掛了,但是整個節點還可以正常運行。在使用微服務體系中是比較常見的,一臺機器上部署着很多容器實例,其中個容器實例(即相當於剛纔描述掛掉的進程)掛掉了,可以有一個方式去通知其他容器來快速接管,而不用等待執行超時。比如 Consul 通過 Gossip 協議進行多播,關於 Consul,可以參考這篇 Docker 容器部署 Consul 集羣 內容。在批處理系統中,HBase 也有故障轉移機制。

在實際做錯誤檢測處理時,除了需要 節點、容器 做出積極的反饋,還要有一定的重試機制。重試的實現可以基於網絡傳輸協議,如使用 TCP 的 RTT;也可以在應用層實現,如 Kafka 的 at-least-once 的實現。基於 Docker 體系的應用,可以使用 SpringCloud 的 Retry,結合 Hytrix、Ribbon 等。對於需要給用戶反饋的應用,不太建議使用過多重試,根據不同的場景進行判斷,更多的時候需要應用做出積極的響應即可,比如用戶的“個人中心頁面”,當 User 微服務掛了,可以給個默認頭像、默認暱稱,然後正確展示其他信息,而不是反覆請求 User 微服務。

時間和順序

在分佈式系統中,時間可以作爲所有執行操作的順序先後的判定標準,也可以作爲一些算法的邊界條件。在分佈式系統中決定操作的順序是很重要的,比如對於提供分佈式存儲服務的系統來說,Repeated Read 以及 Serializable 的隔離級別,需要確定事務的順序,以及一些事件的因果關係等。

物理時鐘

每個機器都有兩個不同的時鐘,一個是 time-of-day,即常用的關於當前的日期、時間的信息,例如,此時是 2018 年 6 月 23 日 23:08:00,在 Java 中可以用 System.currentTimeMillis() 獲取;另一個是 Monotonic 時鐘,代表着單調遞增的時間,一般是測量時間間距,在 Java 中調用 System.nanoTime() 可以獲得 Monotonic 時間,常常用於測量一個本地請求的返回時間,比如 Apache commons 中的 StopWatch 的實現。

在分佈式環境中,一般不會使用 Monotonic,測量兩臺不同的機器的 Monotonic 的時間差是無意義的。

不同機器的 time-of-day 一般也不同,就算使用 NTP 同步所有機器時間,也會存在毫秒級的差,NTP 本身也允許存在前後 0.05% 的誤差。如果需要同步所有機器的時間,還需要對所有機器時間值進行監控,如果一個機器的時間和其他的有很大差異,需要移除不一致的節點。因爲能改變機器時間的因素比較多,比如無法判斷是否有人登上某臺機器改變了其本地時間。

雖然全局時鐘很難實現,並且有一定的限制,但基於全局時鐘的假設還是有一些實踐上的應用。比如 Facebook Cassandra 使用 NTP 同步時間來實現 LWW(Last Write Win)。Cassandra 假設有一個全局時鐘,並基於這個時鐘的值,用最新的寫入覆蓋舊值。當然時鐘上的最新不代表順序的最新,LWW 區分不了實際順序;另外還有如 Google Spanner 使用 GPS 和原子時鐘進行時間同步,但節點之間還是會存在時間誤差。

邏輯時鐘

在分佈式系統中,因爲全局時鐘很難實現,並且像 NTP 同步過程,也會受到網絡傳輸時間的影響,一般不會使用剛纔所述的全局同步時間,當然也肯定不能使用各個機器的本地時間。對於需要確認操作執行順序的時候,不能簡單依賴一個基於 time-of-day 的 timestamps,所以需要一個邏輯時鐘,來標記一些事件順序、操作順序的序列號。常見的方式是給所有操作加上遞增的計數器。

這種所有操作都添加一個全局唯一的序列號的方式,提供了一種全局順序的保證,全局順序也包含了因果順序一致的概念。關於分佈式一致性的概念和實現會在後續文章詳細介紹,我們先將關注點回歸到時間和順序上。下面看兩種典型的邏輯時鐘實現。

(1)Lamport Timestamps

Lamport timestamps 是 Leslie Lamport 在 1978 年提出的一種邏輯時鐘的實現方法。Lamport Timestamps 的算法實現,可以理解爲基於每個節點的一對值(NodeId,Counter)的全局順序的同步。在集羣中的每個節點(Node)都有一個唯一標識,並且每個 Node 都持有一個本地的對於所有操作順序的一個 Counter(計數器)。

Lamport 實現的核心思想就是把事件分成三類(節點內處理的事件、發送事件、接收事件):

  • 如果一個節點處理一個事件,節點 counter +1。

  • 如果是發送一個消息事件,則在消息中帶上 counter 值。

  • 如果是接收一個消息事件,則更新 counter = max(本地 counter,接收的消息中帶的 counter) +1。

簡單畫了個示例如下圖:

初始的 counter 都是 0,在 Node1 接收到請求,處理事件時 counter+1(C:1表示),並且再發送消息帶上 C:1。

在 Node1 接受 ClientA 請求時,本地的 Counter=1 > ClientA 請求的值,所以處理事件時 max(1,0)+1=2(C:2),然後再發送消息,帶上 Counter 值,ClientA 更新請求的最大 Counter 值 =2,並在下一次對 Node2 的事件發送時會帶上這個值。

這種序列號的全局順序的遞增,需要每次 Client 請求持續跟蹤 Node 返回的 Counter,並且再下一次請求時帶上這個 Counter。lamport 維護了全局順序,但是卻不能更好的處理併發。在併發的情況下,因爲網絡延遲,可能導致先發生的事件被認爲是後發生的事件。如圖中紅色的兩個事件屬於併發事件,雖然 ClientB 的事件先發出,但是因爲延遲,所以在 Node 1 中會先處理 ClientA,也即在 Lamport 的算法中,認爲 Node1(C:4) happens before Node1(C:5)。

Lamport Timestamps 還有另一種併發衝突事件:不同的 NodeId,但 Counter 值相同,這種衝突會通過 Node 的編號的比較進行併發處理。比如 Node2(C:10)、Node1(C:10) 是兩個併發事件,則認爲 Node2 的時間順序值 > Node1 的序列值,也就認爲 Node1(C:10) happens before Node2(C:10)。

所以可見,Lamport 時間戳是一種邏輯的時間戳,其可以表示全局的執行順序,但是無法識別併發,以及因果順序,併發情況無法很好地處理 偏序

(2)Vector Clock

Vector Clock 又叫向量時鐘,跟 Lamport Timestamps 比較類似,也是使用 SequenceNo 實現邏輯時鐘,但是最主要的區別是向量時鐘有因果關係,可以區分兩個併發操作,是否一個操作依賴於另外一個。

Lamport Timestamps 通過不斷把本地的 counter 更新成公共的 MaxCounter 來維持事件的全局順序。Vector Clock 則各個節點維持自己本地的一個遞增的 Counter,並且會多記錄其他節點事件的 Counter。通過維護了一組 [NodeId,Counter] 值來記錄事件的因果順序,能更好得識別併發事件,也即,Vector Clock 的 [NodeId,Counter] 不僅記錄了本地的,還記錄了其他 Node 的 Counter 信息。

Vector Clock 的 [NodeId,Counter] 更新規則:

  • 如果一個節點處理一個事件,節點本地的邏輯時鐘的 counter +1。

  • 當節點發送一個消息,需要包含所有本地邏輯時鐘的一組 [NodeId,Counter] 記錄值。

  • 接受一個事件消息時, 更新本地邏輯時鐘的這組 [NodeId,Counter] 值:

    • 讓這組 [NodeId,Counter] 值中每個值都是 max(本地 counter,接收的消息中的counter)。

    • 本地邏輯時鐘 counter+1。

如下圖簡單示意了 Vector Clock 的時間順序記錄:

三個 Node,初始 counter 都是 0。NodeB 在處理 NodeC 的請求時,記錄了 NodeC 的 Counter=1,並且處理事件時,本地邏輯時鐘的 counter=0+1,所以 NodeB 處理事件時更新了本地邏輯時鐘爲 [B:1,C:1]。在事件處理時,通過不斷更新本地的這組 Counter,就可以根據一組 [NodeId,Counter] 值來確定請求的因果順序了,比如對於 NodeB,第二個處理事件 [A:1,B:2,C:1] 早於第三個事件:[A:1,B:3,C:1]。

在數值衝突的時候,如圖中紅色箭頭標記的。NodeC 的 [A:2,B:2,C:3] 和 NodeB[A:3,B:4,C:1]。C:3 > C:1、B:2 < B:4,種情況認爲是沒有因果關係,屬於同時發生。

Vector Clock 可以通過各個節點的時間序列值的一組值,識別兩個事件的先後順序。Vector 提供了發現數據衝突的方式,但是具體怎樣解決衝突需要由發現衝突的節點決定,比如可以將併發衝突拋給 Client 決定,或者用 Quorum-NRW 算法進行讀取修復(Read Repair)。

Amazon Dynamo 就是通過 Vector Clock 來做併發檢測的一個很好的分佈式存儲系統的例子。對於複製節點的數據衝突使用了 Quorum NRW 決議,以及讀修復(Read Repair)處理最新更新數據的丟失,詳細實現可以參考這篇論文 Dynamo: Amazon’s Highly Available Key-value Store ,Dynamo 是典型的高可用、可擴展的,提供弱一致性(最終一致性)保證的分佈式 K-V 數據存儲服務。後續文章再介紹 Quorums 算法時,也會再次提到。Vector Clock 在實際應用中,還會有一些問題需要處理,比如如果一個上千節點的集羣,那麼隨着時間的推移,每個 Node 將需要記錄大量 [NodeId,Counter] 數據。Dynamo 的解決方案是通過添加一個 timestamp 記錄每個 Node 更新 [NodeId,Counter] 值的時間,以及一個設定好的閾值,比如說閾值是 10,那麼本地只保存最新的 10 個 [NodeId,Counter] 組合數據。

小結

本文引出了一些分佈式系統的常見問題以及一些基礎的分佈式系統模型概念,微服務的架構目前已經被更廣泛得應用,但微服務面臨的問題其實也都是經典的分佈式場景的問題。

本文在分佈式系統的問題中,主要介紹了關於錯誤檢測以及時間和順序的處理模型。關於時間和順序的問題處理中,沒有一個絕對最優的方案,Cassandra 使用了全局時鐘以及 LWW 處理順序判定;Dynamo 使用了 Vector clock 發現衝突,加上 Quorum 算法處理事件併發。

OK,關於微服務中分佈式的問題就介紹到這裏。

推薦閱讀

必須掌握【分佈式鎖】三種實現方式

Spring Boot系列文章彙總,值得收藏!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章