每日最新頭條.有趣資訊

B站在微服務治理中的探索與實踐

作者 | 曹國梁

編輯 | 田曉旭

本文整理自曹國梁在趣頭條技術沙龍上發表的演講《B 站在微服務治理中的探索與實踐》。

大家都知道微服務有兩個痛點,一個是如何拆分微服務,微服務的邊界怎麽劃分制定;二是微服務上了規模之後如何管理,因為只要上了規模,任何小小的問題都可能會被放大,最後導致雪崩效應。

1

微服務化帶來的挑戰

上圖是我們 B 站全鏈路追蹤的一個截圖,這只是其中一個拓撲圖的調用鏈路,就已經非常複雜了。可以想象一下,如果是整個公司所有的調用鏈路,會有多麽複雜。而這就帶來了微服務治理的複雜性問題:如何保證注冊和發現;如何保證多機房高可用;如何保證低延遲等等。

其次,微服務化以後,服務拆分的比較多,調用鏈也比較長,調用鏈很容易受到一個壞節點的影響,導致用戶端出現超時的現象。另外,負載不均衡會導致熱點問題,並影響資源調度;單個節點不可用,如果限流或者熔斷手段做的不好可能有雪崩效應;微服務代理的分布式事務問題和分布式一致性問題,以及編排、日誌、鏈路追蹤等問題。

2

Go 語言在 B 站開源服務發現框架 Discovery 的實踐歷程

2015 年到 2017 年,B 站的微服務也是基於 Zookeeper,Zookeeper 是一個 CP 系統,可以保證一致性,在網絡分區的情況下保證可用性。但是我們 CP 系統有一個問題,就是難以支持跨機房。如果機房 1 和機房 2 由於某些不穩定的原因發生網絡斷開,provider B 去往 ZK Follower 的注冊是無法實現的。因為 ZK Follower 所有的請求是強一致,都有同步到 ZK Leader,這時機房 2 就無法注冊了,但其實 Consumer B 和 Provider B 之間的網絡是正常的。

Zookeeper 有一個性能瓶頸,因為強一致系統一般都會緩存全量日誌,而 ZK Leader 是單節點的,所有的寫請求都會到 ZK Leader 上,因此,寫是無法水準擴展的。另外,基於 TCP 的健康檢查也不是最優的。

2018 年,我們開始自研了服務發現框架,目前該框架已經在 B 站大規模使用了。這是一個 AP 的系統,Service Provider 注冊以後,所有的注冊、健康檢測、取消注冊都會通過 Discovery Server 異步同步到其它 Discovery Server,然後來保證最終一致。

Discovery Server 一定要滿足網絡分區時的自我保護,保證健康的服務節點可用。

客戶端與 Discovery Server 是通過 HTTP Long Polling 來連接的。這種方式開發比較簡單,且擁有推拉結合的好處,既能及時感知到節點變更,又方便並發編程的維護。

上圖中下方的表格是與開源 Eureka 的對比圖,基本上 Eureka 可以做到的,Discovery 也可以做到,Eureka 不能做到的,Discovery 還可以做到。(具體可參考表格)

接下來介紹一下機房的流量調度。

右下角的運維小人感知到機房 A 有問題,可以下發一個指令,指令可通過 Discovery 節點在機房 B 擴散,擴散完之後,會在機房 A 隨機挑一個節點擴散,最後把調度信息發給 consumer,consumer 自動把大多數流量切換到 B。

如何保證最終一致?

每一個服務提供者實例都是全球唯一的,可以通過服務 ID+HostName 全球定位到服務實例,所以只要保證每個服務提供者實例達成一致,那麽服務發現就大功告成了。服務提供者實例只要維持一個單調遞增的 dirtyTime,發給 Discovery 節點之後,Discovery Server 收到注冊請求或者其它請求,都會把這些請求廣播一遍,在廣播的時候就可以檢查數據的一致性。

Discovery 另外一個比較重要的問題就是容災。當發生網絡分區和網絡抖動的時候,因為每一個 Discovery 之間會同步複製心跳信息,所以短時間會丟失大量的心跳。例如,每分鐘心跳小於閾值,Discovery 就會感知到,這時就不會剔除一些本該剔除的指令。即使沒有進入非自我保護模式,Discovery 也會隨機逐步剔除,避免一下子剔除導致全部過期。

當只有部分 Discovery 節點不可用時,因為每一個節點都是有數據的,所以此時只要選擇連接其他正常的 Discovery 節點獲取數據就可以了,並且不可用的節點重啟之後,會自動拉取正常的節點,保持最新的同步。

如果全部的節點都不可用時,客戶端 SDK 會緩存數據,並拒絕任何實例數過低的異常變更推送;在宕機期間,服務提供者會一直向 Discovery 節點發送心跳請求,直到 Disocvery 節點重啟恢復正常之後會返回 404,此時服務提供者通過調用 Register 接口重新注冊。

Discovery 框架客戶端基本是零配置的,客戶端 SDK 通過請求 SLB 拿到所有的 Discovery 服務端節點,並隨機挑選一個節點作為拉取數據的節點。其次,我們在代碼中做了動態注冊,也就說每個 client.Dial 都會生成一個 connection,每個 connection 都會消費一個服務,每個服務都對應一個全局唯一的 appID,代碼中通過寫死 appID 來獲取節點信息並連接。這種 appID 的方式能夠做到動態訂閱、動態銷毀,實現零配置。

零配置的一個特點是在客戶端 SDK 中的都是動態生成的,即所有的訂閱、拉取都要在客戶端中動態生態。這時,我們就需要創建一個全局唯一的 Builder。Builder Interface 實現了兩個方法,一個是 Build,另一個是 Scheme。Build 方法會接受參數——appID,然後返回 Resolver,Resolver 會調用 watch。當有全局事件變更時,都會推送給 Builder,Resolver 從 MailBox 中獲取到相關信息,通過 fetch 實現動態通知和實時推送。

這些都得益於我們的 Golang CSP 並發模型,Discovery 基本都是通過這種方式通信,並用這個方法解決並發編程的問題。和大家分享一下 Discovery 中的 Go 語言最佳實踐。

首先是 errgroup 的使用,當我們啟動了多個 groupteam,其中某個 groupteam 失敗了,那就認為這次並發請求失敗了。但是使用 errgroup 之後,當某個 groupteam 失敗了之後,return error 後會生成一個新的 context,這樣就可以通過散播 error 的方式來避免資源浪費。

其次是分布式客戶端出錯重試時盡量使用 BackoffRetry。假設此時有 100 個客戶端,當搜索端炸了或者 CPU 滿了,如果客戶端同時一起重試會讓情況變得很糟,大家都會競爭,排隊會越來越嚴重。而使用 BackoffRetry,相當於加了一些隨機量,出錯之後隨機 Sleep,並且增加一個避退的規則,例如這次是 1 毫秒,下次是 2 毫秒。這樣,可以盡可能的保證重試的成功率。

3

RPC 負載均衡算法的演進之路

服務發現是個 AP 系統,可能會出現延遲的情況,你拉取到的節點可能是一個錯誤節點,所以我們需要負載均衡來快速剔除它。另外,當出現某個節點 CPU 比較高或者網絡抖動的情況,也是需要用到負載均衡。

這是我們負載均衡算法的 1.0 版本,比較常見的 Weighted Round Robin。從上圖中可以看到,NodeA 權重:NodeB 權重:NodeC 權重 =3:2:1,也就是說 NodeA 會被調用 3 次,NodeB 會被調用 2 次,NodeC 會被調用 1 次,通過這種方式來做到負載的散布。但是這個版本也存在一些問題,一是無法快速摘除有問題的節點,二是無法均衡後端負載,三是無法降低總體延遲。

針對以上問題,我們進行了改進——動態感知的 WRR 算法,利用每次 RPC 請求返回的 Response 夾帶 CPU 使用率,盡可能感知到服務負載,並且每隔一段時間整體調整一次節點的權重分數。

但是這個版本也存在一個問題。有一天,我們發現服務一直在報警,日誌一直在報 504 錯誤(即超時重試),但是在監控時並沒有發現問題,CPU 使用率基本都是 90% 左右。在 CPU 沒有滿的情況下,理論上來講只可能出現一兩個超時,不可能出現大量的超時,最後通過查看 WRR 日誌,發現其實是信息滯後和分布式帶來的羊群效應。

從圖上可以看到當土撥鼠收到了金礦信息,它們就會蜂擁而至,跑在前面的可以搶到了金礦,但是跑在後面的可能搶不到,因為信息肯定是延遲的。另外,這些土撥鼠都是一個個獨立的個體,它不是市場經濟,市場經濟即使信息有延遲,但是也可以通過規劃、調度來分配資源。

導致出現上文詭異情況的原因,就是負載均衡 2.0 版本會自動刷新權重值,但是在刷新時無法做到完全的實時,再快也不可能超過一個 RTT,都會存在一些信息延遲差。當後台資源比較稀缺時,遇到網絡抖動時,就可能會把該節點炸掉,但是在監控上面是感覺不到的,因為 CPU 已經被平均掉了。

發現這個問題之後,我們就引入了負載均衡 3.0。

盡可能獲得最新的信息: 使用帶時間衰減的 Exponentially Weighted Moving Average(帶系數的滑動平均值)實時更新延遲、成功率等信息。

引入 best of two random choices 算法,加入一些隨機性。上圖中,橫軸是信息延遲的時間,縱軸是平均請求響應時間。當橫坐標接近 0 時,best 算法和負載均衡 2.0 差不多,但是當橫坐標接近 40、50 時,這個差距就很明顯了。

引入 infliht 作為參考,平衡壞節點流量,inflight 越高被調度到的機會越少。

計算權重分數,每次請求來時我們都會更新延遲,並且把之前獲得的時間延遲進行權重的衰減,新獲得的時間提高權重,這樣就實現了滾動更新。

上圖就是 best of two 算法,每次從所有節點中隨機 rand 一個節點 A 和 B,之後再經過了比較分數的算法,代碼中的權重值指的是 Discovery 中設置的權重值。

如何測試 RPC 負載均衡?這個測試比較重要,上線的時候稍不注意就可能導致雪崩,所以需要謹慎一些,除了基本的單元測試外,測試代碼還會模擬多客戶端、多服務端場景,並隨機加入網絡抖動、長尾請求、伺服器負載突變、請求失敗等等真實場景中可能出現的情況,並在最後列印出結果來判斷新的功能是否有效果。

另外,我們也會在線上的 Debug 日誌中加一些分析,例如當前的分數成功率等等。

上圖是這是我們上線以後 CPU 收斂的效果。

4

限流 & 熔斷

微服務中的負載均衡解決的是技術壞節點的問題,而限流和熔斷主要是防止系統過載,防止系統雪崩。

這是 B 站一開始的熔斷算法,是參考 Hystrix 熔斷算法,當請求失敗比率達到一定閾值之後,熔斷器開啟,並休眠一段時間,這段休眠期過後,熔斷器將處於半開狀態,在此狀態下將試探性的放過一部分流量,如果這部分流量調用成功後,再次將熔斷器閉合,否則熔斷器繼續保持開啟並進入下一輪休眠周期。

但這個熔斷算法有一個問題,過於一刀切,會把所有的系統一下子全部關掉,本來當時系統還可以通過 30% 或 20% 的流量,但是現在所有流量都不能通過。在半開狀態下,試探性放入的流量必須全部成功,但是此時系統已經過載了,想要成功很難。因為這些問題,後來我們採用了 Google SRE 彈性熔斷算法,彈性熔斷是根據成功率進行調整的,當成功率越高的時候,被熔斷的概率就越小,反之亦然。同時,參數是可以自定義的,通過調整參數可以使得熔斷算法更加激進或者更加溫和。

單機令牌桶限流是我們一開始就在使用的限流算法,就是到了現在,還有 50% 的服務是在使用這個算法。令牌桶一開始會裝一些 token,每隔幾秒令牌桶中會收到新的 token,當攔截器從令牌桶中拿 token 的時候,如果可以拿到就接著放行,如果拿不到就丟棄掉。

這個算法的問題是只針對局部服務端的限流,無法掌控全局資源,而且令牌桶的容量以及放 token 的速率無法很好的評估,因為系統負載一直在變化,如果系統因為某些原因進行了縮容和擴容,還需要人為手動去修改,運維成本比較大。另外,令牌桶是沒有優先級的,所以無法讓重要的請求先通過。

這是我們基於 BBR 算法開發的一個自適應限流,BBR 算法就是一個 TCP 的擁塞控制,與微服務中的限流也有一定的相似之處。自適應限流,基於 CPUIOPS 作為啟發值,通過 BBR 算法來決定系統的最大承載量,適應零配置限流算法:cpu > 800 AND InFlight > (maxPass x minRtt x windows / 1000) 。

為什麽要用 CPUIOPS 作為啟發值呢?因為自適應限流與 TCP 擁塞控制還存在不同之處,TCP 中客戶端可以控制發送率,從而探測到 maxPass,但是 RPC 線上無法控制流量的速率,所以必須以 CPU 作為標準,當 CPU 快滿載的時候再開啟,這時我們認為之前探測到的 maxPass 已經接近了系統的瓶頸,乘以 minRtt 就可以得到 InFlight。

除了自適應限流,我們還做了 Codel 隊列,傳統的隊列都是先進先出,但是我們發現微服務可能不太適合這種做法,這是因為微服務會有超時,肯定不可能無限期的等下去,可能你的 SLP 已經設置了 800 毫秒的超時,如果這時放行的是一個老的請求,該請求的成功率就會變低,因為它可能已經排隊了好長時間。

所以這時我們需要一個基於處理時間丟棄的隊列,當系統處於高負載的時候,實行後進先出的策略,也就是說要主動丟棄排隊久的請求,並讓新的請求直接通過,利用這個隊列來彌補之前算法中的緩衝問題,吸收突增的流量。

這是自適應無限流的效果,藍色是請求進來的 QPS 量,綠色是真正通過的 QPS 量,從圖中可以看到,當 CPU 達到百分百時,請求通過已經雪崩了。

這是自適應有限流的效果,可以看到即使藍線一直在增,但綠線通過的量也沒有受到影響,還是保持著一個比較平穩的通過率,可能因為拒絕請求的成本導致綠線稍微有些偏低,但整體影響不大。

5

回顧與展望

回顧一下前文,Go 語言天然支持並發編程,CSP 模型滿足大部分的並發場景,Discovery 就是大量應用了這種思想;貫徹組件化思想,Go 的接口設計剛好夠用;Go 語⾔的程序開發需要在代碼可讀性與性能之間做好平衡取捨,應⽤程序並發模型要在控制之內。對於未來的規劃,我們主要有 5 個小方向:

Discovery 多機房自動化流量調度(全局視角)

Discovery 實現 Merkle Tree 結構 & 支持 Gossip 協議

RPC 負載均衡冷啟動預熱

具有全局視角的分布式限流方案

RPC 請求優先級隊列

作者介紹

曹國梁,bilibili 主站技術中心高級研發工程師。

點個在看少個 bug

獲得更多的PTT最新消息
按讚加入粉絲團