微服务不香了?单体化改造为我们节省上万核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

相关内容

热门资讯

百镜出道,C位依旧:WAIC顶... 来源:雷科技 如果说 WAIC 2025 是今年 AI 硬件的「集体高考」,那 Rokid Glas...
韩媒:韩国扶持五大联合体开发“... 综合韩联社、《朝鲜日报》和《京乡新闻》等韩媒报道,韩国政府4日正式选定NAVER Cloud、Ups...
录取通知书的“飞驰”之路 7月盛夏,安徽省近50万封承载着无数学子青春梦想与家庭期盼的高考录取通知书,正从四面八方“飞驰”向它...
凡知智造取得恒温核酸扩增分析仪... 金融界2025年8月6日消息,国家知识产权局信息显示,山东凡知智造医药科技有限公司取得一项名为“一种...
原创 离... [撒花] 随着科技的不断发展,“仿人形机器人”也变的越来越成熟了,AI的加持也让这些机器人变的更加智...
当AI化身“全能助手”,人类的... 正在屏幕前玩手机的你 有没有过这样的体验? 遇到不懂的问题 第一反应不是翻书思考 而是脱口而出“嘿 ...
珠海创信电子取得电路板用锡膏检... 金融界2025年8月6日消息,国家知识产权局信息显示,珠海创信电子有限公司取得一项名为“一种电路板用...
NBN Co转投亚马逊柯伊伯计... 来源:C114通信网 C114讯 北京时间8月6日晚间消息(蒋均牧)NBN Co与亚马逊达成协议,通...
石景山这家医院药品有了“电子身... “窗口不用一盒盒扫码吗?取药几乎不用排队,这速度真给力!”在北京大学首钢医院门诊药房,刚取到药的患者...
电力数据“会说话” 点亮智慧新... 本文转自【新华社】 新华社北京8月6日电 《经济参考报》8月6日刊发记者邓林如采写的文章《电力数据“...
国能新疆红沙泉二号矿实现纯电无... 近日,在国家能源集团新疆红沙泉二号露天煤矿(以下简称“红二矿”),58台无人驾驶矿卡正高效运转,其中...
普天科技:致力数据治理领域打造... 金融界8月6日消息,有投资者在互动平台向普天科技提问:公司相关产品或技术有哪些能为人工智能赋能?谢谢...
DeepMind发布Genie... 来源:至顶网 尽管目前还没有人找到通过生成式人工智能赚钱的可靠方法,但这并没有阻止Google D...
【喜讯】骨三科陈美玲在全国竞赛... 8月2日,在全国首届护理AI创新应用技能挑战赛决赛中,我院骨三科陈美玲的作品《创新聚力赋能发展——A...
微信内存一下就少了10G上热搜... 站长之家(ChinaZ.com)8月6日 消息:今日,微博热搜榜上#微信内存一下子就少了10G#的话...
我是宁波技能冠军④丨这些技能高... 前言 从工业设备控制(PLC)、工业机器人系统操作等“硬核”制造能力,到人工智能训练、网络安全等高科...
聚焦人工智能产业链 山东蓄力打... 中新网济南8月6日电 (记者 沙见龙)“这项体系如同人工智能产业的‘中央厨房’,能根据不同行业的差异...
2025未来科学大奖揭晓,他们... 科学之光照亮人类未来。6日,备受关注的2025未来科学大奖揭晓。这个夏天,“高冷”的基础研究再次变得...
为什么CA需要有《电子认证服务... 来源:SSL_SecureSocketLayer 1. CA是什么?—— 数字世界的「信任基石」 C...
2025 年 2 款华为会议记... 现在远程会议越来越多,不管是企业还是个人,都需要能快速整理会议记录的工具。我作为科技产品体验师,选了...