微服务不香了?单体化改造为我们节省上万核CPU
创始人
2024-01-08 03:55:59
0

原标题:微服务不香了?单体化改造为我们节省上万核CPU

微服务一直以来是服务治理的基本盘之一,落地到云原生上,往往是每个 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)

dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn

今晚直播丨货拉拉微服务架构演进与数据库中间件、DevOps建设之路

随着AI、云计算等新兴技术应用场景不断扩展,传统的IT架构、数据库管理与开发运维交互模式正面临前所未有的挑战与机遇。为此,dbaplus社群携手货拉拉三位技术专家,围绕“货拉拉微服务架构演进与数据库中间件、DevOps建设之路”这一主题开展线上直播分享,和大家一起深度探讨服务治理、中间件、DataMesh、DevOps等议题。

  • 观看方式:线上直播间/dbaplus社群视频号
  • 直播时间:2023年12月8日(周五)14:30-17:00
  • 直播地址:z-mz.cn/7z1Ko

相关内容

热门资讯

小心!这些办公“黑科技”可能有... 网络视频会议系统可以为用户提供稳定可靠的图像、语音,更便捷地完成数据信息实时交互,在节约成本的同时大...
原创 “... 北京大学校园内,一场数学讲座引来众多数学爱好者的围观,现场不仅有很多北大的师生,就连不少外校学者也慕...
深度学习如何颠覆语音识别技术的... 在过去的几十年里,语音识别技术一直在不断发展,从最早的简单指令识别到如今能够处理复杂语言的系统,语音...
行进中国|有“模”力!“上下楼... 人民网“行进中国”上海调研采访团 白板上,记录着各种思维导图和技术路线;电子屏上预告着最新一周的沙龙...
vivo T4 Lite 5G... 【CNMO科技消息】此前,vivo T4 Lite 5G手机的宣传页面在海外电商平台上线。近日,官方...
鸿蒙系统,成了! 这两天在东莞参加华为HDC2025,昨天参加了主论坛,听了华为诸位领导关于技术的介绍,晚上合作伙伴晚...
心智观察所:MAGA手机,中国... 【文/观察者网专栏作者 心智观察所】 不久前,在曼哈顿特朗普大厦,美国总统的两个儿子埃里克·特朗普...
智飞生物吸附破伤风疫苗获临床试... 财中社6月20日电智飞生物(300122)发布公告,近日公司全资子公司智飞绿竹研发的吸附破伤风疫苗获...
SK集团与亚马逊投资约51亿美... 韩国科学技术信息通信部周五表示,韩国SK集团和亚马逊(212.52, -2.30, -1.07%)旗...
上海市数字公共服务中心落地闵行... 人民网上海6月20日电(马作鹏)6月19日,上海市数字公共服务中心(闵行区-上海马桥人工智能创新试验...
古尔曼:苹果考虑收购初创公司P... 6 月 21 日消息,彭博社记者马克・古尔曼今天清晨撰文称,苹果公司正考虑收购 AI 初创企业 Pe...
消息称英伟达、富士康正洽谈在休... 6 月 20 日消息,路透社今日报道称,英伟达正就在富士康休斯顿新工厂部署人形机器人一事进行商谈(该...
独家|张一鸣关注前沿AI业务,... 澎湃新闻首席记者 范佳来 字节跳动创始人张一鸣持续关注AI业务。 近日,有消息称,张一鸣持续在北京和...
朱雀三号火箭完成九机并联热试车 2025-06-20 13:41:15 作者:狼叫兽 6月20日,朱雀三号可重复使用运载火箭一级动...
2025政务大模型典型案例发布 北京6月20日电(记者 刘育英)由中国通信标准化协会主办、中国信息通信研究院(以下简称“中国信通院”...
数字赋能!拜泉县实现残疾人证全... 近日,拜泉县残联在服务残疾人领域实现重要突破——残疾人证办理正式迈入全流程信息化时代。这项举措通过数...
外泌体的研究热点与未来趋势:D... 引言(来源于DeepSeek) 外泌体研究是当前生命科学和医学领域最活跃的方向之一,其重要性源于外...
均富机电取得一种显示器增高架专... 金融界2025年6月20日消息,国家知识产权局信息显示,宁波均富机电有限公司取得一项名为“一种显示器...
小米申请耳机盒、智能指环及耳机... 金融界2025年6月20日消息,国家知识产权局信息显示,北京小米移动软件有限公司申请一项名为“耳机盒...
刚刚,华为重磅发布多个大模型! “一年以来,盘古大模型深入行业解难题,在30多个行业、500多个场景中落地。”6月20日下午,在华为...