头图

微服务一直以来是服务治理的基本盘之一,落地到云原生上,往往是每个 K8s pods 部署一个服务,独立迭代、独立运维。

但是在快速部署的时候,有时候,我们可能需要一些宏服务的优势。有没有一种方法,能够 “既要又要” 呢?本文基于 tRPC-Go 服务,提出并最终实践了一种经验证可行的方法。

本文原文发布在腾讯内网,随着腾讯 tRPC 框架 正式开源, 笔者决定将敏感信息脱敏后发布至外网,也助力 tRPC 的推广。

微服务的优劣

微服务是云原生的大潮流,它的优势非常明显:

  • 微服务大大降低了模块间的耦合。当某个模块 / 微服务需要变更时,只需要调整这个微服务即可,其他服务无感知
  • 微服务化使得模块的更新能够平滑过渡,避免了停机更新的问题,也适合大型团队或多个团队间合作构建
  • 微服务模块的输入 / 输出定义很明确,非常适合融合 DDD 理念进行设计
  • 问题排查时,能够快速定位出现问题的模块,对运维也很友好

然而微服务也存在劣势:

  • 当系统趋向复杂时,随着微服务的拆分、功能的繁杂和细化,微服务越来越多,一窥系统全貌的难度越来越大
  • 模块间通信通过 RPC 实现,RPC 带来了时间和网络流量的开销
  • 依赖于完备的服务治理体系,对小团队而言,部署成本较高
  • 多租户隔离部署时,运维难度也成倍增加

遇到的问题

我们是心悦俱乐部首页 Feeds 流推荐系统系统的开发团队。但我们推荐系统也接入了其他业务,比如我们在接入游戏知几项目的一个功能后,全量发布前的压测中发现 CPU 开销大到难以接受。

分析

我们的系统是简单按照 “业务 -> 分流 -> 重排 -> 精排 -> 召回” 的推荐系统微服务化部署,没有做编排化:

观察压测数据,我们会发现,在分流层前后的服务,网络开销非常大:

服务峰值网络 IO
业务36.4 + 1.68 = 38.1 Gbps
分流41.0 + 40.2 = 81.2 Gbps
重排3.14 + 40.5 = 43.6 Gbps
精排3.01 + 2.19 = 5.20 Gbps
召回0.849 + 1.23 = 2.08 Gbps

分流服务是推荐系统的总入口,它没有很强的业务属性,而是在整个推荐系统的前面、在业务数据的基础上,加入 A/B Test 参数,供整个推荐系统使用。所以它对于业务负载基本是透传的。

很明显,业务服务发给推荐系统的数据流量非常大,而作为透明传输业务数据的分流服务,入参需要反序列化,出参需要重新序列化,这些都是无谓的算力消耗。

从分流服务的火焰图上也可见一斑——作为主要逻辑的查询实验参数,仅占了不到 10% 的 CPU,剩下的 CPU 都花在 gc、序列化反序列化、RPC 上面:

解决方案

从代码上看,占流量大头的数据结构,在整个调用链路上都是一致的,我们自然想到,省去网络开销,直接在内存里存取该多好啊。

其他内部团队其实也曾经提出一个 “单体大应用融合落地方案”,给了我们很大启发。不过,文档里面只是提出了将所有的微服务合并在一个 pod 中进行部署,服务间调用依然是 RPC 而不是内存调用。

实际上我们观察一下 tRPC 的 RPC 调用方法,可以看到所有的 RPC 调用,对 Go 业务代码来看是以一个 Go interface 的形式给出的;而实现方实现对外提供服务的方式,从业务层面也只是实现相应的 server interface 就可以。也就是说,服务的 client 端和 server 端,看到和实现的,都只是普通的 Go 函数。在此思路上,我们团队的 YongweiChen 同学在该文档的基础上,提出了一个将 RPC "mock" 成本地函数调用的方案,并由我落地验证了。

本文旨在向读者详细说明基于 tRPC 的微服务单体化方案的一种实现方法。代码改造还是有必要的,但我们的目标是尽可能减少代码改造量,避免入侵业务。

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 细节。对调用方而言,这就只是一个函数而已。对,函数!!!

代码改造

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)
    // ......

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 调用;但是在单体场景下,不知不觉地就只是一个内存的调用了。

main 包

我们在原有逻辑中,每一个微服务的逻辑都写在 main 包中。支持单体化的改造之后,每一个微服务的逻辑都应挪到一个非 main 包中,并且微服务依赖的各种组件尽量使用注入,而不是由微服务内部初始化。包括微服务所依赖的 client proxy 接口。

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 来生成上述代码。

部署改造

按照前文所述,我们用一个单体大应用,包含了五个微服务。那么我们在部署的时候,要如何配置呢?

服务配置

首先,我们要决定这个单体应用对外暴露的微服务接口有哪些。如果你需要暴露多个微服务入口,那么就需要在启动时传入的 trpc_go.yaml 中配置对应的多个微服务注册和监听地址。

我们的场景比较简单,因为整个推荐系统是一个单链式调用,所以我们只需要对外暴露业务层的服务即可。注册也直接注册到原有的业务层对应的北极星节点上。

那么剩下的几个微服务呢?每一个微服务可是都调用了 TRPC 的 RegisterXxxx 函数哦?请读者放心,TRPC register 的时候,如果查不到对应的配置入口,那么 TRPC 也只是什么都不做而已,不回导致进程的 panic。

配置配置

在启动时传入的 trpc_go.yaml 文件中,我们还需要添加各微服务所需要的配置入口。这个时候,我们就需要将每一个微服务所需的所有配置,都配置上。需要注意的是,如果之前不同的微服务采用了同样的配置名,却实现了不同的功能,那么在前问代码改造的时候需要修改一下,要不然在此处会发生冲突。

收益

降本增效

进行单体化改造之前,推荐系统五个服务,在我们预定的容量下,预估需要接近 18,000 核。经过单体优化之后,在没有修改任何逻辑的前提下,就将这个数字降到 7000,优化掉了足有 61%。可见 RPC 给我们系统带来的开销有多大。

此外我们后续又做了不少算法和业务层面的优化,又降到了 1000 核的水平,主要是缓存优化、前置计算和闲时算力的优化。

该方案虽然实现了一个单体化的大服务,但是完全不妨碍其他租户的业务采用微服务化的部署。可以说,我们在开发阶段依然是用微服务模式开发,并且在不同租户下采用了不同的部署模式。可谓是在低改造量前提下实现了 “既要又要”。

扩展思考

当然,单体化之后的服务,在运维层面自然会带来宏服务的缺点,比如说运维困难,模块迭代不灵活等等。这个时候就需要我们去权衡利弊、综合各项因素之后,再做出决策了。

本文所实践的方法,其实对于其他 Go 语言框架也都是通用的,包括且不限于 Gin、gRPC。只要开发者在进行微服务开发的时候,遵循以下原则,那么微服务和单体之间的切换就非常方面:

  1. 功能和接口在传递时,尽量通过 interface 进行实现细节的隐藏,这也便于微服务和单体架构的无感切换
  2. 模块、组件甚至整个服务逻辑的初始化,尽可能采用依赖注入,尽可能减少使用 init 进行重度的初始化
  3. 每一个 package 的功能尽可能简单、独立、明确,避免一个 package 中耦合了大量复杂逻辑

本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。

原作者: amc,原文发布于腾讯云开发者社区,也是本人的博客。欢迎转载,但请注明出处。

原文标题:《逆微服务潮流?基于腾讯 tRPC-Go 单体化改造怎么节省上万核 CPU》

发布日期:2023-11-08

原文链接:https://cloud.tencent.com/developer/article/2355815

CC BY-NC-SA 4.0 DEED.png

本文参与了SegmentFault 思否写作挑战赛活动,欢迎正在阅读的你也加入。

amc
927 声望228 粉丝

电子和互联网深耕多年,拥有丰富的嵌入式和服务器开发经验。现负责腾讯心悦俱乐部后台开发