作者 | 曹國梁
編輯 | 田曉旭本文整理自曹國梁在趣頭條技術沙龍上發(fā)表的演講《B 站在微服務治理中的探索與實踐》。
大家都知道微服務有兩個痛點,一個是如何拆分微服務,微服務的邊界怎么劃分制定;二是微服務上了規(guī)模之后如何管理,因為只要上了規(guī)模,任何小小的問題都可能會被放大,最后導致雪崩效應。
1微服務化帶來的挑戰(zhàn)
上圖是我們 B 站全鏈路追蹤的一個截圖,這只是其中一個拓撲圖的調用鏈路,就已經(jīng)非常復雜了。可以想象一下,如果是整個公司所有的調用鏈路,會有多么復雜。而這就帶來了微服務治理的復雜性問題:如何保證注冊和發(fā)現(xiàn);如何保證多機房高可用;如何保證低延遲等等。
其次,微服務化以后,服務拆分的比較多,調用鏈也比較長,調用鏈很容易受到一個壞節(jié)點的影響,導致用戶端出現(xiàn)超時的現(xiàn)象。另外,負載不均衡會導致熱點問題,并影響資源調度;單個節(jié)點不可用,如果限流或者熔斷手段做的不好可能有雪崩效應;微服務代理的分布式事務問題和分布式一致性問題,以及編排、日志、鏈路追蹤等問題。
2Go 語言在 B 站開源服務發(fā)現(xiàn)框架 Discovery 的實踐歷程
2015 年到 2017 年,B 站的微服務也是基于 Zookeeper,Zookeeper 是一個 CP 系統(tǒng),可以保證一致性,在網(wǎng)絡分區(qū)的情況下保證可用性。但是我們 CP 系統(tǒng)有一個問題,就是難以支持跨機房。如果機房 1 和機房 2 由于某些不穩(wěn)定的原因發(fā)生網(wǎng)絡斷開,provider B 去往 ZK Follower 的注冊是無法實現(xiàn)的。因為 ZK Follower 所有的請求是強一致,都有同步到 ZK Leader,這時機房 2 就無法注冊了,但其實 Consumer B 和 Provider B 之間的網(wǎng)絡是正常的。
Zookeeper 有一個性能瓶頸,因為強一致系統(tǒng)一般都會緩存全量日志,而 ZK Leader 是單節(jié)點的,所有的寫請求都會到 ZK Leader 上,因此,寫是無法水平擴展的。另外,基于 TCP 的健康檢查也不是最優(yōu)的。
2018 年,我們開始自研了服務發(fā)現(xiàn)框架,目前該框架已經(jīng)在 B 站大規(guī)模使用了。這是一個 AP 的系統(tǒng),Service Provider 注冊以后,所有的注冊、健康檢測、取消注冊都會通過 Discovery Server 異步同步到其它 Discovery Server,然后來保證最終一致。
Discovery Server 一定要滿足網(wǎng)絡分區(qū)時的自我保護,保證健康的服務節(jié)點可用。
客戶端與 Discovery Server 是通過 HTTP Long Polling 來連接的。這種方式開發(fā)比較簡單,且擁有推拉結合的好處,既能及時感知到節(jié)點變更,又方便并發(fā)編程的維護。
上圖中下方的表格是與開源 Eureka 的對比圖,基本上 Eureka 可以做到的,Discovery 也可以做到,Eureka 不能做到的,Discovery 還可以做到。(具體可參考表格)
接下來介紹一下機房的流量調度。
右下角的運維小人感知到機房 A 有問題,可以下發(fā)一個指令,指令可通過 Discovery 節(jié)點在機房 B 擴散,擴散完之后,會在機房 A 隨機挑一個節(jié)點擴散,最后把調度信息發(fā)給 consumer,consumer 自動把大多數(shù)流量切換到 B。
如何保證最終一致?
每一個服務提供者實例都是全球唯一的,可以通過服務 ID+HostName 全球定位到服務實例,所以只要保證每個服務提供者實例達成一致,那么服務發(fā)現(xiàn)就大功告成了。服務提供者實例只要維持一個單調遞增的 dirtyTime,發(fā)給 Discovery 節(jié)點之后,Discovery Server 收到注冊請求或者其它請求,都會把這些請求廣播一遍,在廣播的時候就可以檢查數(shù)據(jù)的一致性。
Discovery 另外一個比較重要的問題就是容災。當發(fā)生網(wǎng)絡分區(qū)和網(wǎng)絡抖動的時候,因為每一個 Discovery 之間會同步復制心跳信息,所以短時間會丟失大量的心跳。例如,每分鐘心跳小于閾值,Discovery 就會感知到,這時就不會剔除一些本該剔除的指令。即使沒有進入非自我保護模式,Discovery 也會隨機逐步剔除,避免一下子剔除導致全部過期。
當只有部分 Discovery 節(jié)點不可用時,因為每一個節(jié)點都是有數(shù)據(jù)的,所以此時只要選擇連接其他正常的 Discovery 節(jié)點獲取數(shù)據(jù)就可以了,并且不可用的節(jié)點重啟之后,會自動拉取正常的節(jié)點,保持最新的同步。
如果全部的節(jié)點都不可用時,客戶端 SDK 會緩存數(shù)據(jù),并拒絕任何實例數(shù)過低的異常變更推送;在宕機期間,服務提供者會一直向 Discovery 節(jié)點發(fā)送心跳請求,直到 Disocvery 節(jié)點重啟恢復正常之后會返回 404,此時服務提供者通過調用 Register 接口重新注冊。
Discovery 框架客戶端基本是零配置的,客戶端 SDK 通過請求 SLB 拿到所有的 Discovery 服務端節(jié)點,并隨機挑選一個節(jié)點作為拉取數(shù)據(jù)的節(jié)點。其次,我們在代碼中做了動態(tài)注冊,也就說每個 client.Dial 都會生成一個 connection,每個 connection 都會消費一個服務,每個服務都對應一個全局唯一的 appID,代碼中通過寫死 appID 來獲取節(jié)點信息并連接。這種 appID 的方式能夠做到動態(tài)訂閱、動態(tài)銷毀,實現(xiàn)零配置。
零配置的一個特點是在客戶端 SDK 中的都是動態(tài)生成的,即所有的訂閱、拉取都要在客戶端中動態(tài)生態(tài)。這時,我們就需要創(chuàng)建一個全局唯一的 Builder。Builder Interface 實現(xiàn)了兩個方法,一個是 Build,另一個是 Scheme。Build 方法會接受參數(shù)——appID,然后返回 Resolver,Resolver 會調用 watch。當有全局事件變更時,都會推送給 Builder,Resolver 從 MailBox 中獲取到相關信息,通過 fetch 實現(xiàn)動態(tài)通知和實時推送。
這些都得益于我們的 Golang CSP 并發(fā)模型,Discovery 基本都是通過這種方式通信,并用這個方法解決并發(fā)編程的問題。和大家分享一下 Discovery 中的 Go 語言最佳實踐。
首先是 errgroup 的使用,當我們啟動了多個 groupteam,其中某個 groupteam 失敗了,那就認為這次并發(fā)請求失敗了。但是使用 errgroup 之后,當某個 groupteam 失敗了之后,return error 后會生成一個新的 context,這樣就可以通過散播 error 的方式來避免資源浪費。
其次是分布式客戶端出錯重試時盡量使用 BackoffRetry。假設此時有 100 個客戶端,當搜索端炸了或者 CPU 滿了,如果客戶端同時一起重試會讓情況變得很糟,大家都會競爭,排隊會越來越嚴重。而使用 BackoffRetry,相當于加了一些隨機量,出錯之后隨機 Sleep,并且增加一個避退的規(guī)則,例如這次是 1 毫秒,下次是 2 毫秒。這樣,可以盡可能的保證重試的成功率。
3RPC 負載均衡算法的演進之路
服務發(fā)現(xiàn)是個 AP 系統(tǒng),可能會出現(xiàn)延遲的情況,你拉取到的節(jié)點可能是一個錯誤節(jié)點,所以我們需要負載均衡來快速剔除它。另外,當出現(xiàn)某個節(jié)點 CPU 比較高或者網(wǎng)絡抖動的情況,也是需要用到負載均衡。
這是我們負載均衡算法的 1.0 版本,比較常見的 Weighted Round Robin。從上圖中可以看到,NodeA 權重:NodeB 權重:NodeC 權重 =3:2:1,也就是說 NodeA 會被調用 3 次,NodeB 會被調用 2 次,NodeC 會被調用 1 次,通過這種方式來做到負載的散布。但是這個版本也存在一些問題,一是無法快速摘除有問題的節(jié)點,二是無法均衡后端負載,三是無法降低總體延遲。
針對以上問題,我們進行了改進——動態(tài)感知的 WRR 算法,利用每次 RPC 請求返回的 Response 夾帶 CPU 使用率,盡可能感知到服務負載,并且每隔一段時間整體調整一次節(jié)點的權重分數(shù)。
但是這個版本也存在一個問題。有一天,我們發(fā)現(xiàn)服務一直在報警,日志一直在報 504 錯誤(即超時重試),但是在監(jiān)控時并沒有發(fā)現(xiàn)問題,CPU 使用率基本都是 90% 左右。在 CPU 沒有滿的情況下,理論上來講只可能出現(xiàn)一兩個超時,不可能出現(xiàn)大量的超時,最后通過查看 WRR 日志,發(fā)現(xiàn)其實是信息滯后和分布式帶來的羊群效應。
從圖上可以看到當土撥鼠收到了金礦信息,它們就會蜂擁而至,跑在前面的可以搶到了金礦,但是跑在后面的可能搶不到,因為信息肯定是延遲的。另外,這些土撥鼠都是一個個獨立的個體,它不是市場經(jīng)濟,市場經(jīng)濟即使信息有延遲,但是也可以通過規(guī)劃、調度來分配資源。
導致出現(xiàn)上文詭異情況的原因,就是負載均衡 2.0 版本會自動刷新權重值,但是在刷新時無法做到完全的實時,再快也不可能超過一個 RTT,都會存在一些信息延遲差。當后臺資源比較稀缺時,遇到網(wǎng)絡抖動時,就可能會把該節(jié)點炸掉,但是在監(jiān)控上面是感覺不到的,因為 CPU 已經(jīng)被平均掉了。
發(fā)現(xiàn)這個問題之后,我們就引入了負載均衡 3.0。
盡可能獲得最新的信息: 使用帶時間衰減的 Exponentially Weighted Moving Average(帶系數(shù)的滑動平均值)實時更新延遲、成功率等信息。
引入 best of two random choices 算法,加入一些隨機性。上圖中,橫軸是信息延遲的時間,縱軸是平均請求響應時間。當橫坐標接近 0 時,best 算法和負載均衡 2.0 差不多,但是當橫坐標接近 40、50 時,這個差距就很明顯了。
引入 infliht 作為參考,平衡壞節(jié)點流量,inflight 越高被調度到的機會越少。
計算權重分數(shù),每次請求來時我們都會更新延遲,并且把之前獲得的時間延遲進行權重的衰減,新獲得的時間提高權重,這樣就實現(xiàn)了滾動更新。
上圖就是 best of two 算法,每次從所有節(jié)點中隨機 rand 一個節(jié)點 A 和 B,之后再經(jīng)過了比較分數(shù)的算法,代碼中的權重值指的是 Discovery 中設置的權重值。
如何測試 RPC 負載均衡?這個測試比較重要,上線的時候稍不注意就可能導致雪崩,所以需要謹慎一些,除了基本的單元測試外,測試代碼還會模擬多客戶端、多服務端場景,并隨機加入網(wǎng)絡抖動、長尾請求、服務器負載突變、請求失敗等等真實場景中可能出現(xiàn)的情況,并在最后打印出結果來判斷新的功能是否有效果。
另外,我們也會在線上的 Debug 日志中加一些分析,例如當前的分數(shù)成功率等等。
上圖是這是我們上線以后 CPU 收斂的效果。
4限流 & 熔斷
微服務中的負載均衡解決的是技術壞節(jié)點的問題,而限流和熔斷主要是防止系統(tǒng)過載,防止系統(tǒng)雪崩。
這是 B 站一開始的熔斷算法,是參考 Hystrix 熔斷算法,當請求失敗比率達到一定閾值之后,熔斷器開啟,并休眠一段時間,這段休眠期過后,熔斷器將處于半開狀態(tài),在此狀態(tài)下將試探性的放過一部分流量,如果這部分流量調用成功后,再次將熔斷器閉合,否則熔斷器繼續(xù)保持開啟并進入下一輪休眠周期。
但這個熔斷算法有一個問題,過于一刀切,會把所有的系統(tǒng)一下子全部關掉,本來當時系統(tǒng)還可以通過 30% 或 20% 的流量,但是現(xiàn)在所有流量都不能通過。在半開狀態(tài)下,試探性放入的流量必須全部成功,但是此時系統(tǒng)已經(jīng)過載了,想要成功很難。因為這些問題,后來我們采用了 Google SRE 彈性熔斷算法,彈性熔斷是根據(jù)成功率進行調整的,當成功率越高的時候,被熔斷的概率就越小,反之亦然。同時,參數(shù)是可以自定義的,通過調整參數(shù)可以使得熔斷算法更加激進或者更加溫和。
單機令牌桶限流是我們一開始就在使用的限流算法,就是到了現(xiàn)在,還有 50% 的服務是在使用這個算法。令牌桶一開始會裝一些 token,每隔幾秒令牌桶中會收到新的 token,當攔截器從令牌桶中拿 token 的時候,如果可以拿到就接著放行,如果拿不到就丟棄掉。
這個算法的問題是只針對局部服務端的限流,無法掌控全局資源,而且令牌桶的容量以及放 token 的速率無法很好的評估,因為系統(tǒng)負載一直在變化,如果系統(tǒng)因為某些原因進行了縮容和擴容,還需要人為手動去修改,運維成本比較大。另外,令牌桶是沒有優(yōu)先級的,所以無法讓重要的請求先通過。
這是我們基于 BBR 算法開發(fā)的一個自適應限流,BBR 算法就是一個 TCP 的擁塞控制,與微服務中的限流也有一定的相似之處。自適應限流,基于 CPUIOPS 作為啟發(fā)值,通過 BBR 算法來決定系統(tǒng)的最大承載量,適應零配置限流算法:cpu > 800 AND InFlight > (maxPass x minRtt x windows / 1000) 。
為什么要用 CPUIOPS 作為啟發(fā)值呢?因為自適應限流與 TCP 擁塞控制還存在不同之處,TCP 中客戶端可以控制發(fā)送率,從而探測到 maxPass,但是 RPC 線上無法控制流量的速率,所以必須以 CPU 作為標準,當 CPU 快滿載的時候再開啟,這時我們認為之前探測到的 maxPass 已經(jīng)接近了系統(tǒng)的瓶頸,乘以 minRtt 就可以得到 InFlight。
除了自適應限流,我們還做了 Codel 隊列,傳統(tǒng)的隊列都是先進先出,但是我們發(fā)現(xiàn)微服務可能不太適合這種做法,這是因為微服務會有超時,肯定不可能無限期的等下去,可能你的 SLP 已經(jīng)設置了 800 毫秒的超時,如果這時放行的是一個老的請求,該請求的成功率就會變低,因為它可能已經(jīng)排隊了好長時間。
所以這時我們需要一個基于處理時間丟棄的隊列,當系統(tǒng)處于高負載的時候,實行后進先出的策略,也就是說要主動丟棄排隊久的請求,并讓新的請求直接通過,利用這個隊列來彌補之前算法中的緩沖問題,吸收突增的流量。
這是自適應無限流的效果,藍色是請求進來的 QPS 量,綠色是真正通過的 QPS 量,從圖中可以看到,當 CPU 達到百分百時,請求通過已經(jīng)雪崩了。
這是自適應有限流的效果,可以看到即使藍線一直在增,但綠線通過的量也沒有受到影響,還是保持著一個比較平穩(wěn)的通過率,可能因為拒絕請求的成本導致綠線稍微有些偏低,但整體影響不大。
5回顧與展望
回顧一下前文,Go 語言天然支持并發(fā)編程,CSP 模型滿足大部分的并發(fā)場景,Discovery 就是大量應用了這種思想;貫徹組件化思想,Go 的接口設計剛好夠用;Go 語?的程序開發(fā)需要在代碼可讀性與性能之間做好平衡取舍,應?程序并發(fā)模型要在控制之內。對于未來的規(guī)劃,我們主要有 5 個小方向:
Discovery 多機房自動化流量調度(全局視角)
Discovery 實現(xiàn) Merkle Tree 結構 & 支持 Gossip 協(xié)議
RPC 負載均衡冷啟動預熱
具有全局視角的分布式限流方案
RPC 請求優(yōu)先級隊列
作者介紹
曹國梁,bilibili 主站技術中心高級研發(fā)工程師。