微服務不可或缺的特性:Go微服務容錯與韌性(Service Resilience)

2019-10-03     Go語言中文網

Service Resilience是指當服務的的運行環境出現了問題,例如網絡故障或服務過載或某些微服務宕機的情況下,程序仍能夠提供部分或大部分服務,這時我們就說服務的韌性很強。它是微服務中很重要的一部分內容,並被廣泛討論。它是衡量服務質量的一個重要指標。Service Resilience從內容上講翻譯成「容錯」可能更接近, 但「容錯」英文是「Fault Tolerance」,它的含義與「Service Resilience」是不同的。因此我覺得翻譯成「服務韌性「比較合適。服務韌性在微服務體系中非常重要,它通過提高服務的韌性來彌補環境上的不足。

服務韌性通過下面幾種技術來提升服務的可靠性:

  • 服務超時 (Timeout)
  • 服務重試 (Retry)
  • 服務限流(Rate Limiting)
  • 熔斷器 (Circuit Breaker)
  • 故障注入(Fault Injection)
  • 艙壁隔離技術(Bulkhead)

程序實現:

服務韌性能通過不同的方式來實現,我們先用代碼的來實現。程序並不複雜,但問題是服務韌性的代碼會和業務代碼混在一起,這帶來了以下問題:

  • 誤改業務邏輯:當你修改服務韌性的代碼時有可能會失手誤改業務邏輯。
  • 系統架構不夠靈活:將來如果要改成別的架構會很困難,例如將來要改成由基礎設施來完成這部分功能的話,需要把服務韌性的代碼摘出來,這會非常麻煩。
  • 程序可讀性差:因為業務邏輯和非功能性需求混在一起,很難看懂這段程序到底需要完成什麼功能。有些人可能覺得這不很重要,但對我來說這個是一個致命的問題。
  • 加重測試負擔:不管你是要修改業務邏輯還是非功能性需求,你都要進行系統的回歸測試, 這大大加重了測試負擔。

多數情況下我們要面對的問題是現在已經有了實現業務邏輯的函數,但要把上面提到的功能加入到這個業務函數中,又不想修改業務函數本身的代碼。我們採用的方法叫修飾模式(Decorator Pattern),在Go中一般叫他中間件模式(Middleware Pattern)。修飾模式(Decorator Pattern)的關鍵是定義一系列修飾函數,每個函數都完成一個不同的功能,但他們的返回類型(是一個Interface)都相同,因此我們可以把這些函數一個個疊加上去,來完成全部功能。下面看一下具體實現。

我們用一個簡單的gRPC微服務來展示服務韌性的功能。下圖是程序結構,它分為客戶端(client)和服務端(server),它們的內部結構是類似的。「middleware」包是實現服務韌性功能的包。 「service」包是業務邏輯,在服務端就是微服務的實現函數,客戶端就是調用服務端的函數。在客戶端(client)下的「middleware」包中包含四個文件並實現了三個功能:服務超時,服務重試和熔斷器。「clientMiddleware.go"是總入口。在服務端(server)下的「middleware」包中包含兩個文件並實現了一個功能,服務限流。「serverMiddleware.go"是總入口。

修飾模式:

修飾模式有不同的實現方式,本程序中的方式是從Go kit中學來的,它是我看到的是一種最靈活的實現方式。

下面是「service」包中的「cacheClient.go", 它是用來調用服務端函的。「CacheClient」是一個空結構,是為了實現「CallGet()」函數,也就實現了「callGetter」接口(下面會講到)。所有的業務邏輯都在這裡,它是修飾模式要完成的主要功能,其餘的功能都是對它的修飾。

下面是客戶端的入口文件「clientMiddleware.go". 它定義了」callGetter「接口,這個是修飾模式的核心,每一個修飾(功能)都要實現這個接口。接口裡只有一個函數「CallGet」,就是這個函數會被每個修飾功能不斷調用。 這個函數的簽名是按照業務函數來定義的。它還定義了一個結構(struct)CallGetMiddleware,裡面只有一個成員「Next」, 它的類型是修飾模式接口(callGetter),這樣我們就可以通過「Next」來調用下一個修飾功能。每一個修飾功能結構都會有一個相應的修飾結構,我們需要把他們串起來,才能完成依次疊加調用。

「BuildGetMiddleware()」就是用來實現這個功能的。CircuitBreakerCallGet,RetryCallGet和TimeoutCallGet分別是熔斷器,服務重試和服務超時的實現結構。它們每個裡面也都只有一個成員「Next」。在創建時,要把它們一個個依次帶入,要注意順序,最先創建的 「CircuitBreakerCallGet」 最後執行。在每一個修飾功能的最後都要調用「Next.CallGet()」,這樣就把控制傳給了下一個修飾功能。

服務重試:

當網絡不穩定時,服務有可能暫時失靈,這種情況一般持續時間很短,只要重試一下就能解決問題。下面是程序。它的邏輯比較簡單,就是如果有錯的話就不斷地調用「tcg.Next.CallGet(ctx, key, csc)」,直到沒錯了或達到了重試上限。每次重試之間有一個重試間隔(retry_interval)。

服務重試跟其他功能不同的地方在於它有比較大的副作用,因此要小心使用。因為重試會成倍地增加系統負荷,甚至會造成系統雪崩。有兩點要注意:

  1. 重試次數:一般來講次數不要過多,這樣才不會給系統帶來過大負擔
  2. 重試間隔時間:重試間隔時間要越來越長,這樣能錯開重試時間,而且越往後失敗的可能性越高,因此間隔時間要越長。一般是用斐波那契數列(Fibonacci sequence)或2的冪。當然如果重試次數少的話可酌情調整。示例中用了最簡單的方式,恆定間隔,生產環境中最好不要這樣設置。

並不是所有函數都需要重試功能,只有非常重要的,不能失敗的才需要。

服務超時:

服務超時給每個服務設定一個最大時間限制,超過之後服務停止,返回錯誤信息。它的好處是第一可以減少用戶等待時間,因為如果一個普通操作幾秒之後還不出結果就多半出了問題,沒必要再等了。第二,一個請求一般都會占用系統資源,如線程,資料庫連結,如果有大量等待請求會耗盡系統資源,導致系統宕機或性能降低。提前結束請求可以儘快釋放系統資源。下面是程序。它在context里設置了超時,並通過通道選擇器來判斷運行結果。當超時時,ctx的通道被關(ctx.Done()),函數停止運行,並調用cancelFunc()停止下游操作。如果沒有超時,則程序正常完成。

這個功能應該設置在客戶端還是服務端?服務重試沒有問題只能在客戶端。服務超時在服務端和客戶端都可以設置,但設置在客戶端更好,這樣命運是掌握在自己手裡。

下一個問題是順序選擇。 你是先做服務重試還是先做服務超時?結果是不一樣的。先做服務重試時,超時是設定在所有重試上;先做服務超時,超時是設定在每一次重試上。這個要根據你的具體需求來決定,我是把超時定在每一次重試上。

服務限流(Rate Limiting):

服務限流根據服務的承載能力來設定一個請求數量的上限,一般是每秒鐘能處理多少個並發請求。超過之後,其他所有請求全部返回錯誤信息。這樣可以保證服務質量,不能得到服務的請求也能快速得到結果。這個功能與其他不同,它定義在服務端。當然你也可以給客戶端限流,但最終還是要限制在服務端才更有意義。

下面是服務端「service」包中的「cacheServer.go", 它是服務端的接口函數。「CacheService」是它的結構,它實現了「Get」函數,也就服務端的業務邏輯。其他的修飾功能都是對他的補充修飾。

下面是「serverMiddleware.go」,它是服務端middleware的入口。它定義了結構「CacheServiceMiddleware」, 裡面只有一個成員「Next", 它的類型是 「pb.CacheServiceServer」,是gRPC服務端的接口。注意這裡我們的處理方式與客戶端不同,它沒有創建另外的接口, 而是直接使用了gRPC的服務端接口。客戶端的做法是每個函數建立一個入口(接口),這樣控制的顆粒度更細,但代碼量更大。服務端所有函數共用一個入口,控制的顆粒度較粗,但代碼量較少。這樣做的原因是客戶端需要更精準的控制。具體實現時,你可以根據應用程式的需求來決定選哪種方式。「BuildGetMiddleware」是服務端創建修飾結構的函數。ThrottleMiddleware是服務限流的實現結構。它裡面也只有一個成員「Next」。在創建時,要把具體的middleware功能依次帶入,現在只有一個就是「ThrottleMiddleware」。

下面是服務限流的實現程序,它比其他的功能要稍微複雜一些。其他功能使用的的控制參數(例如重試次數)在執行過程中是不會被修改的,而它的(throttle)是可以並行讀寫的,因此需要用「sync.RWMutex」來控制。具體的限流功能在「Get」函數中,它首先判斷是否超過閥值(throttle),超過則返回錯誤信息,反之則運行。

熔斷器 (Circuit Breaker):

熔斷器是最複雜的。 它的主要功能是當系統檢測到下游服務不暢通時對這個服務進行熔斷,這樣就阻斷了所有對此服務的調用,減少了下游服務的負載,讓下游服務有個緩衝來恢復功能。與之相關的就是服務降級,下游服務沒有了,需要的數據怎麼辦?一般是定義一個降級函數,或者是從緩存里取舊的數據或者是直接返回空值給上游函數,這要根據業務邏輯來定。下面是它的服務示意圖。服務A有三個下游服務,服務B,服務C,服務D。其中前兩個服務的熔斷器是關閉的,也就是服務是暢通的。服務D的熔斷器是打開的,也就是服務異常。

熔斷器用狀態機(State Machine)來進行管理,它會監測對下游服務的調用失敗情況,並設立一個失敗上限閥值,由閥值來控制狀態轉換。它有三個狀態:關閉,打開和半打開。這裡的「關閉「是熔斷器的關閉,服務是打開的。下面是它的簡單示意圖。

正常情況熔斷器是關閉的,當失敗請求數超過閥值時,熔斷器打開,下游服務關閉。熔斷器打開是有時間限制的,超時之後自動變成半打開狀態,這時只放一小部分請求通過。當請求失敗時,返回打開狀態,當請求成功並且數量超過閥值時,熔斷器狀態變成關閉,恢復正常。下面是它的詳細示意圖。它圖里有偽程序,可以仔細讀一下,讀懂了,就明白了實現原理。

當有多個修飾功能時,咋一看熔斷器應該是第一個執行的,因為如果服務端出了問題最好的對策就是屏蔽掉請求,其他的就不要試了,例如服務重試。但仔細一想,這樣的話服務重試就沒有被熔斷器監控,因此熔斷器還是最後一個執行好。不過具體情況還是要根據你有哪些修飾功能來決定。

熔斷器有很多不同的實現,其中最出名的可能是Netflix的「Hystrix」。本程序用的是一個go開源庫叫gobreaker, 因為它比較純粹(Hystrix把很多東西都集成在一起了),當然熔斷的原理都是一樣的。下面是熔斷器的程序。其中「cb」是「CircuitBreaker」的變量,它是在「init()」裡面創建的,這樣保證它只創建一次。在創建時,就設置了具體參數。「MaxRequests」是在半開狀態下允許通過的最大請求數。」Timeout「是關閉狀態的超時時間(超時後自動變成半開狀態),這裡沒有設置,取預設值60秒。「ReadyToTrip」函數用來控制狀態轉換,當它返回true時,熔斷器由關閉變成打開。熔斷器的功能是在函數「CallGet」中實現的,它的執行函數是「cb.Execute」, 只要把要運行的函數傳入就行了。如果熔斷器是打開狀態,它就返回預設值,如果熔斷器是關閉狀態,它就運行傳入的請求。熔斷器自己會監測請求的執行狀態並根據它的信息來控制開關轉換。

本示例中熔斷器是設置在客戶端的。從本質上來講它是針對客戶端的功能,當客戶端察覺要調用的服務失效時,它暫時屏蔽掉這個服務。但我覺得它其實設置在服務端更有優勢。 設想一下當有很多不同節點訪問一個服務時,當然也有可能別人都能訪問,只有我不能訪問。這基本可以肯定是我的節點和服務端節點之間的連結出了問題,根本不需要熔斷器來處理(容器就可以處理了)。因此,熔斷器要處理的大部分問題是某個微服務宕機了,這時監測服務端更有效,而不是監測客戶端。當然最後的效果還是要屏蔽客戶端請求,這樣才能有效減少網絡負載。這就需要服務端和客戶端之間進行協調,因此有一定難度。

另外還有一個問題就是如何判斷服務宕機了,這個是整個熔斷器的關鍵。如果服務返回錯誤結果,那麼是否意味著服務失效呢?這會有很多不同的情況,熔斷器幾乎不可能做出完全準確的判斷,從這點上來講,熔斷器還是有瑕疵的。我覺得要想做出準確的判斷,必須網絡,容器和Service Mesh進行聯合診斷才行。

故障注入(Fault Injection)

故障注入通過人為地注入錯誤來模擬生產環境中的各種故障和不穩定性。你可以模擬10%的錯誤率,或服務響應延遲。使用的技術跟上面講的差不多,可以通過修飾模式來對服務請求進行監控和控制,來達到模擬錯誤的目的。故障注入既可以在服務端也可以在客戶端。

艙壁隔離技術(Bulkhead)

艙壁隔離技術指的是對系統資源進行隔離,這樣當一個請求出現問題時不會導致整個系統的癱瘓。比較常用的是Thread pool和Connection pool。比如系統里有資料庫的Connection Pool,它一般都有一個上限值,如果請求超出,多餘的請求就只能處於等待狀態。如果你的系統中既有運行很慢的請求(例如報表),也有運行很快的請求(例如修改一個資料庫欄位),那麼一個好的辦法就是設立兩個Connection Pool, 一個給快的一個給慢的。這樣當慢的請求很多時,占用了所有Connection Pool,但不會影響到快的請求。下面是它的示意圖,它用的是Thread Pool。

Netflix的Hystrix同時集成了艙壁隔離技術和熔斷器,它通過限制訪問一個服務的資源(一般是Thread)來達到隔離的目的。它有兩種方式,第一種是通過Thread Pool, 第二種是信號隔離(Semaphore Isolation)。也就是每個請求都要先得到授權才能訪問資源,詳細情況請參閱這裡.

艙壁隔離的實際應用方式要比Hystrix的廣泛得多,它不是一種單一的技術,而是可以應用在許多不同的方向(詳細情況請參閱這裡) 。

新一代技術--自適應並發限制(Adaptive Concurreny Limit)

上面講的技術都是基於靜態閥值的,多數都是每秒多少請求。但是對於擁有自動伸縮(Auto-scaling)的大型分布式系統,這種方式並不適用。自動伸縮的雲系統會根據服務負載來調整伺服器的個數,這樣服務的閥值就變成動態的,而不是靜態的。Netflix的新一代技術可以建立動態閥值,它叫自適應並發限制(Adaptive Concurrency Limit)。它的原理是根據服務的延遲來計算負載,從而動態地找出服務的閥值。一旦找出動態閥值,這項技術是很容易執行的,困難的地方是如何找出閥值。這項技術可以應用在下面幾個方向:

  • RPC(gRPC):既可以應用在客戶端,也可以應用在服務端。可以使用攔截器(Interceptor)來實現
  • Servlet: 可以使用過濾器來實現(Filter)
  • Thread Pool:這是一種更通用的方式。它可以根據服務延遲來自動調節Thread Pool的大小,從而達到並發限制。

詳細情況請參見Netflix/concurrency-limits.

Service Mesh實現:

從上面的程序實現可以看出,它們的每個功能並不複雜,而且不會對業務邏輯進行侵入。但上面只是實現了一個微服務調用的一個函數,如果你有很多微服務,每個微服務又有不少函數的話,那它的工作量還是相當大的。有沒有更好的辦法呢?當我接觸服務韌性時,就覺得直接把功能放到程序里對業務邏輯侵入太大,就用了攔截器(Interceptor),但它不夠靈活,也不方便。後來終於找到了比較靈活的修飾模式的實現方式,這個問題終於解決了。但工作量還是太大。後來看到了Service Mesh才發現問題的根源。因為服務韌性本來就不是應用程式應該解決的問題,

而是基礎設施或中間件的主場。這裡面涉及到的許多數據都和網絡和基礎設施相關,應用程式本來就不掌握這些信息,因此處理起來就束手束腳。應用程式還是應該主要關注業務邏輯,而把這些跨領域的問題交給基礎設施來處理。

我們知道容器技術(Docker和Kubernetes)的出現大大簡化了程序的部署,特別是對微服務而言。但一開始服務韌性這部分還是由應用程式來做,最有名的應該是「Netflix OSS」。現在我們把這部分功能抽出來, 就是Service Mesh, 比較有名的是Istio和Linkerd。當然Service Mesh還有其它功能,包括網路流量控制,權限控制與授權,應用程式監測等。它是在容器的基礎上,加強了對應用程式的管理並提供了更多的服務。

當用Service Mesh來實現服務韌性時,你基本不用編程,只需要寫些配置文件,這樣更加徹底地把它與業務邏輯分開了,也減輕了碼農的負擔。但它也不是沒有缺點的,編寫配置文件實際上是另一種變向的編程,當文件大了之後很容易出錯。現在已經有了比較好的支持容器的IDE,但對Service Mesh的支持還不是太理想。另外,就是這個技術還比較新,有很多人都在測試它,但在生產環境中應用的好像不是特別多。如果想要在生產環境中使用,需要做好準備去應對各種問題。當然Service Mesh是一個不可阻擋的趨勢,就像容器技術一樣,也許將來它會融入到容器中,成為容器的一部分。有關生產環境中使用Service Mesh請參閱「下一代的微服務架構基礎是ServiceMesh?」

Service Mesh的另一個好處是它無需編程,這樣就不需要每一種語言都有一套服務韌性的庫。當然Service Mesh也有不同的實現,每一種實現在設置參數時都有它自己的語法格式,理想的情況是它們都遵守統一的接口,希望以後會是這樣。

什麼時候需要這些技術?

上面提到的技術都不錯,但不論你是用程序還是用Service Mesh來實現,都有大量的工作要做,尤其是當服務眾多,並且之間的調用關係複雜時。那麼你是否應該讓所有的服務都具有這些功能呢?我的建議是,在開始時只給最重要的服務增加這些功能,而其他服務可以先放一放。當在生產環境運行一段時間之後,就能發現那些是經常出問題的服務,再根據問題的性質來考慮是否增加這些功能。應用本文中的修飾模式的好處是,它增加和刪除修飾功能都非常容易,並且不會影響業務邏輯。

結論:

服務韌性是微服務里非常重要的一項技術,它可以在服務環境不可靠的情況下仍能提供適當的服務,大大提高了服務的容錯能力。它使用的技術包括服務超時 (Timeout),服務重試 (Retry),服務限流(Rate Limiting),熔斷器 (Circuit Breaker),故障注入(Fault Injection)和艙壁隔離技術(Bulkhead)。你可以用代碼也可以用Service Mesh來實現這些功能,Service Mesh是將來的方向。但實現它們需要大量的工作,因此需要考慮清楚到底哪些服務真正需要它們。

源碼:

完整源碼的github連結 https://github.com/jfeng45/grpcservice

索引:

[1]Go kit

http://gokit.io/examples/stri...

[2] Circuit Breaker Pattern

https://docs.microsoft.com/en...

[3]gobreaker

https://github.com/sony/gobre...

[4]Bulkhead pattern

https://docs.microsoft.com/en...

[5]How it Works

https://github.com/Netflix/Hy...

[6]It takes more than a Circuit Breaker to create a resilient application

https://developers.redhat.com...

[7]Netflix/concurrency-limits

https://github.com/Netflix/co...

[8]Istio

https://istio.io/

[9]Linkerd

https://linkerd.io/

[10]下一代的微服務架構基礎是ServiceMesh?

https://www.sohu.com/a/271138...

原文連結:https://segmentfault.com/a/1190000020503704

本文作者:倚天碼農

文章來源: https://twgreatdaily.com/zh-tw/Zy14lW0BMH2_cNUgX8Dn.html