微服務一直以來是服務治理的基本盤之一,落地到云原生上,往往是每個 K8s pods 部署一個服務,獨立迭代、獨立運維。
但是在快速部署的時候,有時候,我們可能需要一些宏服務的優勢。有沒有一種方法,能夠 “既要又要” 呢?本文基于 tRPC-Go 服務,提出并最終實踐了一種經驗證可行的方法。
一、微服務的優劣
微服務是云原生的大潮流,它的優勢非常明顯:
- 微服務大大降低了模塊間的耦合。當某個模塊 / 微服務需要變更時,只需要調整這個微服務即可,其他服務無感知;
- 微服務化使得模塊的更新能夠平滑過渡,避免了停機更新的問題,也適合大型團隊或多個團隊間合作構建;
- 微服務模塊的輸入 / 輸出定義很明確,非常適合融合 DDD 理念進行設計;
- 問題排查時,能夠快速定位出現問題的模塊,對運維也很友好。
然而微服務也存在劣勢:
- 當系統趨向復雜時,隨著微服務的拆分、功能的繁雜和細化,微服務越來越多,一窺系統全貌的難度越來越大;
- 模塊間通信通過 RPC 實現,RPC 帶來了時間和網絡流量的開銷;
- 依賴于完備的服務治理體系,對小團隊而言,部署成本較高;
- 多租戶隔離部署時,運維難度也成倍增加。
二、遇到的問題
我們是心悅俱樂部首頁 Feeds 流推薦系統的開發團隊。但我們推薦系統也接入了其他業務,比如我們在接入游戲知幾項目的一個功能后,全量發布前的壓測中發現 CPU 開銷大到難以接受。
1.分析
我們的系統是簡單按照 “業務 → 分流 → 重排 → 精排 → 召回” 的推薦系統微服務化部署,沒有做編排化:
觀察壓測數據,我們會發現,在分流層前后的服務,網絡開銷非常大:
分流服務是推薦系統的總入口,它沒有很強的業務屬性,而是在整個推薦系統的前面、在業務數據的基礎上,加入 A/B Test 參數,供整個推薦系統使用。所以它對于業務負載基本是透傳的。
很明顯,業務服務發給推薦系統的數據流量非常大,而作為透明傳輸業務數據的分流服務,入參需要反序列化,出參需要重新序列化,這些都是無謂的算力消耗。
從分流服務的火焰圖上也可見一斑——作為主要邏輯的查詢實驗參數,僅占了不到 10% 的 CPU,剩下的 CPU 都花在 gc、序列化反序列化、RPC 上面:
三、解決方案
從代碼上看,占流量大頭的數據結構,在整個調用鏈路上都是一致的,我們自然想到,省去網絡開銷,直接在內存里存取該多好啊。
其他內部團隊其實也曾經提出一個 “單體大應用融合落地方案”,給了我們很大啟發。不過,文檔里面只是提出了將所有的微服務合并在一個 pod 中進行部署,服務間調用依然是 RPC 而不是內存調用。
實際上我們觀察一下 tRPC 的 RPC 調用方法,可以看到所有的 RPC 調用,對 Go 業務代碼來看是以一個 Go interface 的形式給出的;而實現方實現對外提供服務的方式,從業務層面也只是實現相應的 server interface 就可以。也就是說,服務的 client 端和 server 端,看到和實現的,都只是普通的 Go 函數。在此思路上,我們團隊的同學在該文檔的基礎上,提出了一個將 RPC "mock" 成本地函數調用的方案,并由我落地驗證了。
本文旨在向讀者詳細說明基于 tRPC 的微服務單體化方案的一種實現方法。代碼改造還是有必要的,但我們的目標是盡可能減少代碼改造量,避免入侵業務。
1.RPC 背景
以我們的重排服務為例,重排服務需要實現這樣的一個 PB:
service FeedsRerank {
rpc GetFeedList (GetFeedRequest) returns (GetFeedReply) {}
}
通過 tRPC 命令行工具 build 之后,會生成一個xxx.trpc.go文件,其中包含 service 接口:
type FeedsRerankService interface {
GetFeedList(ctx context.Context, req *GetFeedRequest) (*GetFeedReply, error)
}
作為服務端,需要實現這個接口,并在 mAIn 函數中調用 RegisterFeedsRerankService 注冊實現, tRPC 會自動對接框架和代碼實現。
同時還會生成另外一個 client 接口:
type FeedsRerankClientProxy interface {
GetFeedList(ctx context.Context, req *GetFeedRequest, opts ...client.Option) (*GetFeedReply, error)
}
一般而言,任意一個 client 要調用重排服務的話,只需要 client := pb.NewFeedsRerankClientProxy(),然后就可以直接調用 GetFeedList 方法了,tRPC 幫調用方隱藏了底層 RPC 細節。對調用方而言,這就只是一個函數而已。對,函數!!!
2.代碼改造
1)Client 側
我們的思路是:作為 rerank 這個微服務,要將自己的入口映射到某處;而 client 方不要自行 new 下游的 proxy,而是從這個地方統一取(我們把這個叫做 proxy API),這樣我們就可以實現了。用 Go 的語言來描述, 調用方看到的只是一個 interface, 那我們就在內存把被調用方的代碼按照這個 interface 進行實現, 然后想辦法讓 client 端直接用上這個實現,就可以了!
考慮到絕大部分的 trpc proxy 都只是使用默認參數進行初始化即開箱即用,因此我們就將這些都統一收攏起來,構建了一個獲取各種 client proxy 的 repo 倉庫(比如就簡單命名為 "api"),clent 方從這個倉庫的 getter 函數中獲取自己需要的 client,如:
rerank := api.FeedsRerank()
rsp, err := rerank.GetFeedList(ctx, req)
// .....
2)Server 側
Server 是提供服務的一側,每個微服務,首先要把自己的業務代碼完全抽出來,不要放在 main 包中——這個改造并不難。各微服務的業務邏輯,可以抽取出來稱為 service 包,對外暴露一個 Register 函數,這個函數的入參中包含 trpc-go/service.Server 類型,用于調用 tRPC 服務注冊函數,如重排服務:
pb.RegisterFeedsRerankService(server, rerankImpl)
這是原本就有的常規操作。但是除此之外,還需要調用前文的 proxy API,將自己的實現 mock 一下。需要注意的是,tRPC 的 client proxy 函數參數,相比 server 側實現的方法,多了一個 opts ...client.Option 參數。不過絕大多數情況下,我們忽略這些參數就好了。
還是以重排為例,簡單用以下代碼 mock 一下自身:
type rerankProxy struct {
impl *rerankImpl
}
func (r *rerankProxy) GetFeedList(
ctx context.Context, req *pb.GetFeedRequest, opts ...client.Option,
) (*pb.GetFeedReply, error) {
rsp := &pb.GetFeedRequest{}
err := r.impl.GetFeedList(req, rsp)
return rsp, err
}
func (impl *rerankImpl) mockProxy() {
r := &rerankProxy{impl: impl}
proxyAPI.RegisterFeedsRerank(p)
}
可以看到, 除了通過 rerankImpl 類型實現了作為 server 端的 FeedsRerankService 接口之外, 也通過 rerankProxy 類型實現了 client 端的 FeedsRerankClientProxy 接口。這樣,當上游調用時, 統一從 proxy API 中獲取 proxy 接口實現, 在微服務場景下,那么就是一個正常的 RPC 調用;但是在單體場景下,不知不覺地就只是一個內存的調用了。
3)main 包
我們在原有邏輯中,每一個微服務的邏輯都寫在 main 包中。支持單體化的改造之后,每一個微服務的邏輯都應挪到一個非 main 包中,并且微服務依賴的各種組件盡量使用注入,而不是由微服務內部初始化。包括微服務所依賴的 client proxy 接口。
4)Proxy API 實現
前文提到的 Proxy API 的實現原理很簡單,各 client proxy 只需要默認調用 NewXxx 函數初始化即可(比如對應前文的 NewFeedsRerankClientProxy),得益于 tRPC 的懶初始化機制,這些 Proxy 創建了之后,只要不去調用它,那么即便配置里不包含相關的 client 配置,就不會報錯。因此,雖然在 Proxy API 中初始化了多個 Proxy,也不會對具體到某個微服務造成影響。
至于 mock 動作,則通過 RegisterXxxx 函數(比如前文的 RegisterFeedsRerank)實現。具體落到細節處,也只不過是一個個的私有成員變量而已。
Proxy API 的代碼大致框架如下:
package proxyapi
type API interface {
FeedsRerank() pb.NewFeedsRerankClientProxy
RegisterFeedsRerank(p pb.NewFeedsRerankClientProxy)
}
func DefaultAPI() API {
return defaultAPIImpl
}
type apiImpl struct {
internalFeedsRerankClientProxy pb.FeedsRerankClientProxy
internalXxxxClientProxy pb.XxxxClientProxy // 作為實例, 其他的微服務模式類似, 下同
// ...
}
var _ API = (*apiImpl)(nil)
var defaultAPIImpl = new()
func new() *apiImpl {
return &apiImpl{
internalFeedsRerankClientProxy: pb.NewFeedsRerankClientProxy(), // trpc 的默認 client 初始化邏輯
internalXxxxClientProxy: pb.NewXxxxClientProxy(),
// ...
}
}
func (a *apiImpl) FeedsRerank() pb.NewFeedsRerankClientProxy {
return a.internalFeedsRerankClientProxy
}
func (a *apiImpl) RegisterFeedsRerank(p pb.NewFeedsRerankClientProxy) {
if p != nil {
a.internalFeedsRerankClientProxy = p
}
}
// ...
上面的重復邏輯挺多,為了減少無意義的重復代碼開發,我們在代碼中編寫了 shell 腳本,并且通過 go generate 來生成上述代碼。
四、部署改造
按照前文所述,我們用一個單體大應用,包含了五個微服務。那么我們在部署的時候,要如何配置呢?
1.服務配置
首先,我們要決定這個單體應用對外暴露的微服務接口有哪些。如果你需要暴露多個微服務入口,那么就需要在啟動時傳入的 trpc_go.yaml 中配置對應的多個微服務注冊和監聽地址。
我們的場景比較簡單,因為整個推薦系統是一個單鏈式調用,所以我們只需要對外暴露業務層的服務即可。注冊也直接注冊到原有的業務層對應的北極星節點上。
那么剩下的幾個微服務呢?每一個微服務可是都調用了 tRPC 的 RegisterXxxx 函數哦?請讀者放心,tRPC register 的時候,如果查不到對應的配置入口,那么 tRPC 也只是什么都不做而已,不會導致進程的 panic。
2.配置配置
在啟動時傳入的 trpc_go.yaml 文件中,我們還需要添加各微服務所需要的配置入口。這個時候,我們就需要將每一個微服務所需的所有配置,都配置上。需要注意的是,如果之前不同的微服務采用了同樣的配置名,卻實現了不同的功能,那么在代碼改造的時候需要修改一下,要不然在此處會發生沖突。
五、收益
1.降本增效
進行單體化改造之前,推薦系統五個服務,在我們預定的容量下,預估需要接近 18,000 核。經過單體優化之后,在沒有修改任何邏輯的前提下,就將這個數字降到 7000,優化掉了足有 61%。可見 RPC 給我們系統帶來的開銷有多大。
此外我們后續又做了不少算法和業務層面的優化,又降到了 1000 核的水平,主要是緩存優化、前置計算和閑時算力的優化。
該方案雖然實現了一個單體化的大服務,但是完全不妨礙其他租戶的業務采用微服務化的部署。可以說,我們在開發階段依然是用微服務模式開發,并且在不同租戶下采用了不同的部署模式。可謂是在低改造量前提下實現了 “既要又要”。
2.擴展思考
當然,單體化之后的服務,在運維層面自然會帶來宏服務的缺點,比如說運維困難,模塊迭代不靈活等等。這個時候就需要我們去權衡利弊、綜合各項因素之后,再做出決策了。
本文所實踐的方法,其實對于其他 Go 語言框架也都是通用的,包括且不限于 Gin、gRPC。只要開發者在進行微服務開發的時候,遵循以下原則,那么微服務和單體之間的切換就非常方面:
- 功能和接口在傳遞時,盡量通過 interface 進行實現細節的隱藏,這也便于微服務和單體架構的無感切換;
- 模塊、組件甚至整個服務邏輯的初始化,盡可能采用依賴注入,盡可能減少使用 init 進行重度的初始化;
- 每一個 package 的功能盡可能簡單、獨立、明確,避免一個 package 中耦合了大量復雜邏輯。
作者丨張敏
來源丨公眾號:騰訊云開發者(ID:QcloudCommunity)