Go 生態下的位元組跳動大規模微服務效能優化實踐

語言: CN / TW / HK

Go 是一門很有特色的程式語言,已經被廣泛應用到不少領域,隨著使用場景的發展,一些效能相關的問題也開始逐漸暴露出來。本次分享將以位元組跳動的效能優化工作為例,介紹基於 Go 生態的微服務體系下,分析系統性能、優化不同層次軟體以提升執行效能、提高資源使用效率的一些實踐和經驗,會特別介紹在 Go 語言 SDK 側的一些優化工作。

專案背景


微服務是一種將複雜應用拆分為微小的服務單元,每個服務單元都可以獨立升級甚至替換,從而實現快速交付和迭代的文化。

位元組跳動是對微服務技術使用得非常極致的企業之一:伴隨業務的迅速擴張,微服務以其靈活迭代、高可擴充套件、高度相容的特性,幫助位元組跳動快速建立起一套基礎設施系統,滿足服務水平擴縮容、業務高速發展變化和不同團隊靈活協作的需求。時至今日,位元組跳動的線上微服務型別數量已超過 10 萬。

但作為一家快速發展的企業,位元組特殊的內部業務場景也對微服務落地提出了一些挑戰,如:

  • 大規模:一是叢集規模非常大,二是業務的領域比較廣泛,業務領域涵蓋了短影片、內容推薦、電商等各類場景;
  • 快迭代:一是演進速度快,很多新特性被很快釋出出來,二是新技術演進快,開發者樂於學習使用新技術;
  • 多語言:位元組內部的服務以 Go 語言為主,佔據 55% 以上,同時相容了許多其它語言;位元組早期創業階段的微服務主要是使用 Python 進行編寫,後期逐步轉到 Go 語言。

從程式語言的角度看,Golang 能在位元組內部得到大規模應用,離不開它對於微服務的幾大優勢

  • 簡單易用上手簡單,很多人只需花費一週左右就能開始獨立承接任務;
  • 高併發Go 語言天然適合 I/O 密集場景,支援高併發,能更好地利用多核心 CPU 的能力,很適合編寫包含大量網路通訊的微服務系統;
  • 效能合適Go 語言編譯速度很快,程式啟動也很迅速,同時具有還算不錯的執行時效能。

當然,世上沒有完美的事物。從效能角度來看,微服務也為位元組跳動基礎架構團隊帶來了兩個效能代價:通訊代價,不同服務之間通過網路進行通訊,使用者必須壓縮資料包,將其變成與平臺、語言無關的協議傳送出去,由對方解碼之後使用,因此會造成通訊上的開銷。特別是在 Service Mesh 被大規模推廣和使用後,通訊需要消耗更多的資源;治理負擔,微服務架構是一個松耦合架構,其要求各個微服務自發進行演化生長。如果組織缺乏自上向下的管理,很容易導致微服務野蠻生長,造成治理負擔。

 Go 服務效能分析


叢集效能優化一般有如下思路:收集原始效能資料——建立指標體系——跟蹤監控異常/手動分析——定位效能瓶頸——優化方案。

需要注意的是,只做一次優化是遠遠不夠的,我們更希望將相關最佳實踐做成系統或工具,日常執行下去,在位元組內部,我們的做法是構建統一效能平臺。

 收集原始效能資料

原始資料共有三種來源,一是業務資料,包括 QPS、RT 等;二是系統資料,包括 CPU、記憶體等;三是執行時資料,包括 PProf 和 FuncProf 資料。

其中,PProf 是通過取樣方式,在一秒鐘內預設打 100 個點,如果踩到了一個點就相當於佔了 1% 時間。位元組跳動基礎架構語言團隊在內部的 Go 發行版增加了 FuncProf 的功能,開始執行時進行計時,停止執行時按下暫停,最後將資料合併。下圖展示了資料的流向,我們需要從業務叢集拉取業務資料,同時可能還需要和監控系統、運維繫統進行互動。

 建立指標體系

獲取原始資料之後,我們需要依靠指標體系對資料進行分析和判斷。指標體系能夠幫助我們揭示叢集效能特徵,回答基本問題(比如效能對不對,是否變差)。同時,指標的選擇至關重要,不同的指標選擇會導致完全不同的結論。

位元組跳動基礎架構語言團隊秉承著指標選擇的規範——保證指標的可擴充套件性和可迭代性,弱指標強於沒指標。該指標可能並不足以完全解釋資料,但是能揭示部分問題也比沒有指標強。

當衡量 CPU 時,業界有很多成熟的演算法,比如將 workload 的使用關係和資源掛鉤,這需要該領域的專家協助執行,我們目前採用的方式是單核 QPS。當然,不同型別服務的請求特徵是不一樣的,比如打包傳送影片業務和賬戶查詢業務肯定有完全不同的請求特徵;而 CPU 核心的差別更大,晶片技術一直在高速發展,不同型號的 CPU 單核效能可能相差數倍。

然而我們認為“表達能力偏弱的指標強於沒有指標”。並且在進行比較時,我們會避免絕對值的比較,儘量採用相對值進行比較,從而更充分地利用原始指標。舉一個例子:

上圖顯示了一天內單節點 CPU 的利用率變化情況,變化幅度大,並且波峰和波谷的差距很大。那麼圖中哪個時間段對效能分析是有意義的?我們會更關注 T1 時段,即峰值 CPU 利用率。團隊將峰值的資料採集完之後,會在叢集維度進行一定程度的歸一化處理,利用規模效應磨平單點上的偏差。

圖中可以看到處理結果呈現單核 QPS 趨勢,在實際應用中,這個指標很大程度上能反映系統的效能特徵。當然,我們也在嘗試更多精細化的分析工作,歡迎對這方面感興趣的朋友加入我們團隊共同探索。

 效能追蹤

效能追蹤方法包括自動和手動兩種方法,自動方法是指程式碼主動識別問題,手動方法需要人工操作去觸發。其中,自動發現問題分為兩個維度:單機維度和叢集維度,我們可以在單機和叢集維度上檢查是否存在問題並做出響應。

如下圖所示,位元組內部使用 Agent 在後臺自動檢測單機是否存在效能瓶頸,如果發現問題,它會通知效能平臺及時取樣案發現場資料,由此我們可以在單機維度抓取效能下降的資料。

 定位效能問題

在分析完效能問題之後,我們需要對具體的元件進行修改。我們的思路是為效能平臺使用者提供自頂向下的逐步鑽探的分析流程。

我們在單機收集資料,包括 CPU 利用率、程式碼的 Stack 、Frame 等資訊,然後將它們打散,在不同的維度形成不同的組合並展示。如下圖所示,首先我們在叢集維度展示一個熱力圖。

該熱力圖基於整個業務線的角度,將許多的服務放在一起分析哪條業務線消耗資源最多;同時,我們也會在服務層匯聚一個 profiling 分析;最後我們基於兩個角度在元件層定位問題,一是基於平臺角度去看指標時是一個自底向上不停組合出不同指標的情況;二是使用者在分析時是一個自上而下的鑽探檢視過程。

 優化方案

軟體型別一般劃分為業務軟體和系統軟體。其中,SDK/三方庫屬於業務軟體,基礎庫、語言執行時、容器/OS屬於系統軟體。業務程式碼的特徵是:寫很容易,修改很頻繁,它的優化並不具備普適性;系統軟體的特徵是修改和維護比較費勁,優化具有普適性,可以被推廣到很大範圍,絕大部分業務都可以受益;同時修改業務軟體的收益一般大於修改系統軟體。

位元組內部的優化方案是體系化優化,在單節點中從上到下,對業務層、基礎庫元件、程式語言每個層次進行優化,跨節點優化會涉及合併部署。某個效能優化專案資料顯示,通過我們的優化手段,CPU 資源大約節約了 19%。

 

 Go 服務效能優化


本章節將具體展示位元組內部的 Go 服務效能優化手段和措施,涵蓋了從業務到語言的實踐過程。

業務層優化 

業務層優化面臨的挑戰主要有兩點:

  • 服務間的差異性巨大:比如推送文字服務和推送影片服務的業務程式碼之間存在很大的差異,難以出現通用優化技術;

  • 工具如何更加有效右下圖展示了基本的業務程式碼分析思路,然而事實上大家工作重心不同,並不能要求所有同學都按同一個套路思考;這時候打造一套好用、高效的工具,降低效能分析的心智負擔就很重要了。

關於業務層優化,這裡總結了幾點比較容易獲取收益的優化經驗:

  • 減少複雜度不過度設計,簡單而直接的做法往往會更高效,比如減少網路通訊次數和資料量;

  • 重視編碼規範問題如果能夠在專案前期得到解決,將會帶來更大的收益;

  • 升級元件到“比較新”的版本在控制好穩定性的前提下,新版本的軟體一般會帶來更好的效能,比如升級 Go v1.17 版本對於 calling convention 的優化具有一定的效果。

這裡舉一個業務層優化案例:A/B 測試。這是一種使用者體驗研究方法,被廣泛應用於位元組跳動產品命名、互動設計、推薦演算法、使用者增長、廣告優化和市場活動等各方面決策上。

一開始我們並不知道 A/B 測試是瓶頸,只是效能平臺按照從業務線到元件的方式下鑽,會報告出這個元件消耗大量資源,優化之後可能帶來可觀的收益。

通過分析這個元件的關鍵特徵資料,A/B 測試的引數規模引起了我們注意。下圖展示了在較短時間內某個叢集上 A/B 測試引數個數的變化情況。隨著時間的推移和業務的增長,這個指標發生了巨大變化,同時伴隨效能劣化的趨勢。

在微服務系統中,眾多的微服務都是通過網路松耦合在一起,如果需要將一個 A/B 測試配置傳遞給鏈路上的每個服務,將它放到引數中是一個比較簡便的做法,事實上之前的系統確實也是這麼做的,但是隨著配置資料的增長,這個傳遞變成了效能瓶頸之一。

針對這個問題,我們最後採取的解決方案是短期縮減規模,調整業務系統將 A/B 測試引數進行分割、控制之後,系統達到了 10% 以上的優化效果。中長期來看,優化通訊和系統架構,加強監控和稽核會是更重要的發展方向。

 基礎庫優化

我們認為能夠脫離當前公司運維環境使用的公共程式碼大概率是屬於基礎庫範疇的,位元組跳動將這部分程式碼中的優秀元件獨立成了一個開源專案——gopkghttps://github.com/bytedance/gopkg)。這裡面的程式碼都是經過位元組生產環境的殘酷考驗和反覆驗證,有較高的實用價值。

“庫的設計其實就是語言的設計”,在位元組內部我們還把基礎庫中最常用的優秀元件整合到了語言執行時中,比如各類演算法和資料容器,讓業務同學開箱即用,不引入額外依賴或修改原始碼即可受益。同時,我們也嘗試向上遊開源社群貢獻相關程式碼,讓更多人受益,比如近期我們將排序演算法 PDQSort 貢獻到 Golang 社群,成為 Go1.19 版本的標配。

 語言執行時優化

為了實現更高的效能,位元組跳動基礎架構語言團隊對 Go SDK 進行了定製優化,在相容社群版本的前提下,面向後端服務優化。

一般我們認為 Go SDK 包含兩個部分:介面和實現。介面層優化包含語法、標準庫和一些常見的命令,比如 go build、go tool 等;而實現層一般是使用者不會直接接觸的編譯器、垃圾回收器、標準庫實現等,這部分的改動大部分是對使用者程式碼透明的,使用者不用改程式碼就可以享受收益。

為了達到優化效能的目的,我們的思路是:對介面層只增加不修改;對實現層做有意義的效能改進,並保留切換社群行為的開關。這樣既保持和社群生態極高的相容性,又能對更影響效能的實現邏輯進行高度優化。

記憶體管理優化

我們認為 Go 的記憶體管理面臨的問題之一是過於為 GC 暫停優化(雖然這是它最大的賣點),它為此付出了分配效率、GC 吞吐等代價。其中最容易在微服務上觀察到的問題是:記憶體分配動作佔用過多的 CPU。一些典型服務上大約百分之十幾的 CPU 資源都被用來執行記憶體分配動作,這些動作分散在一次請求處理的各處程式碼中,最終直接拖慢了整體執行效率。

對於 15% 的代價,我們做了一些詳細的分析,發現在位元組的微服務系統上,大部分分配的物件都是小物件,並且很多物件都沒有指標(Go 會將有指標和無指標的物件儲存在不同記憶體區域),所以我們思考有沒有更快的分配思路?

Go 的記憶體分配使用類似 TCMalloc (https://google.github.io/tcmalloc/) 的分配方式,如下圖所示。它的做法是:使用者先去查詢 mcache,它會通過索引把一個 size 取整到一個固定大小,比如將 19 取整到 24,然後查詢 24 對應的 bucket 池, 然後找出一個空 bucket 返回給使用者。這種邏輯涉及到 bucket 的查詢,分配的不同物件可能位於較遠的地址空間,區域性較差。

為了簡化這部分開銷,我們選擇了 Bump-pointer 分配方式,如下圖所示。Bump-pointer 分配的做法非常簡單:使用一個指標 P 指向一段連續的空閒記憶體空間,需要分配 N 個位元組的記憶體時,就把 P 的值返回給使用者,同時執行 P += N 即可。

我們製作了一個特性:GAB(Goroutine-Allocation-Buffer),為每個 Goroutine 保留一塊用於 Bump-pointer 分配的 Buffer,讓堆記憶體分配的請求儘量落到這個 Buffer。為什麼做 G 這層,而不是 M 或 P 層呢?這是經過測試的經驗性結論,G 層效果最好。為了保證相容性,我們把這個 Buffer 直接對映為 TCMalloc 風格管理的一個 bucket 中,因此它與現有 Go 執行時的管理機制完全相容。最後效果上表現為一個 TCmalloc 的 bucket 中匯聚了多個 Bump-pointer 快速分配的物件。

物件的分配只是第一步,如果我們從不回收記憶體,最終還是會 OOM 掛掉。GAB 記憶體的回收仍然是依賴於 Go 執行時自身的標記-清理演算法,如果 bucket 作為一個整體死掉,就可以一次性批量回收大量 GAB 物件,效能很高,微服務的記憶體使用行為很多時候符合分代假說,所以大部分物件都可以輕鬆回收。但是如果 bucket 中有少量活躍物件呢?比如少量請求資料被放到了 cache,這樣正常路徑就無法回收,為此我們製作了 CopyGC 的回收機制,通過移動物件的方式回收空閒空間。

這個特性整體效果比較明顯,如下圖所示,CPU 佔用率降低了 5% ~12%。

編譯器 Beast mode

Go 編譯器雖然編譯速度很快,但是並沒有選擇生成效能最高的程式碼,因此位元組跳動基礎架構語言團隊研發了一個額外的編譯模式,即編譯器 Beast mode。正如隱身戰鬥機會有個額外的 Beast mode 用於火力壓制,編譯器 Beast mode 擁有更多的優化手段,執行效率更高。我們選擇在開發階段使用標準編譯模式,提高開發效率;釋出到線上時使用 Beast mode 編譯生成效能更高的二進位制。

這裡舉一個額外優化的例子:常量傳播優化。比如說要在 Go 中分配一個 slice ,N 被賦值 1 ,如果後面沒有對 N 進行修改,Go 之後會一直將 slice 分配在堆上。當我們進行了常量傳播優化之後,這個常量會直接被各個編譯器吃掉,Go 就可以把它分配到棧上。

這個編譯器優化效果比較明顯,在很多 Benchmark 上都取得了比較好的效果,如下圖所示,time/op 越少越好:

排程器優化

排程器的優化思路比較簡單,由於微服務面向網路通訊,我們將業務程式碼中的 polling 動作和排程器裡面的 polling 動作打通。這裡不展開詳細介紹,如下圖所示:

下圖展示了相關效能資料,藍色部分展示了優化過的效果,可見效果比較理想。

 

 未來展望


關於未來展望,位元組語言團隊未來主要會關注以下三個方向:

  • 極速執行時我們首先會關注如何將加速執行時,比如加入動態程式碼生成能力、Balanced GC 能力,支援 Adaptive Runtime、定製硬體支援;同時歡迎對系統軟體開發感興趣的朋友加入我們一起研發;
  • 生態友好:位元組在開源上比較活躍,位元組內部同學的日常工作與 Github 聯絡十分緊密。位元組內部框架團隊同學開源了一系列微服務產品——CloudWeGohttps://github.com/cloudwego),歡迎大家使用。雖然位元組在開源方面宣傳並不多,但確實是一個不斷實踐開源的組織,之後我們也會將 Go 方面的許多優化開源出來,做一個對生態更友好的組織;
  • 工具實踐其將思路強加給別人,不如將一個好用工具推薦給他們,用工具固化一些最佳實踐,讓更多使用者可以輕鬆參與效能優化。

掃描二維碼,加入位元組跳動基礎架構語言團隊

- END -

近期,位元組跳動宣佈 KubeWharf 專案正式開源。

KubeWharf 是位元組跳動基礎架構團隊在對 Kubernetes 進行了大規模應用和不斷優化增強之後的技術結晶。這是一套以 Kubernetes 為基礎構建的分散式作業系統,由一組雲原生元件構成,專注於提高系統的可擴充套件性、功能性、穩定性、可觀測性、安全性等,以支援大規模多租叢集、在離線混部、儲存和機器學習雲原生化等場景。