當我們談微服務,我們在談什麼 (3) — 如何保障微服務的穩定性

fredal發表於2019-04-16

當一個單體應用改造成多個微服務之後,在請求呼叫過程中往往會出現更多的問題,通訊過程中的每一個環節都可能出現問題。而在出現問題之後,如果不加處理,還會出現鏈式反應導致服務雪崩。服務治理功能就是用來處理此類問題的。我們將從微服務的三個角色:註冊中心、服務消費者以及服務提供者一一說起。

註冊中心如何保障穩定性

註冊中心主要是負責節點狀態的維護,以及相應的變更探測與通知操作。一方面,註冊中心自身的穩定性是十分重要的。另一方面,我們也不能完全依賴註冊中心,需要時常進行類似註冊中心完全當機後微服務如何正常執行的故障演練。

這一節,我們著重講的並不是註冊中心自身可用性保證,而更多的是與節點狀態相關的部分。

節點資訊的保障

我們說過,當註冊中心完全當機後,微服務框架仍然需要有正常工作的能力。這得益於框架內處理節點狀態的一些機制。

本機記憶體

首先服務消費者會將節點狀態保持在本機記憶體中。一方面由於節點狀態不會變更得那麼頻繁,放在記憶體中可以減少網路開銷。另一方面,當註冊中心當機後,服務消費者仍能從本機記憶體中找到服務節點列表從而發起呼叫。

本地快照

我們說,註冊中心當機後,服務消費者仍能從本機記憶體中找到服務節點列表。那麼如果服務消費者重啟了呢?這時候我們就需要一份本地快照了,即我們儲存一份節點狀態到本地檔案,每次重啟之後會恢復到本機記憶體中。

服務節點的摘除

現在無論註冊中心工作與否,我們都能順利拿到服務節點了。但是不是所有的服務節點都是正確可用的呢?在實際應用中,這是需要打問號的。如果我們不校驗服務節點的正確性,很有可能就呼叫到了一個不正常的節點上。所以我們需要進行必要的節點管理。

對於節點管理來說,我們有兩種手段,主要是去摘除不正確的服務節點。

註冊中心摘除機制

一是通過註冊中心來進行摘除節點。服務提供者會與註冊中心保持心跳,而一旦超出一定時間收不到心跳包,註冊中心就認為該節點出現了問題,會把節點從服務列表中摘除,並通知到服務消費者,這樣服務消費者就不會呼叫到有問題的節點上。

服務消費者摘除機制

二是在服務消費者這邊拆除節點。因為服務消費者自身是最知道節點是否可用的角色,所以在服務消費者這邊做判斷更合理,如果服務消費者呼叫出現網路異常,就將該節點從記憶體快取列表中摘除。當然呼叫失敗多少次之後才進行摘除,以及摘除恢復的時間等等細節,其實都和客戶端熔斷類似,可以結合起來做。

一般來說,對於大流量應用,服務消費者摘除的敏感度會高於註冊中心摘除,兩者之間也不用刻意做同步判斷,因為過一段時間後註冊中心摘除會自動覆蓋服務消費者摘除。

服務節點是可以隨便摘除/變更的麼

上一節我們講可以摘除問題節點,從而避免流量呼叫到該節點上。但節點是可以隨便摘除的麼?同時,這也包含"節點是可以隨便更新的麼?"疑問。

頻繁變動

當網路抖動的時候,註冊中心的節點就會不斷變動。這導致的後果就是變更訊息會不斷通知到服務消費者,服務消費者不斷重新整理本地快取。如果一個服務提供者有100個節點,同時有100個服務消費者,那麼頻繁變動的效果可能就是100*100,引起頻寬打滿。

這時候,我們可以在註冊中心這邊做一些控制,例如經過一段時間間隔後才能進行變更訊息通知,或者開啟開關後直接遮蔽不進行通知,或者通過一個概率計算來判斷需要向哪些服務消費者通知。

增量更新

同樣是由於頻繁變動可能引起的網路風暴問題,一個可行的方案是進行增量更新,註冊中心只會推送那些變化的節點資訊而不是全部,從而在頻繁變動的時候避免網路風暴。

可用節點過少

當網路抖動,並進行節點摘除過後,很可能出現可用節點過少的情況。這時候過大的流量分配給過少的節點,導致剩下的節點難堪重負,罷工不幹,引起惡化。而實際上,可能節點大多數是可用的,只不過由於網路問題與註冊中心未能及時保持心跳而已。

這時候,就需要在服務消費者這邊設定一個開關比例閾值,當註冊中心通知節點摘除,但快取列表中剩下的節點數低於一定比例後(與之前一段時間相比),不再進行摘除,從而保證有足夠的節點提供正常服務。

這個值其實可以設定的高一些,例如百分之70,因為正常情況下不會有頻繁的網路抖動。當然,如果開發者確實需要下線多數節點,可以關閉該開關。

服務消費者如何保障穩定性

一個請求失敗了,最直接影響到的是服務消費者,那麼在服務消費者這邊,有什麼可以做的呢?

超時

如果呼叫一個介面,但遲遲沒有返回響應的時候,我們往往需要設定一個超時時間,以防自己被遠端呼叫拖死。超時時間的設定也是有講究的,設定的太長起的作用就小,自己被拖垮的風險就大,設定的太短又有可能誤判一些正常請求,大幅提升錯誤率。

在實際使用中,我們可以取該應用一段時間內的P999的值,或者取p95的值*2。具體情況需要自行定奪。

在超時設定的時候,對於同步與非同步的介面也是有區分的。對於同步介面,超時設定的值不僅需要考慮到下游介面,還需要考慮上游介面。而對於非同步來說,由於介面已經快速返回,可以不用考慮上游介面,只需考慮自身在非同步執行緒裡的阻塞時長,所以超時時間也放得更寬一些。

容錯機制

請求呼叫永遠不能保證成功,那麼當請求失敗時候,服務消費者可以如何進行容錯呢?通常容錯機制分為以下這些:

  • FailTry:失敗重試。就是指最常見的重試機制,當請求失敗後檢視再次發起請求進行重試。這樣從概率上講,失敗率會呈指數下降。對於重試次數來說,也需要選擇一個恰當的值,如果重試次數太多,就有可能引起服務惡化。另外,結合超時時間來說,對於效能有要求的服務,可以在超時時間到達前的一段提前量就發起重試,從而在概率上優化請求呼叫。當然,重試的前提是冪等操作。
  • FailOver:失敗切換。和上面的策略類似,只不過FailTry會在當前例項上重試。而FailOver會重新在可用節點列表中根據負載均衡演算法選擇一個節點進行重試。
  • FailFast:快速失敗。請求失敗了就直接報一個錯,或者記錄在錯誤日誌中,這沒什麼好說的。

另外,還有很多形形色色的容錯機制,大多是基於自己的業務特性定製的,主要是在重試上做文章,例如每次重試等待時間都呈指數增長等。

第三方框架也都會內建預設的容錯機制,例如Ribbon的容錯機制就是由retry以及retry next組成,即重試當前例項與重試下一個例項。這裡要多說一句,ribbon的重試次數與重試下一個例項次數是以笛卡爾乘積的方式提供的噢!

熔斷

上一節將的容錯機制,主要是一些重試機制,對於偶然因素導致的錯誤比較有效,例如網路原因。但如果錯誤的原因是服務提供者自身的故障,那麼重試機制反而會引起服務惡化。這時候我們需要引入一種熔斷的機制,即在一定時間內不再發起呼叫,給予服務提供者一定的恢復時間,等服務提供者恢復正常後再發起呼叫。這種保護機制大大降低了鏈式異常引起的服務雪崩的可能性。

在實際應用中,熔斷器往往分為三種狀態,開啟、半開以及關閉。引用一張martinfowler畫的原理圖:

當我們談微服務,我們在談什麼  (3) — 如何保障微服務的穩定性

在普通情況下,斷路器處於關閉狀態,請求可以正常呼叫。當請求失敗達到一定閾值條件時,則開啟斷路器,禁止向服務提供者發起呼叫。當斷路器開啟後一段時間,會進入一個半開的狀態,此狀態下的請求如果呼叫成功了則關閉斷路器,如果沒有成功則重新開啟斷路器,等待下一次半開狀態週期。

斷路器的實現中比較重要的一點是失敗閾值的設定。可以根據業務需求設定失敗的條件為連續失敗的呼叫次數,也可以是時間視窗內的失敗比率,失敗比率通過一定的滑動視窗演算法進行計算。另外,針對斷路器的半開狀態週期也可以做一些花樣,一種常見的計算方法是週期長度隨著失敗次數呈指數增長。

具體的實現方式可以根據具體業務指定,也可以選擇第三方框架例如Hystrix。

隔離

隔離往往和熔斷結合在一起使用,還是以Hystrix為例,它提供了兩種隔離方式:

  • 訊號量隔離:使用訊號量來控制隔離執行緒,你可以為不同的資源設定不同的訊號量以控制併發,並相互隔離。當然實際上,使用原子計數器也沒什麼不一樣。
  • 執行緒池隔離:通過提供相互隔離的執行緒池的方式來隔離資源,相對來說消耗資源更多,但可以更好地應對突發流量。

降級

降級同樣大多和熔斷結合在一起使用,當服務呼叫者這方斷路器開啟後,無法再對服務提供者發起呼叫了,這時候可以通過返回降級資料來避免熔斷造成的影響。

降級往往用於那些錯誤容忍度較高的業務。同時降級的資料如何設定也是一門學問。一種方法是為每個介面預先設定好可接受的降級資料,但這種靜態降級的方法適用性較窄。還有一種方法,是去線上日誌系統/流量錄製系統中撈取上一次正確的返回資料作為本次降級資料,但這種方法的關鍵是提供可供穩定抓取請求的日誌系統或者流量取樣錄製系統。

另外,針對降級我們往往還會設定操作開關,對於一些影響不大的採取自動降級,而對於一些影響較大的則需進行人為干預降級。

服務提供者如何保障穩定性

限流

限流就是限制服務請求流量,服務提供者可以根據自身情況(容量)給請求設定一個閾值,當超過這個閾值後就丟棄請求,這樣就保證了自身服務的正常執行。

閾值的設定可以針對兩個方面考慮,一是QPS即每秒請求數,二是併發執行緒數。從實踐來看,我們往往會選擇後者,因為QPS高往往是由於處理能力高,並不能反映出系統"不堪重負"。

除此之外,我們還有許多針對限流的演算法。例如令牌桶演算法以及漏桶演算法,主要針對突發流量的狀況做了優化。第三方的實現中例如guava rateLimiter就實現了令牌桶演算法。在此就不就細節展開了。

重啟與回滾

限流更多的起到一種保障的作用,但如果服務提供者已經出現問題了,這時候該怎麼辦呢?

這時候就會出現兩種狀況。一是本身程式碼有bug,這時候一方面需要服務消費者做好熔斷降級等操作,一方面服務提供者這邊結合DevOps需要有快速回滾到上一個正確版本的能力。

更多的時候,我們可能僅僅碰到了與程式碼無強關聯的單機故障,一個簡單粗暴的辦法就是自動重啟。例如觀察到某個介面的平均耗時超出了正常範圍一定程度,就將該例項進行自動重啟。當然自動重啟需要有很多注意事項,例如重啟時間是否放在晚上,以及自動重啟引起的與上述節點摘除一樣的問題,都需要考慮和處理。

在事後覆盤的時候,如果當時沒有保護現場,就很難定位到問題原因。所以往往在一鍵回滾或者自動重啟之前,我們往往需要進行現場保護。現場保護可以是自動的,例如一開始就給jvm加上列印gc日誌的引數-XX:+PrintGCDetails,或者輸出oom檔案-XX:+HeapDumpOnOutOfMemoryError,也可以配合DevOps自動指令碼完成,當然手動也可以。一般來說我們會如下操作:

  • 列印堆疊資訊,jstak -l 'java程式PID'
  • 列印記憶體映象,jmap -dump:format=b,file=hprof 'java程式PID'
  • 保留gc日誌,保留業務日誌

排程流量

除了以上這些措施,通過排程流量來避免呼叫到問題節點上也是非常常用的手段。

當服務提供者中的一臺機器出現問題,而其他機器正常時,我們可以結合負載均衡演算法迅速調整該機器的權重至0,避免流量流入,再去機器上進行慢慢排查,而不用著急第一時間重啟。

如果服務提供者分了不同叢集/分組,當其中一個叢集出現問題時,我們也可以通過路由演算法將流量路由到正常的叢集中。這時候一個叢集就是一個微服務分組。

而當機房炸了、光纜被偷了等IDC故障時,我們又部署了多IDC,也可以通過一些方式將流量切換到正常的IDC,以供服務繼續正常執行。切換流量同樣可以通過微服務的路由實現,但這時候一個IDC對應一個微服務分組了。除此之外,使用DNS解析進行流量切換也是可以的,將對外域名的VIP從一個IDC切換到另一個IDC。

相關文章