软件架构从来没有所谓的银弹,好的架构除了良好的设计,更少不了持续的迭代优化。腾讯文档在业务挑战之下,实现了一种灵活切换单体、微服务的架构设计方案,对业界同类型同场景项目具备较高可借鉴性。本文将详细介绍腾讯文档在实现单体服务和微服务切换过程中所采用的具体方法和技术,以及所取得的收益。
一、引言:腾讯文档面临的架构挑战
在软件开发领域,选择合适的架构设计至关重要。单体服务和微服务架构各有优缺点,分别适用于不同场景。本文将重点探讨腾讯文档如何在这两种架构之间灵活切换,以适应不同业务场景的需求。
单体服务架构将所有功能集成到一个应用中,简单且易于维护,适合中小型项目。但随着项目规模扩大,可能面临可扩展性差、部署困难等挑战。相反,微服务架构将应用拆分为多个独立服务,具有解耦、隔离、弹性等优势,适用于大型项目。然而,微服务架构使系统更复杂,需要考虑服务拆分、调用、部署等方面,可能导致成本增加。
腾讯文档面临在单体服务和微服务架构之间做出权衡的挑战。在 C 端场景中,用户规模庞大,需要快速迭代;在私有化场景中,面临运行成本、维护成本等挑战。为了充分利用两者优势,腾讯文档采用了一种灵活的架构策略,在不同场景下实现切换。本文将详细介绍腾讯文档在实现单体服务和微服务切换过程中所采用的具体方法和技术,以及所取得的收益。这些经验将为其他项目在面临类似挑战时提供有益的借鉴。
二、探索:微服务和单体服务的平衡
当微服务的劣势成为系统的瓶颈时,我们需要重新评估单体服务的使用。私有化部署的腾讯文档就是这样的一个场景,其 C 端场景有 100+ 个微服务,如果把这 100+ 个微服务原样地迁移到私有化上,将面临:
高运行成本:每个微服务内部都要运行一套 tRPC-Go 框架运行时,它重复占用了大量的内存和 CPU 资源。而对私有化客户来说,他们的文档服务可能只有几十到几百人使用,这样的运行成本是不可接受的。
高部署成本:每个服务都需要维护 tad 描述文件。按照半年到一年的版本更新节奏,每次更新时,需要更新所有这些服务的 tad 描述文件,这是一个巨大的工作量。
高镜像分发成本:每个微服务都需要打包成镜像,然后分发到私有化客户的服务器上。所有微服务的镜像总大小已经超过了 10GB,给分发带来了很大的挑战。
这时单体服务就显得非常有用了,然而我们并不希望从头开发一个单体服务,重新再实现一次所有的功能。我们需要的是一个灵活的架构,即:可根据需求把所有微服务随时合并成少数几个单体服务,在保留微服务优势的前提下,消除其劣势。
如下图所示,我们的目标是在 C 端场景中,仍然以微服务的形式部署在 TKEx 平台上;而在私有化场景,我们将微服务合并成少数几个单体服务。
三、实践:灵活自动化的切换
为实现微服务与单体服务之间的灵活切换,我们开发了一个名为 monolith 的工具来完成服务的自动合并,这个工具依赖于一个配置文件,如下所示:
modules:
name: monolith-module1
merged_servers:
name: microserver1
...
name: microserver2
...
name: monolith-module2
merged_servers:
name: microserver3
...
name: microserver4
...
这个配置文件描述了我们需要输出哪些单体服务,以及这些单体服务从哪些微服务合并。此外,我们还利用了自动化技术,将检测和生成单体服务集成到每次 MR 中。
通过这些工具和流程,我们能够在保证代码质量的同时,快速地将微服务合并为单体服务,以满足不同业务场景的需求。当然,在开发过程中,也遇到了很多挑战,主要包括以下方面:
1、挑战一:各式各样的框架
腾讯文档已经持续迭代了 5-6 年,在此期间,尝试过多种不同的框架,如 tRPC-Go、tRPC-Cpp、SPP、LSF 等。然而,支持所有这些框架的灵活切换既不现实也没有必要。
经过了近一年的整改,实际上绝大部分服务都已经采用了标准的 tRPC-Go 框架,也遵循腾讯文档既定的规范进行开发。因此,我们只选择了那些使用 tRPC-Go 框架的服务进行整合。
tRPC 框架已开源,详情请阅读:《腾讯开源 tRPC:多语言、高性能 RPC 开发框架》
当然,有一些服务在 tRPC-Go 框架上进行了魔改,例如不使用 trpc_go.yaml 配置文件,而是通过 Go 代码定义所有配置。这种服务无法被合并。因此,所有需要合并到单体服务的微服务都必须使用腾讯文档约定的标准化方式开发。
除了框架不同之外,腾讯文档还有一些小仓服务,这些小仓服务从一开始就不在我们的考虑范围之内,直到它们被合并到大仓为止。幸运的是,现存的小仓服务也就 3-5 个,已不再是我们需要关注的问题。
2、挑战二:大相径庭的配置
tRPC-Go 框架支持灵活的 plugin, filter 等扩展方式,为业务带来了很多便利。然而,这也对我们落地单体服务的灵活切换带来了挑战。过去,腾讯文档不同的团队针对基础功能各自开发了不同的组件,比如配置中心、日志组件、CGI 开发组件等,导致配置存在差异。
以业务配置为例,私有化场景中,部分服务读取本地的配置,部分服务通过 trpc_go.yaml 的 plugin 中来读取业务配置。这种情况下,使用本地文件来读取配置的路径可能产生冲突,因为它们基本上都是 ${APPPATH}/config/app.yaml 。所以,我们开发了统一的配置组件,通过 plugin 来读取业务配置。微服务和单体服务的配置结构如下所示:
微服务的配置:
plugin:
config:
app:
providers:
serverA:
:
foo: bar
foz: baz
单体服务配置:
plugin:
config:
app:
providers:
serverA:
:
foo: bar
foz: baz
serverB:
:
zoo: bar
zoz: baz
...
此外,一些插件配置的合并也存在问题。比如某个插件的配置结构如下:
plugin:
:
:
key1: value1
key2: value2
在微服务环境下独自运行时,这种配置没有问题。但在合并到单体服务后,每个服务的配置不同,就会产生冲突。为解决这个问题,我们对此类插件进行了改造,形成了如下的配置结构:
plugin:
:
:
service1:
key1: value1
key2: value2
service2:
key1: value2
key2: value1
...
除此之外,还有一些配置冲突,如多个服务使用相同名字的 Redis,但它们的 target 指向不同的 Redis 实例地址,这种冲突在配置合并过程中也需要解决。
3、挑战三:随意修改的状态
tRPC-Go 框架提供了许多全局变量让用户使用,以便业务开发者能灵活地调整某些功能特性,如 http.DefaultServerCodec 和 restful.Marshaller 等。在微服务环境下,随意修改这些全局变量可能不会带来很大影响。但对于单体服务,任何对这些全局变量的改动都可能影响到其他服务的功能。一般而言,我们禁止随意修改公共的全局变量,但对于一些必须要修改的全局变量,我们采取了多种不同的策略。
策略 1: 开发通用的插件
基于有些需要修改 http.DefaultServerCodec 的服务,我们提供了统一的 CGI 开发组件,这个组件会基于 tRPC-Go 的能力,提供一个叫做 cgi 的全新的 protocol,它提供了统一的标准化的应答结构:
策略 2: 分模块硬隔离
而对于那些需要修改 restful.Marshaller 的服务来说,我们采取了另一种做法。观察到所有需要修改 restful.Marshaller 的服务都属于同一类服务,且它们对此全局变量的修改内容是一致的,因此我们为这些服务独立了一个单体模块,让它们和不需要修改此变量的服务从根本上隔离开来。
4、挑战四:隐秘角落的臭虫
有一些隐藏的 Bug 在微服务独立运行时并不会出现,一旦合并到了单体服务,它就会慢慢浮出水面,打你个措手不及,然后让你回味无穷。请看下面这段代码:
func Register(s *server.Server) {
someserverpb.RegisterSomeServerSomeService(s,newSomeServiceImpl())
}
乍一看,这段代码似乎没有什么问题。然而,问题就出在 RegisterSomeServerSomeService 方法的传参上,它把 server.Server 传递进去了,在 tRPC-Go 的实现中,它会遍历这个 server 所有的 service,然后将 newSomeServiceImpl 的返回值注册上去。在单体服务中,它会将所有之前注册的 service 都覆盖掉,最后 CGI 在访问时返回 404。更不幸的是,这种问题的定位相当繁琐。
这只是其中一个例子,实际情况中我们遇到的问题远远不止于此。但面对种种这样的问题,我们并不是束手无策的,在此,我愿分享一些个人经验供大家参考。通常,定位这类疑难杂症时,我会运用一种名为“二分定位法”的方法来查找问题。这个名词是我自创的,具体实施步骤如下:
首先明确问题。例如,在上述示例中,我需要确认 /foo/bar 这个 CGI 访问时是否真的会返回404,以及这是否是由服务合并所导致的后果。因此,我需要在原始微服务环境下验证这个 CGI 访问是否正常,而在单体服务中,它是否会返回404。
明确问题后,我们可以去掉一半的服务,然后观察是否正常。如果表现依旧,那么就继续去掉一半的服务。如果表现恢复正常,我们就增加当前1/2数量的服务。这样,很快就可以定位到究竟是合并哪个服务导致了这个问题。这个步骤类似于二分查找法,但由于它用于定位问题,因此我称之为“二分定位法”。
当然,要想更快速地定位问题,仅依靠此方法还远远不够。有时候还需要结合调试、日志等一系列手段。如果问题是由并发导致的,甚至还需要仔细分析存在并发的代码片段。幸运的是,并发导致的问题通常具有极强的不确定性,因此很容易判断问题是否由并发引起。
5、挑战五:花样百出的变更
在实现单体服务与微服务之间的灵活切换后,可能会遇到各种变更带来的问题。例如,某个服务在合并运行一段时间后,突然出现故障。如果不采取一些措施,这种情况可能在由数百个微服务合并的单体服务中变得难以避免。
幸运的是,我们在设计之初就仔细考虑了这种情况。我们会在每次 MR 中,通过流水线进行自动检查,包括检查服务是否仍然满足合并约束(如配置是否仍然能合并,代码结构是否仍然符合规范等)。当然,这仅仅是我们一开始采取的措施。从长远来看,这还不够。在后续规划中,我们还将集成接口测试、端到端(e2e)测试等。
借助这些自动化功能,我们可以对每次变更保持信心,确保它们不会破坏合并兼容性。
在克服上述各种挑战之后,我们惊喜地发现最终的工具模型变得非常简单明了。最终它浓缩成了一个简单的模版文件:
// Code generated by backend/tools/monolith, DO NOT EDIT.
// Package service rpc入口层。
package service
import (
{{- range .MergedServers}}
{{.}}service "docx/backend/application/{{.}}/service"
{{- end}}
"git.code.oa.com/trpc-go/trpc-go/server"
)
// Register 注册 pb service 实现。
func Register(s *server.Server) {
{{- range .MergedServers}}
{{.}}service.Register(s)
{{- end}}
}
这个模板会根据配置,将所有经过标准化处理的服务注册到单体服务中,从而生成最终的单体服务代码。这种简洁高效的实现方式不仅提高了开发效率,还降低了维护成本,为实现单体服务与微服务之间的灵活切换提供了有力支持。
四、成果:单体服务架构带来的收益
相较于微服务架构,单体服务架构的收益颇为可观,主要体现在镜像大小、内存占用以及链路性能方面。下面是腾讯文档的某个模块(原微服务数量是 4 个)在单体化之后的一些典型场景的数据对比:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
更为可观的是,我们预测在全面单体化之后,最终交付的总镜像大小将降低 75%,总内存占用将降低 96%。
通过实施单体服务架构,我们可以在保留微服务优势的前提下,显著降低系统资源消耗和提高性能。这种灵活的架构策略有助于根据不同场景和需求,实现资源的合理分配和优化。
如果字段的最大可能长度超过255字节,那么长度值可能…
只能说作者太用心了,优秀
感谢详解
一般干个7-8年(即30岁左右),能做到年入40w-50w;有…
230721