有的团队为了节约机器成本、有的团队为了提升研发效率、有的团队为了降低人均服务数
微服务合并,可以从多个角度入手
- 代码重构融合:人工拷贝代码、解决冲突,然后分阶段实施迁移重构。
- 代码合并打包:将多个代码仓库,拉取后合并打包,构建成一个包部署。对于编译型语言,也称为
合并编译
。 - 服务合并部署:将多个服务,解决环境隔离、版本冲突问题后,合并部署到一个进程。
代码重构融合
原始而纯粹,只要落地阶段细致、严谨,遵循服务迁移的基本套路,做好流量diff,不大会出现严重问题。
以合并两个Java应用为例(将A服务合并到B)
步骤 | 事项(重点完成以下事项) |
---|---|
前置梳理 | 1. 代码迁移范围:①无流量、可下线接口/模块;②必迁移模块梳理。 2. 配置迁移范围:①动态配置:KV、Token、密钥等;②静态配置:application/xxx.properties等。 3. 依赖冲突:迁移前将原服务的依赖升级到与目标服务一致;补充原服务单独使用的特殊依赖到目标服务,防止冲突。 |
代码迁移 | 1. 新增module/package,实现模块隔离。 2. 命名空间冲突:同名配置类、同名bean、同名的动态配置key、同名的task、线程池名。 3. 资源层客户端隔离:合并后的服务,可能需要连接多个mysql/redis/ES集群,对应的datasource或客户端需要分别初始化;例如不同的mapper接口包使用不同的datasource。 |
流量diff | 流量入口异步双写,异步执行结果与原服务执行结果预期一致。 |
流量切换 | 异步执行结果与原服务执行结果稳定一致后,将流量入口调用,迁移到目标服务的新方法上。 |
**服务合并,不只是代码合并。**影响代码运行结果的还有:动态配置、组件依赖关系(初始化顺序、bean间依赖)、运行环境有效性(事务、代理、异步生效与否)、消息队列延迟消费配置等。
代码迁移
,除了解决命名冲突,还要考虑资源层在使用上的隔离。
对于只将多个小服务 代码合并,但DB等资源层不合并的情况:合并后的服务,不同模块独立使用不同的datasource数据源,需要隔离Databource和DatabourceTransactionllanager,并将迁移代码中的@Transactional指定对应新增的事务管理器;
- 以将A服务合并到B的场景举例,B原本使用的Databource和DatabourceTransactionllanager都是作为default的,对应业务模块的@Transactional无需指定。服务A的代码迁入B后,使用@Transactional时指定对应新增的事务管理器。
- 合并后的服务,如需连接多个redis/ES集群,也是显示初始化多个客户端,在不同的业务模块中使用不同的客户端进行资源操作;跟在一个服务里使用多个redis/ES集群方式差不多。
线下验证
,可以充分利用原服务已有的测试能力,包括单测、自动化测试等。这是理想情况。
服务合并过程中,除了解决冲突需要修改,原服务逻辑在不大改的情况下,可以线下覆盖核心流程;然后线上流量diff,稳定一致后再切流。
上线准备
,需要关注各类鉴权、配置迁移。
迁移原服务的接口鉴权、访问资源的客户端鉴权、定时任务配置等。
除此之外,还要迁移服务治理策略的配置:熔断、限流配置、告警策略等。
流量diff
在线上环境进行。理论上,需要覆盖所有的读写接口、以及其他形式的流量入口(定时任务or消费组等)。
- 读接口,进行实时、异步比对,整体成本较低。
- 写接口,有一定的成本。
优势 | 劣势 | |
---|---|---|
新建一套临时表,异步双向 流量入口处,接收写请求后,先同步调用老方法,变更写入正式表;再异步调用(迁移到目标服务后的)新方法,对流量染色、标记是diff流量。 (迁移到目标服务后的)新方法,使用Mybatis Interceptor依据染色改写表名(追加_temp后缀);将数据写入临时表;最后比对两份数据表的一致性。 | 无侵入、数据隔离 | 1. 需要新起一套数据表; 2. 新方法除了DB变更外,其他缓存、对外通知MQ,也需根据流量染色,在diff阶段忽略不执行(老方法已完整执行、并对外发了MQ,新方法写入临时表用于比对,不用再发)。 |
同步执行新流程,比对结果快照 接收写请求后,先调用新方法,再调用老方法; 新方法开启事务执行完,查询事务内变更的数据并保存为快照,不提交&回滚事务; 老方法开启事务执行完,查询事务内变更的数据并与新方法的结果快照进行比对,提交事务。 | 1. 同步串行执行,耗时高; 2. 回滚事务可能产生告警; 3. 只适合在单个服务内重构时使用,跨服务重构时 除非改造写接口,返回结果快照。 |
其他形式的流量入口(定时任务or消费组等),需要区分任务类型,对于异步写的场景,也可以采用以上方案diff两套数据源或diff结果快照。
流量diff,理论上需要覆盖所有的读写接口。实际读接口实时、异步比对的成本更低。某些基础元数据的业务场景,仅diff读也行:读不一致说明写有问题,读一致,写肯定一致的情况;无需再diff写。
代码合并打包
以微服务的方式开发,以单体服务的方式运行。
对于编译型语言,也称为合并编译,需要在编译阶段 解决命名冲突、依赖冲突,确定唯一的入口函数等,编译成一个二进制的可执行文件。
而像Java这类半编译半解释型语言,需要借助maven-shade-plugin/maven-assembly-plugin插件,构建的jar文件包含所有依赖,同时支持重命名某些依赖的包。
- 要避免property文件互相覆盖,可以使用AppendingTransformer来对文件内容追加合并。
- 支持指定唯一的入口类。
对于已落地服务治理的团队,该方式更复杂的是:A和B合并后,服务注册 要注册为合并后的整体;A和B之前的调用方在服务发现时,获取服务列表也是“合并后整体”的。
两个代码仓库合并打包,原服务的动态配置、限流/告警策略等,是否使用原服务的app标识进行获取、服务注册发现 是否使用目标服务的app标识等问题,核心需要本地化基建的支持。
服务合并部署
对于编译型语言,例如Go plugin支持将Go包编译为共享库(.so)的形式单独发布,主程序可以在运行时动态加载这些编译为动态共享库文件的go plugin。但要求依赖版本、Go编译版本都一致。
而Java,使用类加载隔离、实现多个服务的依赖隔离,可以在同一个JVM实例中部署多个服务。
但由于合并代码后,两个服务会共享一个进程,服务占用的资源、CPU消耗、磁盘占用等均会叠加,所以资源利用率低、磁盘、CPU、内存占用小的服务合并更有优势。
如果多个服务使用同一端口,会发生端口竞争,暴露端口时最好使用随机端口。