Koupleless 内核系列|模块化隔离与共享带来的收益与挑战

Koupleless 内核系列|模块化隔离与共享带来的收益与挑战

本篇文章属于 Koupleless 进阶系列文章之一,默认读者对 Koupleless 的基础概念、能力都已经了解,如果还未了解过的可以查看官网

进阶系列一:Koupleless 模块化的优势与挑战,我们是如何应对挑战的

进阶系列二: Koupleless 内核系列 | 单进程多应用如何解决兼容问题

进阶系列三:Koupleless 内核系列 | 一台机器内 Koupleless 模块数量的极限在哪里?

进阶系列四:Koupleless 可演进架构的设计与实践|当我们谈降本时,我们谈些什么


在 Koupleless 模块化架构下,有四大特点:快、省、灵活部署、平滑演进,这些主要的优势来自于对应用架构的纵向和横向的分层解耦,在隔离与共享之间寻找最佳的平衡,同时也对应用全生命周期(需求 -> 研发-> 测试验证 -> 发布 -> 线上运维调度等)流程进行升级,包括基座提前预热、模块独立迭代、机器合并复用等上。这篇文章会从运行时隔离和共享的角度分析 Koupleless 模块化的优势,对应的性能 benchmark 对比,同时也会详细介绍模块化背后的挑战和解决方式。

共享带来的优势分析

传统的 SpringBoot 之间完全隔离,相互之间通过 RPC 进行通信,模块化的模式在于模块之间存在更直接的共享能力,包括类和对象,以及更直接更高效的本机 jvm service 调用等,从而在隔离与共享之间找到一个更佳的平衡点。为了深入分析在隔离的基础上增强共享带来的效果,我们以社区应用 eladmin 为基础拆出一个基座 + 三个模块进行实验,统计了一些数据提供给用户查看,如果兴趣也可以自行下载验证。

类的共享

Koupleless 采用 SOFAArk 将模块里的类委托给基座加载,可以让模块 ClassLoader 运行时只加载模块特有的类,模块打包不会包含这些被复用的类,从而降低打包构建产物的大小以及运行 MetaSpace 的内存消耗,根据模块与基座相同类的占比不同,降低的大小和比例也不同。在 eladmin 中,可以看到镜像化在 300MB,而模块化构建产物只有 100KB。

构建产物大小对比(假设基础镜像为200M)

对象的共享

对象的共享主要通过如下两种方式来复用基座上的对象、逻辑、资源、连接等:

  1. static 变量或对象的共享
  2. 模块通过 Jvm Service 调用基座的逻辑,类似 api 调用且没有序列化的开销

static 对象共享

多个模块通过类委托加载机制复用基座类,这些复用的类里一些 static 变量在基座启动时已经初始化完成,模块启动时发现这些类里的 static 变量已经初始化过就会直接返回基座初始化的值,这样基座类里定义的 static 变量或对象会被模块共享,特别是单例对象,在中间件里存在不少这样的对象。这些对象在模块初始化时自动判断是否存在实例,存在了就会复用,例如 ehcache 里的 CacheManager。

模块 JVM Service 调用基座

基座内的通用逻辑定义在基座 bean 或者接口里,模块可以通过注解 (Dubbo 与 SOFARPC 的注解) 或者 api 的方式调用这些 bean 或接口。这样模块启动可以无需再次初始化基础设施服务或者连接,能降低模块资源消耗提升模块启动速度。同时由于这些接口 和 bean 是在同一个进程里,通过 api 或 JVM Service 调用没有序列化与反序列化开销,所以也不会出现调用性能下降问题。

通过上述介绍的类和对象复用,我们可以看到模块的内存消耗相对于传统应用,从原来的 200MB 下降到了 30 MB,同时因为减少了一些类与对象的初始化逻辑,启动时间也从原来的 8 秒下降到4秒。

内存消耗大小对比

启动耗时对比

eladmin-mngeladmin-quartzeladmin-system
构建产物大小对比(MB)内存消耗对比(MB)启动耗时对比(s)构建产物大小对比(MB)内存消耗对比(MB)
微服务3252008.63327192
模块化0.112333.670.05134

详细数据表

可以看到共享带来了成倍级的收益,收益的多少和复用多少基座的类和资源有关,如果基座沉淀更多的逻辑和资源,那么模块的启动速度还可以提升更多。在蚂蚁集团内部由于基础设施的 sdk 较多,将这些下沉到基座后,模块的复用率较高,大部分应用的启动速度从分钟级降到了 10秒 左右。

共享带来的问题分析

共享除了带来收益外,在一些特殊情况下也会带来一些问题。这些问题主要在于多应用与热部署这两方面

  1. 多应用主要是 Static 变量共享、多 ClassLoader 的切换
  2. 热部署主要是动态卸载时部分资源不会释放

Koupleless 结合在蚂蚁逻辑 5 年的经验,提炼了遇到的问题列表,并给出对应的解决方案。这里将遇到的问题列出来,让大家清楚模块化的问题与挑战,与社区一起共同完善模块化框架。

类共享

当前类共享的设计是模块将一些公共类委托给基座,模块类查找时可以复用基座的类。

Static 共享变量

在单进程多个应用模式下,模块复用基座里的类,这些类里的 Static 变量在基座启动时会完成一次初始化,模块再次启动时,在如下两种情况下

  1. 直接复用基座的值

那么大部分是符合预期的,在少部分模块希望使用自己的值,这时候会发现使用的是基座值而与实际预期不符

  1. 直接覆盖基座的值

对于一些希望使用模块自身值的情况,直接覆盖原有的值,那么会出现覆盖问题,static 最后只保留最后一次安装的值

解决方式:遇到这类情况只要将原来 static 变量增加一层 key 为 classLoader 的 map 即可解决。

多 ClassLoader

我们定义了类委托关系,是优先查找模块里的类,模块查找不到再查找基座里的类。但由于模块除了一些能够委托给基座的类外,一定存在一些无法委托给基座的类,也就是部分委托的方式。所以在类查找过程中会有这 5种情况。

这5种情况下,在少数情况下会出现如下的一些异常 case:

  1. 模块和基座里都有的类:如果某些类同时被两个 ClassLoader 加载,且涉及到类的 instanceOf 等判断,会导致一些 LinkageError 或者 is not assignable to的错误。
  2. 模块里有、基座里没有的类:如果由模块 ClassLoader 进入到基座 ClassLoader,然后在基座 ClassLoader 里执行 Class.forName(“模块类名”) 方法,会查找不到类。

解决方式:这里我们需要通过 adapter 确保查找类的时候,传入正确的模块 ClassLoader。另外在线程池下,存在线程复用,需要将线程与对应 ClassLoader 的绑定正确。

部分资源没有自动卸载(热部署才有)

模块 SpringBoot 的关闭实际上只是调用了 SpringContext 的 Shutdown 方法,底层依赖 Spring 的 Context 管理进行服务和 Bean 的卸载、依赖 JVM 的 GC 进行对象的回收,没有执行真正的 JVM 关闭操作。由于框架和业务设计和开发一般较少关注 Shutdown 时的资源清理的,主要依赖 JVM 的关闭操作自动完成资源的清理。所以模块在热部署是先卸载后安装可能存在一些线程、资源、连接等未清理问题。

解决方式:只要用户主动监听 SpringContext Close 事件,主动做下清理工作即可。热部署还有另外一个 metaspace 会在每次安装增涨的问题,这个 Koupleless 会在安装时检测 metaspace 阈值,超过阈值可以回退到重启安装即可解决 metaspace 增涨问题。

共享带来问题的解决方案

为了更系统的解决这些问题,我们从问题的发现 -> 治理 -> 防御都做了设计和工具实现,这些会在系列的后续文章里进行介绍。另外我们也通过分析用户需求,发现不同场景并不是所有的问题都需要解决的,只需要解决其中部分问题即可。

中台模块

中台模块追求启动快、占用资源少,模块较为轻量,以代码片段为主,较少直接引入一些中间件,一般不需要处理 static / classLoader / 资源卸载等问题,这种模式是问题域最小的,上述所说问题基本不存在,可以直接集成接入 Koupleless 使用。

是否有问题解决方式解决后是否还有问题
static 共享变量问题无需处理
多 ClassLoader 问题无需处理
卸载残留问题无需处理

应用模块

相对于中台模块的是应用模块,应用模块也就是模块较重,和普通应用一样可以使用各种中间件等能力,这种模式会存在上述提到的一些问题。对于应用模块存量两种场景,一种是长尾场景另一种是非长尾场景,非长尾场景遇到的问题相对可以较少,我们先看下非长尾场景。

非长尾应用

非长尾应用指每台机器的流量都较充分,已经较充分利用了每一台机器的计算能力。这类应用一般没有因为拆分微服务造成的资源浪费问题,更多关心的是启动速度、迭代效率。我们可以部署和调度策略,解决一部分问题。首先可以通过 1:1 的方式在一个基座机器上仅安装一个模块,能在达到较快启动的同时,避开多个应用合并在一起的问题。同时可以通过调度的方式,每次模块安装新版本的时候可以选择空基座(没有安装过任何模块)机器进行安装,原来老版本可以通过异步重启的方式卸载掉老模块,解决热部署的卸载残留问题。以一个模块的升级过程为例,具体过程如下:

第一步,初始状态

第二步,从buffer 里筛选一台机器安装模块 1 版本 2

第三步,将版本 2 的基座机器调度给基座 1,将版本 1 的基座机器调度给 buffer

第四步,buffer 集群发现有机器有已废弃的模块实例,则发起重启,从而清理掉上面的模块实例

以此类似,把基座 1 所有机器都升级到版本 2

这套方式的效果如下表:

是否存在解决方式解决后是否还存在
static 共享变量问题1:1,一个基座上仅安装一个模块
多 ClassLoader 问题1:1,一个基座上仅安装一个模块
卸载残留问题异步调度,安装模块新版本的时候,选择无模块的基座实例,不存在卸载老版本过程
长尾应用/私有化交付

这个场景里追求省资源,这就需要把多个应用合并在一个 jvm 里,就会遇到多应用的 Static 变量和 ClassLoader(如果解决 RPC 调用消耗上把多应用合并在一起也有类似问题)。这种模式根据是否需要追求迭代也分两种:

  1. 不需要高效迭代,可以直接使用静态合并部署,不会存在热部署的卸载残留问题。
是否存在解决方式解决后是否还存在
static 共享变量问题从发现 –> 治理 –> 回归防御 逐步治理
多 ClassLoader 问题从发现 –> 治理 –> 回归防御 逐步治理
卸载残留问题-
  1. 动态合并部署,该模式会有多应用、热卸载的问题,多应用的问题无法规避,需要通过我们提供的整套工具逐步完善解决,热卸载的问题可以通过模块部署的时候同步或异步的方式重启基座解决。
是否存在解决方式解决后是否还存在
static 共享变量问题从发现 –> 治理 –> 回归防御 逐步治理
多 ClassLoader 问题从发现 –> 治理 –> 回归防御 逐步治理
卸载残留问题可以通过模块部署的时候,同步或异步的方式重启基座来解决

后续我们会建设 ModuleController 和对应的平台能力,针对不同的场景提供不同的“套餐”,帮助各位按需选择适合的模式。这是 Koupleless 为了帮助存量应用解决对应实际问题,从框架和平台角度共同解决的思路,如果感兴趣的同学,欢迎访问官网 koupleless.io 加群共同建设。