字節跳動合并編譯實踐
字節跳動微服務過微的背景
截止 2023 年底,字節跳動內部微服務的數量超過了 30 萬,而且這個數字還在快速的增長當中,每個季度仍然會新增上萬個微服務。伴隨著海量的微服務,微服務過微帶來的編解碼、序列化、網絡和服務治理開銷過大問題也愈加凸顯,在一些性能敏感、QPS 大的的服務上急需優化。于是極致的微服務合并方案合并編譯應運而生。目前公司內采用合并編譯方式合并的服務超過 300w core,取得的 CPU Quota 收益超過 40w core,接口時延根據包大小有 2-15 ms 不等的優化。
合并編譯如何解決微服務過微的問題
合并編譯是將兩個(或多個)微服務,在編譯期間合并為一個二進制,以一個進程的方式運行。如果當前存在 A -> B 這樣一個調用關系,A B 合并之后,將以一個二進制的方式呈現。A 原來通過 RPC 方式調用 B 的邏輯,將轉變為 A 在進程內部通過函數調用 B 實際的處理函數。
流量比例 A : C : D = 8 : 1 : 1 示意圖
合并編譯優勢相比 RPC 調用是非常明顯的。
- 在性能方面:RPC 調用時的「編解碼、服務治理、網絡」的開銷在合并后將完全的「減少到零」
- 在研發效率方面:合并編譯仍然能夠「保留微服務研發的優勢」,在日常開發的時候還是 A 服務的團隊去維護 A, B 服務的團隊去維護 B,只有在上線時才會合并在一起,并不會對研發效率造成影響
- 在靈活性方面:合并編譯能夠做到「靈活地合并與拆分」,如示意圖所示,A 和 B 就合起來了, C 和 D 并不接入合并編譯,仍然以 RPC 的方式去調用真正部署的 B
- 在穩定性方面:合并后的服務的「穩定性也會相應地提高」,合并編譯不再經過網絡也意味著微服務帶來的超時、過載等問題將不復存在。
不過,合并編譯還是有一點劣勢的。首先,在運行時隔離性上,微服務帶來的資源的隔離、故障隔離在合并后將不復存在;第二個點是版本管理,在合并之后,如果要更新 A 進程中依賴的 B 的版本,需要將 A 重新編譯上線。我們也做了大量的工作去減少這些弊端對業務的影響。
合并編譯面臨的挑戰
合并編譯在研發過程中也面臨非常多的挑戰,我們將這些挑戰分成了三大類:基礎挑戰,可優化的點以及理想形態。基礎挑戰是合并編譯必須要解決的問題,否則在上線過程中可能會出現很多問題;而優化項則是能夠讓用戶更友好的使用合并編譯,減少合并編譯對用戶的影響;最后是合并編譯的理想形態,也是目前合并編譯還沒有解決的一些點。
合并編譯面臨的挑戰
在基礎挑戰方面,主要包括四大類:
1.在隔離性上:
a. 依賴隔離:兩個服務依賴了不兼容的依賴版本,該如何解決沖突?
b. 環境變量隔離:兩個服務依賴了相同的環境變量但是不同的值,該如何隔離環境變量?
c. 權限的隔離,合并后的服務如何仍然擁有原本的身份呢?
d. 身份的隔離:合并后的服務如何按照原有的身份進行打點和上報呢?
2.調用轉換:如何自動化地將 RPC 的方式去轉換為 Func call 的方式?
3.易用性上:合并編譯需要一個自動化的工具去自動的完成這一次的合并。
4.穩定性上,合并編譯改造完成后,如何進行第一次的上線,最好還能有一些灰度的邏輯呢?如何保障在后續迭代過程中的穩定性呢?
在可優化項當中,主要包括兩大類:
1.穩定性:合并后該如何測試才能保證合并后的穩定性呢?以及如果線上出現了問題,該如何快速的定位到問題,讓相應的同學快速止損和排查呢?
2.易用性上:
a. 版本管理:下游怎么知道上游用了哪些版本?上游又怎么知道自己該使用下游哪個版本呢?
b. 研發流程:上游有上游的研發流程,下游有下游的研發流程,那合并后的研發流程應該是什么樣子的呢
c. 編譯問題排查:合并后的服務,如果遇到編譯問題用戶就會找上來,如何讓用戶能夠擁有一定的排查能力呢?
d. 本地 Debug:用戶是沒有合并后代碼的,在本地用戶該如何進行斷點調試呢?
以上的這些問題合并編譯都一一的解決了,不過針對最后一個大類理想層面,目前還沒有很好的解決方式。第一類是集中在運行時進程內的隔離性,如何將資源、Panic 進行隔離;第二類是如何讓用戶接受和理解合并編譯的形態,就好像接受和理解微服務一樣。
合并編譯如何解決技術挑戰
依賴隔離
Go 采用 Go Module 的方式進行依賴管理,不同的 import path 代表不同的依賴,比如
import (
"namespaceA/github.com/cloudweGo/kitex"
"namespaceB/github.com/cloudweGo/kitex"
)
代表兩個依賴。同時 Go Module 支持 replace 的方式,將遠端依賴替換到本地目錄當中,并按照路徑進行尋址。比如
replace github.com/cloudweGo/kitex => /tmp/kitex
那么,代碼中引用的 github.com/cloudweGo/kitex/client
會去 /tmp/kitex/client
路徑下尋找對應的代碼。
于是合并編譯利用這兩個特性進行了依賴隔離:首先將每個服務的依賴下載下來分別放到隔離后的目錄內,如下圖所示
之后對不同的服務內的每個 import path 添加相對應的前綴,并使用 replace 將前綴指向對應的本地目錄。
通過這種方式,合并編譯實現了完全的依賴隔離。有了依賴隔離作為基礎,其他的環境變量的隔離、權限的隔離、身份的隔離等等都很容易能夠解決了。
調用轉換
調用轉換
左邊是一個 RPC 方式的調用圖,Client 發起一次調用,需要經過服務治理的中間件、傳輸的元信息和編解碼部分,再通過網絡傳輸到對端, Server 也需要進行一次同樣的一些操作。合并編譯希望做到右邊的這種形式,Client 發起一次調用,它調用的是進程內的 Server 的對應的方法。實現這樣的轉換需要兩步,第一步需要獲得 method 實現;第二步將實現去注入到 Client 當中去。
為了獲得 Server 暴露的接口,合并編譯做了下圖所示的處理。左邊這張圖是一個正常的 Kitex 服務的初始化和啟動,它會執行一些初始化的邏輯,然后初始化并且啟動 Server。在合并編譯場景下,這部分的邏輯變成了右圖。合并編譯將 main 函數變成了一個可導出的內函數,可導出了才可以讓 Client 去調用。第二個合并編譯給這個函數增加了返回值,表示 Server 的元信息。
獲取接口的信息
得益于 Kitex 良好的擴展性,Kitex 將 Client 抽象為了一個接口,只要實現這個 Call 方法,就可以實現一個 Kitex 的 Client,也是得益于這個抽象,使得合并編譯注入 Server 實現非常容易。
type Client interface {
Call(ctx context.Context, method string, request, response interface{}) error
}
一個普通的 RPC Client 的初始化只需要這一次 RPC 的信息就可以了。那針對合并編譯 ServiceInlineClient 的初始化,還需要增加Server 的元信息參數。這個信息就是通過上文對改造后的 main 函數調用獲得的。
serverInfo := server.Main()
kc, err := client.NewServiceInlineClient(serviceInfo(), serverInfo ,options…)
第二步合并編譯需要為 ServiceInlineClient 實現 Call 方法,使得它在 Call 的時候不去走 RPC 的邏輯,而是去走本地調用,在 ServerInfo 里找對應的方法。Kitex 針對合并編譯做了一些特殊的支持,以上的這部分代碼的實現在 CloudweGo Kitex 當中以上代碼,感興趣的小伙伴可以參考 Kitex 中合并編譯部分。
版本管理
合并編譯和 SDK 版本管理的痛點有點相似,比如:
- 下游升級時,上游感知不到,會造成版本的不一致
- 下游并不知道上游依賴的是自己的哪個版本,也就無法告知上游升級
- 版本選擇復雜,上游也不知道這次升級需要選擇下游的哪個版本
于是,合并編譯針對具體的業務場景做了梳理,并與研發流程與發布平臺做了聯動,平臺提供了基礎的能力,減少用戶對合并編譯的學習成本。
針對最終一致性,下游可以在鏡像平臺上配置好上游依賴的默認版本,下次上游上線的時候可以默認帶上去,也不用上游主動去選擇該使用的版本。針對強一致性可以通過一條流水線,同時升級上下游;也可以擁有上游權限的團隊直接去升級上游服務。除此之外,平臺上也會收集版本的元信息,用戶可以很直觀的看到自己依賴了哪些版本,以及自己的哪些版本被依賴了。
修改默認發布的版本
上游選擇下游的版本
服務接入
合并編譯主要解決微服務過微帶來的性能問題,其收益公式如下
DownstreamQuota 指下游服務的資源申請量;MergeRatio 指合并的比例;Codec Ratio 指編解碼的開銷;ServiceGovernaceRatio 指服務治理的開銷。
從收益公式中可以看到,合并編譯應該聚焦于「資源量大、調用關系密切、編解碼開銷大」的服務,才能夠拿到較大的收益。為了能夠快速篩選出適合接入的服務,合并編譯團隊從 Trace 流量表、Quota 資源表出發,對全公司內的服務進行篩選,篩選條件為:從 Server 視角看,來自單一最大上游的流量占總流量的比例超過 30% 或者從 Client 視角看,來自單一最大下游的流量占總流量的比例超過 30%。之后再和 Quota 表做關聯,按照 Client + Server 總 Quota 降序排列,于是就得到了一張公司內大致適合合并的鏈路表。該表是合并的必要條件,還要滿足:
- 非緩存、固定開銷類型的服務:這類型的服務在合并后因為實例數增加會導致開銷增加。
- 容器負載太高的服務:容器負載高的服務本身就不是很穩定,合并可能會加劇。除此之外,內存很高的服務沒法合并,合并是內存直接相加,但是容器規格是有上限的。
- 編解碼大于3%的服務:編解碼大于 3% 合并后比較穩妥的可以看到收益,如果低于 3% 的話服務是很重計算型的服務,不一定適合合并。
案例分析
下面是從鏈路表中篩選出的一對比較適合合并的服務。從 Server Ratio 中 0.962 中可以看出,這個下游 96% 的流量都是來自這一個上游,流量的親和度非常高;同時 Client Quota 和 Server 的 Quota 相差不多,那這一對就是潛在的適合合并的服務。
之后再結合火焰圖上尋找 Kitex 的編解碼開銷,一般來說編解碼開銷在 3% 以上合并是有收益的,開銷在 5% 以上的收益比較大。像下面的這個服務編解碼占到了近10%(包非常大),這樣的服務合并的收益是非常大的。
火焰圖編解碼開銷
結合流量關系表和火焰圖的篩選,這對服務取得了 4w+ 核的收益。
除此之外,除了拿到 CPU 收益,針對時延、SLA 等也拿到了不小的收益,甚至在很多非 CPU 收益的場景,合并編譯繼續發揮它的價值,比如:
- 大上游 + 小下游:防止突發流量導致下游過載,常態預留較多資源又會造成浪費。合并后使得小下游可以使用整個服務的資源。
- 利用合并編譯做 RPC 權限收斂:下游每新增一個上游就要為這個上游添加訪問權限、配置限流等等,而利用合并編譯多身份的能力,用戶添加了一個 proxy 層,并將多個上游與該 proxy 進行合并編譯,大大減少了配置的成本。
合并規模
根據鏈路表中的數據,粗篩公司內部一共有 1.8w 條鏈路可以合并,鏈路總核數約 2.6 億核。抽樣 500 條鏈路,其中能夠合并的服務鏈路條數為 13 條。按照合并后 10% 的收益統計,合并編譯可以帶來的 CPU 收益約為 67w core。
目前,合并編譯采用重點服務點對點跟進的策略,公司內部已經完成合并編譯的 CPU 核數超過 300w core,取得了超過 40w core 的收益,接口時延也有 2-15ms 不等的收益。
總結與展望
合并編譯能夠在字節跳動內部大規模落地,證明了合并編譯這種形態在架構上的可行性。目前,合并編譯推進方式是點對點的,針對的是已有的服務,在降本增效的背景下,如果合并后有性能和成本的收益,則會盡可能的推動業務進行合并。不過,這樣的推進缺乏全局統一的視角,對業務架構的演進幫助不大,且效率相對比較低。未來,我們希望自頂向下的平臺化地推進。
這與團隊內發起的業務域體系構建項目不謀而合。業務域項目針對目前面臨的業務架構混亂、鏈路復雜、架構復雜度高等問題,推出一套完善的平臺和產品,幫助業務完成業務域的自動劃分和分層。業務域項目會借助合并編譯和流量治理等工具和能力,從更高的視角去做架構復雜度治理,包括「鏈路治理」和「過微服務治理」:
- 鏈路治理:對于鏈路過深的場景,可以借助合并編譯完成上下游的合并,降低鏈路深度;未來合并編譯也將探索循環依賴、相互依賴場景下多個微服務合并能力。
- 過微服務治理:對于微服務拆分過細、服務的資源 Quota 低的場景,合并編譯將支持多個 Server 的合并,合并后以一個進程的方式對外提供服務,方便統一進行治理和管控。
可以期待的是,結合合并編譯這一成熟且高效的工具,業務域的架構師在「不修改代碼」的情況下,可以快速、自動化完成不同場景下的微服務合并,「極大降低架構優化和業務改造的成本」,從而縮減低價值服務,沉淀高價值服務,最終形成清晰的業務架構。微服務的合并并非是對微服務的全盤推翻,而是重新對業務架構進行審視和治理,結合當前業務的規模和研發效率對其進行優化,朝著理想架構演進。
Reference
CloudWeGo:https:www.cloudwego.io
Kitex:https://github.com/cloudwego/kitex