日調1000億,騰訊微服務平臺的架構演進

http://mpvideo.qpic.cn/0bf22uaamaaajaanmi3ahrpvbvoda3kqabqa.f10002.mp4?dis_k=ba947d3da0c098f0aab41a04c653dec1&dis_t=1601210079&vid=wxv_1519816301052018689

點擊查看完整直播回放~

一、微服務平臺簡介

1. 微服務平臺

要搭建一套能穩定支持海量調用的微服務系統,需要先看看系統由哪些模塊組成。如上圖所示,從下往上看,不同的用戶 VPC 代表多租戶,中間是服務註冊發現的模塊,頂部是應用管理模塊和數據化運營模塊,應用管理模塊用來進行 CICD,包括了分發、部署以及配置管理等應用生命週期相關的功能。

數據化運營這個模塊主要用於幫助業務進行分析,包括但不限於調用鏈、日誌、metrics 等。

從系統架構上來說,藍框裏的屬於數據面,也就是常說的 data plane,是影響業務請求的核心鏈路;灰色區域內更多的偏向控制流,也就是 control plane ,幫助我們更簡單的使用好微服務。

數據面和控制面屬於兩種不同的架構,面臨的挑戰也各不相同。數據面的挑戰主要難點在於大流量、穩定性和高可用等,而控制面的更多則是業務複雜度。本文將圍繞着整個數據面分享如何讓微服務系統變得更穩定。

2. 海量調用的起始——微服務

2014 年雲興起之後,提出了雲時代下的微服務概念:單一應用程序構成的小程序,自己擁有自己的邏輯與輕量化處理能力,服務以全自動方式部署,與其他的服務之間進行通信,服務可以使用不同編程語言和數據庫實現。

這個概念和早期 Dubbo 這種基於 Netty 框架構成的系統本質上沒區別,只不過雲興起之後增加了一些自動部署和 docker 的能力,也做了更多的集成。

通過上圖可以看到,我們每個微服務都內聚了自己的業務邏輯,允許訪問不同的數據庫,以及通過 rest API 進行互相通信。從模型來看有點像是蜂巢,也很像一張網。這裏就會引申出一個小問題,這麼多的微服務,他們之間是如何進行調用的?

http client 本身我們知道是通過 IP 和 port 來進行互相調用,早期單體應用還能夠簡單的進行配置。但是在微服務時代,特別是用了 K8s 和 docker,每次啓動的 IP 也可能會變,這裏該怎麼辦呢?其實可以通過服務註冊發現模塊進行機器實例的互相發現和調用。

3. 微服務之間如何互相發現

我們來看圖解釋一下什麼是服務註冊發現。

圖上能看到有兩個微服務,ServiceA 和 ServiceB。在這裏我們定義 ServiceA 爲服務調用方,ServiceB 爲服務提供者。

前面也說到了,在單體應用時代,我們都需要通過配置來指定需要訪問的 IP,但是雲時代下的微服務,IP 本身會變,所以需要有一個地方來記錄這些 IP,那就是服務註冊和發現的能力。

ServiceB 的每個實例在啓動時,會將自身的 IP 和 port 寫入到服務發現模塊的某個路徑,然後 ServiceA 會從這個路徑來拉取到想要訪問的 ServiceB 的服務提供者的列表。這裏就會拉取到三個實例節點,從中選擇一個節點進行訪問。

目前市面上已經有很多成熟的註冊發現組件,像是 zookeeper,nacos,Consul 等。Consul 本身作爲一個開箱即用,並且支持 http 請求,同時擁有豐富的文檔和簡單的 API 的系統被很多的中小型公司青睞和使用。

當然,Consul 和 Spring 的對接也很成熟,很多中小型公司,特別是比較新的公司很多都會選擇 Consul 來作爲服務註冊發現。所以我們選擇基於 Consul 作爲底層基礎組件,在上面一點點的進行擴展,來搭建一套穩定的服務註冊發現模塊。

二、突破原生開源架構

1. 原生Consul的能力與限制

Consul 有開源版本和企業版本,對於開源版本來說,基本功能都齊全,但企業級能力卻不提供。缺少這些企業級的能力,對想用 Consul 來實現支持海量調用的微服務系統會有不足。

原生的Consul的服務發現 API 參數只有一個服務名,想要多租戶或者帶上 namespace,只能拼接在服務名上。但是這裏需要強行依賴 SDK,對於 Consul 這樣一個暴露 http 的通用服務,不能限定用戶的語言,也不可能每個語言去實現一套 SDK,我們需要用其他方式來實現。

2. 實現多租戶

實現多租戶的能力,需要在原有的 Consul-server 集羣之前,加一層 Consul-access 層。對外暴露的 API 和原生的 Consul 完全一樣,但背後針對一些 API 可以進行了一些改造。

先簡單看看通過加入 access 層,怎麼來實現多租戶的能力:

服務註冊發現一般來說分爲三步

  1. 服務提供進行實例註冊;
  2. 心跳上報;
  3. 服務消費者拉取服務提供者列表。

爲了簡化問題,我們在這裏不考慮異常情況和順序情況。

針對這三步,我們分別進行一些改造,用戶側使用原生的 Consul-SDK 進行服務註冊,當 access 接收到註冊請求後,會將該請求翻譯成一個 KV 請求,然後存儲到 Consul-server 上。

第一步,實現多租戶的能力,服務發現第一步是服務提供者進行實例註冊,接到註冊請求時會將該請求翻譯成key是/租戶/service/serviceA/instance-id/data,value 是用戶註冊上來的節點信息的請求,每一個 instance-id 對應的目錄就代表着一個服務提供者。

並沒有採取原生的服務註冊的 Consul 提供的服務註冊,而是爲了做治理能力分開成了 KV 進行存儲。

第二步,心跳請求也會被 Consul-access 攔截,翻譯成 KV 請求,存在 Consul 集羣上。

第三步,是服務發現請求,該請求被 Consul-access 攔截後,首先會從前面所說的路徑下拉取當前全部的實例列表,然後將對應實例的最後心跳上報時間取出,第二步存 KV 的地方將服務註冊的實例信息和心跳數據進行合併。

合併是將最近 30 s 內沒有心跳數據的節點狀態置爲 critical,30 s 內有心跳的節點狀態置爲 passing,然後返回給客戶端,這樣對於客戶端來說,和直接請求 Consul-server 行爲保持一致。

但這種實現方式有個問題:用戶如果使用原生的 API 進行服務註冊的話,本身其實並不包含租戶的信息,那 access 如何知道寫入的路徑呢?

3. 透明地生成租戶信息

這種實現有一個問題,用戶使用的原生 API 進行註冊,本來不帶有租戶信息,Access 第一步就無法實現了,這塊如何處理?

原生 Consul 的有一個參數叫 token,是個 String 字段,爲了即兼容原生的 Consul請求,又能夠獲取我們想要的信息,我們在這裏做了一些文章。

首先需要實現 “token” 模塊,該模塊提供 token 授予和驗證,同時也提供根據 token 返回相應信息的能力。

用戶首先申請 token 密鑰,填寫信息,如租戶名等等,然後 token-server 模塊會根據這些信息生成一個 token 密鑰並返回給客戶端。用戶在使用原生 API 的時候,需要把該 token 帶上。Consul-access 收到 token 後,會從 token-server 來換取對應的信息,這樣對於用戶而言做到了完全透明。

Token-server 模塊的存在,使得 access 層除了能夠實現一些企業級的功能,比如多租戶,也可以使得整個 Consul 集羣可以更加穩定,比如防止用戶惡意的進行調用,來對 Consul-server 造成 ddos 攻擊。

也可以針對不同的業務模塊或者用戶可以進行不同的治理手段,比如我們可以根據用戶的 token 等級來設定 Consul 的配額,或者可以在測試環境限制訪問頻率,或者說限制某些用戶的某些操作權限。

引入了 token 模塊和 access 層,還可以做非常多有意思的事情,這裏是給大家發散思考的地方,如果你們來做的話,還會加入哪些能力呢?

我們已經發現加入了 access 層從功能層面上能夠讓整個 Consul 集羣能夠更加穩定,有些有經驗的工程師可能會想到,會不會因爲多了一層,而導致性能變差呢?

下面我們來看看,在加入 access 層後,我們做了哪些性能優化。

三、讓服務發現更穩定

1. 性能優化Ⅰ

我們先來看一下沒有 Consul-access 這一層的情況:

假設有 A,B,C 三個微服務,服務 A 需要調用服務 B 和服務 C。

服務 A 有 100 個實例,每個客戶端實例都需要訂閱服務 B 和服務 C,那麼和 Consul 需要建立的長連接數爲 2* 100=200 個連接。每個微服務需要建立 2 個長連接,一共 100 個,所以需要 200 個。

再來看一下加了 Consul-access 層之後,每個微服務需要建立兩個長鏈接,100 個實例是 200 個,客戶端到 access 的連接數還是 100* 2=200 沒有變化,但是 access 到 Consul-server 的連接卻變成了 3* 2=6 個!

這裏的 3 指的是 Consul-access 的臺數,而 2 指的是需要訂閱的服務數目,這裏就是爲什麼 B 和 C 兩個可以做聚合的原因。

因爲對於 access 而言,不同實例的相同訂閱請求是可以合併的,比如實例 1-33 服務監聽請求都發到了第一臺 access,access 只需要發送一次 watch 請求到 Consul-server 上。當 Consul 集羣的這個值出現變化後,會返回給 access,而 access 會從緩存中拉取出監聽該服務的連接,然後依此將變化後的值推送回客戶端。

這裏可能有人會有疑問,就算 access 到 Consul-server 的連接數從 200 降爲了 6,但是客戶端到 access 層的連接還是 200 啊,而且總數還是 206,還多了 6 個,這裏的區別在哪裏呢?

Consul 本身是一個 CP 的系統,自身是基於 raft 來保證強一致的,即使請求連接發到 follower,也會同樣的轉發到 leader 上面。而 watch 類型的讀請求(也就是上面說的訂閱類型請求,需要長連接掛載的),在沒有開啓 stale-read 參數的情況下,也會被轉發至 leader。

因此,leader 上掛載的長連接數會是整個集羣的整體連接數,隨着連接數的增多,每當數據有變化時,leader 需要一次遍歷所有 watch 該路徑的連接,將變更數據返回,會消耗大量的 CPU 和 IO。

比如說 100 個服務監聽請求會 watch 在 Consul-server,當監聽的微服務實例數變化時,Consul-server 就需要遍歷 100 個連接。

雖然 Consul-access 層也需要做遍歷連接這個操作,但 access 本身是無狀態的,這是非常重要的一點。

一臺 access 需要承載 100 個連接,3 臺 access 的話,每臺只需要負責 33 個連接,如果繼續水平擴容,每臺的負載可以更低。而每擴容一臺 access,對 Consul-server 的集羣壓力很小,只會增加 watch 的服務數個請求。

相比之下 Consul-server 沒有辦法無限水平擴容,你擴的越多,反而對 leader 的壓力越大,但是垂直擴容又是有上限的,不可能一直擴展下去,所以我們通過增加 access 層來解決這個難題。

這是一個非常通用的架構思想,當底層系統有瓶頸無法水平擴容時,可以想辦法把壓力上提到一個可以水平擴展的層級,將壓力轉移出去,從而使整個系統變得更加穩定。比如數據庫中間件和背後的 mysql。

有些想的比較遠的同學可能發現了,這樣做只能大大緩解 Consul-server 的瓶頸,但是隨着服務數的增多,還是會出現瓶頸。

2. 性能優化後依然到了瓶頸怎麼辦?

解決方案很簡單,Consul-access 是無狀態可以水平擴容的,但是 Consul-server 集羣有瓶頸,那麼我們可以以 Consul-server 集羣爲粒度進行水平擴容。

我們還是藉助之前說的 token 模塊,根據 token 返回的信息來進行判定,然後決定當前的用戶請求應當轉發到哪個 server 集羣,然後通過這種思路,讓整個服務發現系統做到完全水平擴容。

這種架構目前也被廣泛採用,比如 shardingredis,水平分庫中間件等。

3. 性能優化II

這裏再講一個小優化,在運行過程中,發現 Consul 的 CPU 佔用還是比較高的,這裏用 pprof 進行調用採樣分析後,發現大量的 CPU 消耗都來自於一個請求。

通過排查,發現是 Spring-SDK 中會每 2s 定時發一個 watch 請求,在這裏我們做個轉換,將 watchtimeout 2s 的請求在 access 側轉換成了 55s 轉發給 Consul-server。需要注意的是,這裏的轉換和原來沒有區別,也是會在 55s 有變化的時候返回,但是大大降低了 CPU。上面兩張 CPU 負載圖是在運行了 3 天后的結果。

比起分享具體的某一次優化是怎麼做的,更希望能給大家一些啓發,某些看起來可能很難的事情,比如這裏的 CPU 降低 60% 負載,但你去嘗試優化了,可能發現還是比較容易的,但是成本收益很高,相比起某些架構上或者代碼上的細節,一些點的修改可能會極大的影響穩定性。

4. CP系統如何做到高可用

講了如何從功能緯度和性能緯度讓整個服務發現系統更穩定,下面是如何讓服務發現變得高可用。

Consul 是一個 CP 系統,根據 CAP 定理,CP 系統是沒法做到高可用的,所以我們只能儘可能的在別的環節來加強,彌補一下 CP 系統的可用性。這裏我會從客戶端、SDK 和 access 層來分享如何儘量讓整個系統更高可用。

爲什麼是在 SDK 和 access 層做增強呢,其實是因爲 Consul-server 對我們來說是黑盒,我們不對他進行改造,因爲不同於 access 層,修改 Consul 源碼超出了大部分中小型公司的範圍,在此我們不做定製化。

同樣的,我們還是先分析沒有 access 層的情況:

要搞清楚的一點是,服務註冊發現三步驟中、註冊、心跳和發現,出問題下分別會對系統造成什麼樣子的影響?

首先是註冊,如果一個實例註冊不上去,那麼再有其他實例存在的情況下,對整體微服務是沒有任何影響的。

然後是心跳,如果因爲某個原因,比如網絡閃斷,或者丟包,丟失了心跳,那麼會導致該節點從服務註冊列表中下線。如果實例數少的情況下,部分實例下線會導致流量不均勻甚至整個系統垮掉。

最後發現,如果因爲異常,比如 Consul 重啓後丟失了數據,或者比如網絡原因,服務提供者的實例沒有註冊上去,會導致拉取到的實例列表爲 0,這裏會直接造成服務不可用。

根據上面的分析,我們可以發現,首先要加強的是服務發現:

Spring 原生的服務發現是每 30s 左右去拉取新的實例列表,這裏其實還有個 bug,這個參數無法通過配置來指定。

所以第一步,我們將定時拉取改爲了 watch 機制。好處在於,某些實例如果已經反註冊下線後,可以立刻通知客戶端更新列表,否則在最多 30s 的時間內,可能請求還是會打到已經下線的機器上。

同時,增加了本地緩存,每次拉回來的服務列表,會存儲在本地,這樣如果機器 crash 或者因爲某些原因,Consul 集羣不可用時,不至於導致整個微服務系統全部不可用。

最後增加零實例保護,指的是,如果從 Consul 拉取的列表爲空時,不替換內存中的數據,也不刷新緩存。因爲如果 Consul 集羣不可用,或者冷啓動,或者其他不可預知的場景時,拉取回空列表會造成巨大的影響。

第二步我們要加強心跳上報的流程,心跳上報是 put 請求,所以這裏需要設置 readtimeout,默認是 1min。

這裏要注意的是,1min 可能是兩個心跳週期,如果客戶端和 Consul 之間的網絡抖動或者丟包,會直接造成 1 分鐘內不可用,所以這裏首先要設置 readtimeout,一般推薦 5s 左右。

同時需要配套增加重試機制,否則一次失敗就會導致一個生命週期掉線,這裏推薦 3 次,配合上剛剛超時的 5s,一共 15s,小於一個週期的 30s。

說完 SDK 後,我們再來看看 access 層,在計算機系統裏,每增加一箇中間層,解決一些問題的同時,也會帶來一些問題,特別是可用性這裏,引入中間層,必須做的更多,才能保證和沒有這一層一樣的可用性。

和 SDK 不一樣,access 對於客戶端來說就是服務端,所以要儘可能的保證每個請求的成功,所以我們可以做一些通用性質的可用性增強建設:

第一,和 SDK 的一樣,減少 timeout 和重試,但是由於 raft 的實現機制,我們只需要重試最多 1/2+1 次就行。

第二,當出現某個極端場景,比如整個 Consul-server 集羣不可用,我們需要增加一個兜底集羣。在某個集羣整個不可用時,將流量轉發到兜底集羣,並做下記錄,等服務發現等 get 類型請求時,需要知道從哪個集羣拉取合併數據。

第三,我們還需要增加主動發現問題的能力,這裏我們增加集羣探測的 agent,定時發送請求給每臺 access 和每個 Consul-server 集羣,出了問題及時告警。

除了以上針對每個請求的通用能力建設外,我們還可以針對服務發現,做一些更多的增強。

首先如果真的出現內部錯誤,需要用 500 來代替空列表返回。這樣不管是原生的 SDK 還是剛剛經過我們加強的 SDK,都不會替換內存裏的列表,至少可以保證微服務系統繼續運行。

然後,我們需要加入零實例保護的機制,這裏和 SDK 有些區別,指的是如果發現所有實例都不可用,則以最近一個不可用的實例節點的最後心跳時間作爲基線,往前一個心跳週期作爲時間範圍,將這個時間段的實例狀態置爲 passing 返回給客戶端。

這裏有點拗口,我們舉個實際的例子,比如當前服務 B 有 10 個實例,其中 2 個在幾個小時前就已經掉線了,還有 8 個在正常運行,此時,Consul 集羣完全不可用十分鐘,所以心跳信息無法上報,當 Consul 集羣恢復時,access 會發現最近的心跳是 Consul 集羣不可用那個時間點,也就是 10min 前,但是由於已經超過了一個心跳週期 30s,所以這裏所有的實例都不可用,返回給客戶端的話,會造成非常劇烈的影響。

但是如果增加了零實例保護,則會在返回實例列表時,發現最後一次上報心跳的節點在十分鐘前,同時往前推 30s 發現一共有 8 個節點是在這個時間段丟失心跳,所以會將這 8 個實例返回給客戶端。

當然,這裏需要有個界限,一般選擇 Consul-server 集羣能夠修復的時間,比如 1~3 小時,再長的話可能髒數據概率會比較大。

服務註冊發現的話題先說到這,再來回顧我們通過增加 access 層和 token 模塊,來實現了企業級能力,增強了功能性的穩定性。同時通過聚合連接以及多 Consul-server 集羣模式,大大增強了整個 Consul 的性能穩定性,最後,通過一些高可用的增強,我們加強了整個服務發現系統的可用性。

在我們擁有一個穩定的服務發現系統之後,我們進入下一個話題,如何讓服務之間的調用變得更加穩定。

5. 微服務之間如何互相調用

我們先來看看一個基於 Springcloud 開發的微服務之間的調用流程:

當用戶發起 API 調用時,首先會來到 feign 模塊。在 Springcloud 中,feign 起到了一個承上啓下的作用,他封裝了 http 的調用,讓用戶可以像使用接口一樣的方式發起 rest 調用,同時也是在這裏配置了需要訪問的微服務名稱。

feign 帶着需要訪問的服務名和拼接好的 http 請求來到了 ribbon 模塊,

ribbon 簡單來說就是一個負載均衡模塊,如果給定的是 IP,則會直接像該IP發起調用,如果是服務名的話,會從服務註冊發現模塊中獲取服務名對應的服務提供者列表,然後從中選擇一臺進行調用。

看起來整個調用過程非常簡單,但是實際生產上,特別是流量較大的情況下,如果直接這麼使用開源組件,並且使用默認配置的話,一旦某個環節出了一點問題,可能會直接導致一條線全部都崩潰。這也是很多研發遇到的問題。

那麼如果在我不想改動業務代碼的情況下,我們在這裏又可以做哪些措施來讓系統變得更穩定?

6. 基於開源我們還能多做些什麼

整體的調用圖和上一幅沒有太大變化,這裏我們增加了幾個環節,下面來說一下。

首先,在 feign 和 ribbon 之間,增加了 hystrix 和 fallback。也是分別對應了熔斷和容錯這兩個能力。

熔斷這裏要說的一點是,熔斷本身不是萬能藥,一般來說,熔斷只針對弱依賴,或者直白的說就是不那麼重要的下游服務開啓。

熔斷這個能力是爲了防止雪崩,所謂雪崩就是下游某個服務不可用時,調用該服務的服務調用方也會受到影響從而使得自身資源被喫光,也逐漸變得不可用。慢慢的整個微服務系統都開始變得癱瘓。

額外補充說明,在微服務體系或者說分佈式系統裏,服務半死不活遠比服務整個宕掉難處理的多。

設想一下,假設某個下游服務完全掛了,進程也不存在,此時如果調用者調用該服務,得到的是 connection refuesd 異常,異常會非常快返回,所以就時間上來說和你調用成功沒有太大區別,如果該服務是一個弱依賴服務,那影響更是微乎其微。

但如果下游服務不返回,上游調用者會一直阻塞在那裏,隨着請求的增多,會把線程池,連接池等資源都喫滿,影響其他接口甚至導致整個都不可用。

那在這種場景下,我們需要一種辦法,讓我們達到和下游服務掛掉一樣的快速失敗,那就是熔斷的作用。通過熔斷模塊,我們可以防止整個微服務被某一個半死不活的微服務拖死。同時也提醒大家,要重視快速識別出半死不活的那些機器。

說回容錯,容錯的使用的場景不是非常的頻繁,更多是針對一些不那麼重要的接口返回一些默認的數據,或者配和熔斷等其他治理能力進行搭配使用。

當 ribbon 選定了實例之後,要正式發起調用的之前,可以添加一個重試和超時,加上重試和超時,如果真的能夠配置好這幾個參數,能將系統穩定性提升一大截的。

關於超時,很多人、甚至很多有一定經驗的開發者都喜歡用默認的超時時間。一般來說,默認的超時時間都是分鐘以上,有的框架甚至默認是 0,也就是沒有超時。

這種行爲在生產上十分危險,可能有一臺服務提供者假死在那裏,由於超時時間設置爲 10 分鐘,同時由於 loadbalance 的原因,使得線程慢慢積壓起來,從而導致了自身系統的崩潰。

給出建議——絕對不要使用默認的超時,並且必須合理配置 timeout。超時一般來說主要是 connect timeout 和 read timeout,connect timeout 是指建立連接的超時時間,這個比較簡單,一般同機房 5s 差不多了。

Read timeout 也就是 socketTimeout 需要用戶根據自己下游接口的複雜度來配置,比如響應一般在毫秒級別的,我推薦設置 3s 到 5s 就行了,對一些秒級的 5s 也行,對於比較重要的大查詢,耗時十幾 s 到幾十 s 的我建議設置爲 1min,同時開啓熔斷,以及最好能夠使用異步線程去將這種大請求和業務請求分離。

如果大家覺得這種需要根據每個請求來自行設定 read timeout 的方式太過於麻煩,其實還有一種更方便的方式,就是自適應超時。在網關如果傳入的時候可以設置一個最大的超時時間,每個微服務都會將該時間傳遞下去,這樣可以動態的設置當前請求的超時時間。

說完超時我們再來看一下重試,ribbon 其實自帶了一些配置參數,常用的是 MaxAutoRetries,MaxAutoRetriesNextServer,OkToRetryOnAllOperations,這幾個參數具體的含義和計算公式官網都有,這裏不詳細介紹了。

想提醒大家的是,對於一些冪等的請求,配合上較短的 timeout,合理的設置 1 次到 2 次重試,會讓你整體的微服務系統更加穩定。

上面的所有增強其實不需要用戶修改業務邏輯,基本上都是配置和依賴,但是正確的配置和使用這些開源組件,可以讓你的整個系統變得更加穩定。下面看一些進階場景。

四、穩定調用的學問

1. 更細粒度的降級

介紹了熔斷,hystrix 是服務級別或者接口級別,但生產過程中,都是個別實例出現問題,特別是某一個實例假死或者不返回。

針對這種場景,hyxtrix 的熔斷配置不是很靈活,因爲它是通過整體的錯誤率來進行熔斷的,一般一個實例異常是沒有辦法處罰熔斷。

如果不改代碼,我們有沒有辦法處理這種場景?我們其實可以通過減少 timeout 搭配上重試 1 次繞過該錯誤,如果是一個冪等的服務的話,一個實例壞掉,其實對整個系統不會有太大影響。

但如果是一個非冪等的,或者 put 請求,這裏該怎麼辦?有沒有辦法不讓失敗率升高到 33% ?

TSF 用 resilience4j 重新自己實現了一整套的熔斷,並加入了實例級別熔斷的粒度,用來解決上述場景。

先說一下選型,爲什麼要用 resilience4j 來作爲底座替換 hystrix?原因很簡單,首先 resilience4j 是官方推薦的替代 hystrix 的框架,也更輕巧靈活。

它將容錯、熔斷、艙壁等治理能力獨立,讓用戶可以自行組合,經過壓測,發現單個 resilience4j 熔斷器實例的 qps 可以高達百萬,對應用不會造成額外的負擔,所以這裏選擇基於 resilience4j 來進行二次開發。

熔斷的原理是在發送請求前,根據當前想要訪問的微服務或者接口,獲取到對應的熔斷器狀態。如果是進入 open 狀態,則直接拒絕調用,而在調用請求後,會將成功,失敗的結果更新至對應的熔斷器,如果失敗比例超過了閾值,則打開熔斷器,如果用戶想擴展一些 metrics,比如慢調用等,只要記錄時間,然後傳給 resilience4j 熔斷器即可, resilience4j 默認支持慢調用熔斷。

再看一下實例級別的熔斷,實例級別熔斷和上面兩個維度在實現上有些不同,它更多的是剔除有問題的節點。

具體的時機是在 ribbon從Consul 獲取可用的服務列表後,會增加一步:判定當前訪問的微服務有哪些節點是打開狀態,然後需要將打開狀態的節點從可用列表中剔除,然後再進行 loadbalance,這樣就可以做到及時的將不可用節點剔除,大大降低失敗率。

2. 如何升級應用

我們已經介紹瞭如何讓服務發現和運行時調用變得更穩定,那麼接下去,看看怎麼讓服務能夠平滑的下線和上線。

可能有人會覺得奇怪,上下線能對系統穩定性造成什麼影響?那我們就先來看一下簡單的下線和上線會遇到什麼問題:

首先是下線,一個考慮的比較周到的微服務框架在下線前會發送反註冊請求給 Consul,然後再下線。看起來似乎沒什麼問題,但是從反註冊發送到下線其實間隔很短。

這裏如果用戶用的是剛剛我們加強過的 SDK,也就是將 30s 定時輪訓改爲 watch 的機制後,影響大概在秒級。

具體影響的時間爲從註冊推送到 Consul,以及 Consul 到變更後返回給 watch 的客戶端,然後客戶端執行替換和緩存文件寫入的時間,基本上在幾百 ms 到 1s 左右。假設我們 qps 爲 1000,服務實例列表爲 10 臺,那麼大概會有 100 個請求失敗。

如果用戶用的是原生的 SpringSDK 的話,那麼這裏影響就比較大了,30 秒內會不停的有流量打過去,錯誤率會快速升高。

問題已經清楚,怎麼解決?由於目前 k8S 是主流的容器編排的系統,所以介紹下如何利用 K8S 的一些特性,來做到優雅下線。

先明確這是因爲反註冊和 shutdown 間隔時間太短而導致的異常,如果我們能做到,先反註冊,然後過 40s 再下線,那即使使用的是原生的 SDK,也不會有錯誤,因爲最大 30s 後會更新到新的實例列表。

知道方法後,來看看在不改代碼的情況下怎麼做到優雅下線:

首先可以利用 k8s 自帶的 pre-stophook 能力,這個 hook 是在需要 stop 容器之前進行的一個操作。當 pre-stop hook 執行完畢後才正式執行銷燬容器。在 pre-stophook 裏面進行反註冊,成功後 sleep 35 秒,然後再執行 shutdown。

這裏有個細節,雖然 pre-stophook 裏面進行了反註冊,但是應用還是會繼續發心跳,所以需要在 Consul-access 層屏蔽掉髮送了反註冊請求實例節點的心跳數據。

我們再接着來看看優雅發佈,正常發佈會情況是,k8s 有兩個狀態,分別是 liveness 和 readiness。

默認情況下,容器啓動了就算是 live了,啓動完成後就變成了 ready 了。但大多數的 Spring 應用其實啓動還是耗時間的,有的啓動甚至需要好幾分鐘,如果 k8s 發佈參數設置的不好,可能全部滾動更新後,所有的程序都還沒初始化完畢,這樣直接就導致整個服務全都不可用了。

我們如何避免以上情況?簡單的辦法是評估自己應用啓動的時間,在 k8s 滾動發佈的間隔參數配置的長一點,大於你預估的啓動時間就行。那有沒有更簡單更自動的辦法呢?

通過 k8s 的 readinessprobe,然後在 readinessprobe 中我們向 Consul 發送請求,查詢該實例是否已經註冊到 Consul 上,如果已經在 Consul 上了,則認爲 ready。

因爲一般來說,註冊到 Consul 都已經是啓動的最後一步了,通過這種方式,我們就可以不做任何干預的進行優雅發佈。

從整個優雅發佈和優雅下線可以看到,只是利用了原生 k8s 的 probe 能力,在正確的時間點,與 Consul 一起配合,就能沒有任何異常的讓系統穩定的更新,希望大家在自己的生產上也能用到這一點。

今天分享到此結束,感謝大家的觀看!

五、Q&A

Q: hystrix 相對較重,技術選型的時候爲何選擇了hystrix?

A: hystrix 確實比較重,特別像是線程池隔離,在線程較多的情況下,不管是上下文切換,還是線程本身帶來的影響都是比較大的,所以後續我們在實現自己的熔斷功能時使用了相對輕量級的 Resilience4j 來實現。如果大家有一定動手能力的話,我也推薦大家用 Resilience4j 來定製化開發。因爲 hystrix 對於異步開發框架的新人來說,改造體驗和斷點體驗都不是很友善。

Q: 熔斷屏蔽掉出錯節點後,需要把對應機器下掉或者告警出來嗎?

A: 熔斷其實是一個臨時解決問題的方案,不能說有熔斷就不去做異常的告警和發現,熔斷可以在最短時間內幫助你快速降低錯誤異常率,但是真正要永久性的解決錯誤和異常率還是需要通過比較完整的告警機制以及監控機制快速定位節點並且把它剔除掉。

因爲熔斷從 close 狀態到 open 狀態把出錯的服務器熔斷掉,但是這裏面的參數其實是有時間的,過了這段時間後,它會從 open 狀態變爲 halfopen 狀態,而在 halfopen 狀態再次回到 open 狀態的時候,如果節點還是有問題,就會大量的拋錯。

綜上,如果有能力的話,請務必要把告警和監控機制完善好。

Q:Consul 本身有權限,爲什麼還開發 access 模塊?

A: 如果想對 Consul 做進一步的開發,可以回看本視頻,Access 的功能不僅僅是爲了權限能力,它可以做非常多的事情,包括治理、限流、熔斷和權限認證等,當然也包括需要增加性能優化,比如聚合連接。增加 Access 這一層從功能性到性能上都可以提升穩定性。

Q:java 除了 hystrix、sentinel 還有哪些熔斷實現呢?

A: 熔斷本身其實不是一個很難實現的系統,如果大家感興趣的話可以自己去查一下。不管是 sentinel 還是 Resilience4j,真正核心的數據結構主要就是滑動窗口:sliding window,是用來統計單位時間內失敗率的一個數據結構。當然不管是 sentinel 還是 Resilience4j,它們的性能都非常高。

作者介紹

劉智新,騰訊專家工程師

劉智新,騰訊雲CSIG微服務產品中心專家工程師,6年+中間件研發經驗。目前專注於企業級微服務平臺TSF的研發,包括治理框架、應用生命週期管理、註冊中心、Service Mesh等架構規劃設計。

本文轉載自公衆號雲加社區(ID:QcloudCommunity)。

原文鏈接

日調1000億,騰訊微服務平臺的架構演進

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