选微服务or单体?Spring应用从微服务改造成单体服务的实践体会

最近工作中为了实现程序轻量化,减少内存占用,实践了将原本微服务模式的组件合并成单体服务。基于此次实践,总结下我对微服务架构与单体服务架构的优缺点的思考,同时也记录下我主要改造的点。

背景

微服务架构的优势

HELLO组件的是从HELLO平台的源码和架构改造而来,该平台目标是服务于公司内部专有业务的平台,平台后端采用了微服务架构,该架构大致如下:
!组件内存优化之微服务转单体服务.png
平台上线并稳定运行3年,团队成员们的有效利用了微服务架构的优势:

同时,在组织架构上,团队的分工也符合了康威定律,通过清晰的职责划分来有效分工,将复杂度和关注点有效拆分来降低负担。

康威定律
Any organization that designs a system will produce a design whose structure is a copy of the organization's communication structure.
系统的架构趋同于组织的沟通结构。
—— Melvin Conway, Conway's Law, 1968

当然,微服务架构在实际实践中也犯过很多错误,比如服务模块的过度拆分(功能拆分的粒度太小,增加开发成本和部署复杂度)。
综合来看,微服务化的HELLO平台作为专用的软件平台,服务于公司内研发同事,微服务化带来的优点(可扩展性、独立性和容错性)要大于复杂度提升的成本(运维成本,服务间通信成本等),比较符合开发团队的分工和合作模式。

组件化后的问题

将HELLO平台功能代码迁移改造成HELLO组件时,从代码模块化和部署架构上复用了已有成果,实现了快速改造后与私有化部署软件产品(简称WORLD)集成和发布的目标。但是在发布几个版本后,微服务化的组件给产品中带来了比较大的问题:内存占用过高,也是Java程序最被诟病的缺点。公司软件产品大多是私有化部署,服务器内存在大量组件运行时尤为吃紧,组件不合理的服务拆分和随意地扩展服务段带来了巨大的硬件成本。
与微服务架构的HELLO平台独占服务器资源不同,HELLO组件在WORLD产品属于平台中的一个基础功能模块,对资源占用提出了更严格的要求。

单体服务的优势

微服务转单体服务的实践

微服务化组件的已发布多个版本,因此合并成单体服务的目标是前端接口和外部接口改动尽量小(保持兼容性),同时保持已有的模块划分和职责清晰(保持工作模式)。
这次合并改造基于Spring Cloud生态的微服务,主要解决以下两个组件模块功能的替代:

新增单体服务段

新增一个独立的段(HELLO-center),禁用原有的其他段,组件已有服务段实现所有功能由新段提供。center服务段依赖所有其他服务模块,启动时会扫描依赖的模块jar包,进行依赖注入和初始化。
该服务段的Pom文件如下所示:

<dependencies>  
<dependency>  
<groupId>com.company</groupId>  
<artifactId>HELLO-dictionary</artifactId>  
<version>${project.version}</version>  
</dependency>  
<dependency>  
<groupId>com.company</groupId>  
<artifactId>HELLO-model</artifactId>  
<version>${project.version}</version>  
</dependency>  
<dependency>  
<groupId>com.company</groupId>  
<artifactId>HELLO-development</artifactId>  
<version>${project.version}</version>  
</dependency>  
</dependencies>  

Zuul请求路由转发->Servlet容器内部请求

使用Spring Cloud Zuul时,所有请求由网关服务(HELLO-gateway服务段)进行HTTP代理转发,请求链路如下图:
!组件内存优化之微服务转单体服务-2.png
合并时,去除Zuul网关功能后,需要保持对应的路由地址不变,因而,在所有已有服务模块中Controller的请求路径都手动加上对应的路由前缀,如原有的HELLO-model的页面查询接口:

@RestController  
@RequestMapping("/web/v1/")  
@Api(tags = "页面查询接口控制器")  
public class InterfaceQueryController {  
}  

改造成:

@RestController  
@RequestMapping("/hello-model/web/v1/") //这里增加路由前缀/hello-model  
@Api(tags = "页面查询接口控制器")  
public class InterfaceQueryController {  
}  

完成所有模块的修改后即可实现Zuul网关的相同效果,可以去除Zuul依赖。最后实现的请求效果如下,对组件外部不感知,但是内部已通过直接通过Servlet容器进行路由请求,而不是通过微服务的HTTP路由转发。
!组件内存优化之微服务转单体服务-3.png

OpenFeign远程调用->本地方法调用

OpenFeign对对远程服务调用的做了大量简化,我们在使用时只需要声明远程调用的API接口,无需具体实现即可完成RPC的功能。当微服务转成单体服务后,这些远程调用会改为程序内部本地方法的调用模式,避免了TCP/IP握手连接、消息序列化和反序列化的性能消耗,可以大大提高程序的响应速度和节约带宽资源。
目前组件服务段中已经大量使用OpenFeign已经声明的接口方法:

@FeignClient(name = "HELLO-dictionary", configuration = FeignConfiguration.class)  
public interface DictionaryFeginClient {  
@GetMapping("/inner/v1/list/domains")  
ResultListData<DomainDTO> listDomains();  
}  

OpenFeign组件在启动时会扫描标注了@FeignClient注解的接口,并生成动态代理对象,在调用时会通过负载均衡器选择一个合适的服务实例地址,构造HTTP请求发起远程调用。
在改造成单体服务时,就需要替换OpenFeign的默认代理行为,即通过定义一个这个接口的具体实现类替代OpenFeign的自动代理。通过以下几个步骤实现:

  1. 定义@FeignClient(primary = false)客户端自动装配对象在注入时不是首选项
@FeignClient(name = "HELLO-dictionary", configuration = FeignConfiguration.class, primary = false) //primary=false  
public interface DictionaryFeginClient {  
@GetMapping("/inner/v1/list/domains")  
ResultListData<DomainDTO> listDomains();  
}  
  1. 声明并注入对应的实现类,完成实现类接口的方法调用
@Service  
@Primary  
public class DictionaryLocalFeign implements DictionaryFeginClient {  
@Autowired  
DictionaryInnerController dictionaryInnerController; //注入HELLO-dictionary接口的Controller  

@Override  
public ResultListData<DomainDTO> listDomains() {  
return responseResultListData(dictionaryInnerController.listDomains(), DomainDTO.class); //直接进行方法调用,调用后的结果通过序列化和返序列化返回调用方  
}  

其他@FeignClient客户端类以类似的方式即可替换成本地方法调用,替换了OpenFeign默认的HTTP远程调用方式,实现原本微服务的远程调用转换成本地方法调用。但是,这里依然保持了序列化和反序列化的过程,没有进一步优化的原因是数据对象DTO在不同服务里是独立声明的,如果统一使用相同对象,对已有代码改造过大,权衡下来优化带来的缺陷风险更大所以保持不变。

总结

优化后的效果

微服务架构的多服务段合并成了单个服务段,减少了多个JVM的内存占用,组件内存从原有微服务架构的6.5G减少到单体架构的1.5G左右。
但单体服务的开发模式与我们小团队已有的合作开发模式不太相同,无法独立部署和调试,特别是一个特性涉及多个模块,并发开发时,负责不同模块的团队成员联调必须在同一个环境内,无法像微服务场景下,本地服务注册一个中心即可。

微服务or单体服务?

IBM 大型机之父Fred Brooks 在他的两本著作《没有银弹:软件工程的本质性与附属性工作》和《人月神话:软件项目管理之道》里都反复强调着一个观点:“软件研发中任何一项技术、方法、架构都不可能是银弹
—— [向微服务迈进 | 凤凰架构 (icyfenix.cn)](https://icyfenix.cn/methodology/forward-msa/)

选择微服务or单体服务?这个问题没有确定的答案。在实践了两种架构过程后,对没有银弹的观点更能体会和认可了。不存在完美架构能解决任何场景下的问题,一个软件项目从立项之初的架构可能无法满足其后续发展的需要,因此不必从一开始追求微服务架构(常规场景下单体往往比微服务的有更高的性能和较低的运维成本),也不必坚持使用单体架构(随着项目功能快速迭代,需要更多有经验和靠谱的开发人员来避免错误在单体应用的传播),而更可取的从各个角度综合分析,权衡利弊,如团队组织、运维成本、扩展性、容错性等多个因素考虑。在实际项目开发中随着项目需要,进行动态调整,实践演进式架构。