1. 动态扩展拆解:先想明白你究竟要解决什么问题
1.1 不重启服务的诉求,到底在解决什么现象
Spring Boot 3 发布后,很多团队依然沿用“改代码、打包、发布、重启”的固定节奏。但做过交易中台、运营平台这类项目的人应该都有体感:重启一次的成本远远不只是“断开连接”那么简单。就我个人经历,在某积分中台项目里,一次普通的 Spring Boot 3 应用重启,JVM 启动、Spring 容器刷新、数据库连接池预热、Redis 缓存回填,再加上健康检查等待,整体不可用窗口接近四分钟。在这个窗口里如果链路上游继续打流量,失败率就会突然升高,告警立刻触发。
重启还会把内存里的一些运行态清空。比如营销模块的本地规则缓存、抽奖模块的定时任务状态,全部要从头来。如果预热逻辑写得不充分,重启后又是一轮缓存穿透。所以“不重启服务”不是图省事,而是生产环境里非常实际的服务可用性需求:让业务模块在 JVM 进程存活的前提下完成安装、替换和摘除,把发布从“改变进程状态”变成“改变模块状态”。
1.2 动态模块和功能开关、独立进程到底有何不同
有不少团队会把“配置开关”当成动态模块来用,在配置中心放一个布尔值,状态为 true 就走新逻辑。这个思路能解决一部分“临时灰度”问题,但满足不了本质需求:当你要更新的是一整套业务策略、一个新的支付渠道适配器、一组新的营销算法时,它不是 if/else 能表达的东西,而是必须由 JVM 加载新的类、执行新的逻辑。配置开关只能在已有代码路径里切换分支,没办法把一段新代码凭空送到运行中的进程里。
独立进程方案也可以做到,比如为每个模块单独部署一个 Spring Boot 服务,再通过网关切流量。缺点是运维复杂度上来了,一个模块一个进程,内存占用和部署成本成倍增加,模块之间如果要共享内部服务,还得引入远程调用。动态模块方案的核心差别,是在同一个 JVM 内通过隔离的类加载器管理模块代码,让模块与主程序在运行时解耦。打个比方,它像在一个大办公室里给每个人隔出独立工位,大家共享水电和走廊,但各自的工具和文件互不干扰。
1.3 方案对比:OSGi、自研加载器和独立微服务哪个适合落地
先说结论:我最终选择的是自研 ClassLoader 配合 ServiceLoader。
有人会觉得 OSGi 才是正统动态模块化方案,它的模块隔离、依赖版本管理、生命周期规范都很完整,但引入 OSGi 意味着整套工程要接受它的类加载纪律,学习成本和调试成本相当高。业务团队往往只是想把积分、抽奖、营销这类业务模块做成可插拔,并不需要完整的企业级模块化平台。OSGi 对这类项目属于重型方案,框架本身带来的复杂度会盖过业务收益。
独立微服务方案最容易被团队接受,但问题在于成本和链路。每个模块一个进程,发布和扩容都要管一整套环境,模块多了以后,主应用与模块之间、模块与模块之间的调用全部变为远程调用,排查问题多了一层网络因素。
表格对比一下:
| 对比项 | OSGi | 独立微服务 | 自研 ClassLoader + ServiceLoader |
|---|---|---|---|
| 隔离粒度 | 类加载器级,非常强 | 进程级 | 类加载器级 |
| 实现复杂度 | 偏高 | 中 | 中等偏低 |
| 依赖管理 | 独立 bundle 依赖体系 | 传统依赖体系 | 自定义 parentFirst/childFirst |
| 运维成本 | 需额外框架规范 | 服务数量多,机器成本高 | 集中在主应用内部 |
| 对 Spring Boot 3 侵入性 | 较大 | 很小 | 中 |
自研方案只要把接口契约、类加载策略、注册中心三件事定清楚,就能覆盖绝大多数业务场景。标题里提到的“SpringBoot3 动态扩展实战”,我建议也遵循这条原则:先用最小可用方案跑通,再逐步完善,而不是第一步就追求一个万能平台。
2. 模块框架落地:先定义规范,再写加载代码
2.1 模块的统一门面:接口、元数据和上下文入口
要让模块可以被插拔,第一步不是写加载器,而是先定接口。模块与主程序之间必须有一份稳定的契约,这份契约越清晰,后续替换模块实现就越安全。我在项目里使用了两个接口加一个上下文对象,先把“模块必须长什么样”固定下来。
public interface BusinessModule { String moduleId(); void start(ModuleContext context); void stop(); }ModuleContext 是模块访问主程序能力的窗口,同时也是模块向外暴露服务的出口。它的实现不复杂,但职责定位很关键:
public class ModuleContext { private final ApplicationContext applicationContext; private final Map<String, Object> exportedServices = new ConcurrentHashMap<>(); public ModuleContext(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } public <T> T getBean(Class<T> type) { return applicationContext.getBean(type); } public Object getBean(String name) { return applicationContext.getBean(name); } public void register(String exportName, Object service) { exportedServices.put(exportName, service); } public Map<String, Object> exportedServices() { return exportedServices; } public Properties loadProperties(String name) { try (InputStream in = getClass().getClassLoader().getResourceAsStream(name)) { Properties props = new Properties(); if (in != null) { props.load(in); } return props; } catch (IOException e) { throw new ModuleRuntimeException("load properties failed: " + name, e); } } }这里要特别注意:ModuleContext 里的 register 方法,是把模块对外提供的能力挂到注册中心,而不是向主容器的 Spring BeanFactory 注册 Bean。我见过有人直接调 ApplicationContext 的 registerSingleton,结果模块卸载时 Spring 容器里还残留着引用,造成非常隐蔽的内存泄漏。主容器只负责基础服务,模块能力统一登记在注册中心,卸载时从注册中心摘除即可,这个边界不能模糊。
每个模块 jar 内还需要放一个 module.json 元数据文件:
{ "moduleId": "points", "version": "1.2.0", "mainClass": "com.example.points.PointsModule", "apiVersion": "1.0.0" }moduleId 必须和部署目录名、实现类的 moduleId() 返回值保持一致;apiVersion 用于兼容性校验,接口升级时同步变更版本号,加载器在 install 阶段发现版本不兼容就直接拒绝,避免运行到一半才爆出类型错误。
2.2 模块 jar 怎么打、目录怎么放
模块 jar 的构建方式直接决定动态加载是否顺利。工程结构用 Maven 多模块管理:
parent-pom ├── runtime-api # 接口与上下文定义 ├── points-module # 积分业务模块 ├── lottery-module # 抽奖业务模块 └── main-app # Spring Boot 3 主应用模块 pom 里除了 parent,尽量只依赖 runtime-api,其他第三方库版本由 parent 统一控制。这里有一个很多人踩过的坑:模块 jar 不能打成 Spring Boot 的 fat jar。如果你在模块工程里配置了 spring-boot-maven-plugin 的 repackage,产物会变成一个带 BOOT-INF 目录的结构,URLClassLoader 直接加载后根本找不到 META-INF/services 里的实现类,因为真正的类被埋进了嵌套 jar。模块必须用普通 jar 打包,类路径就是 jar 根目录。
部署目录我建议采用“模块目录/版本目录/jar 文件”的结构:
/data/modules/ points/ 1.0.0/points-module-1.0.0.jar 1.2.0/points-module-1.2.0.jar lottery/ 2.0.0/lottery-module-2.0.0.jar保留版本目录的好处是支持快速回滚。上新版本时先上传新 jar,不着急删旧版本;一旦发现异常,把配置或加载逻辑指向旧版本目录,重新触发一次 reload 就能完成回滚。加载器扫描时只认某个版本目录下最新的 jar,不要同时加载同模块多个版本,否则注册表内部会出现 moduleId 冲突。
2.3 加载器核心组件的职责边界
动态模块框架由三个核心组件构成,职责必须拆开,否则后期维护会非常痛苦:
- ModuleScanner:扫描目录变化,发现新增 jar、版本变更、文件替换。
- ModuleClassLoader:从 jar 加载类,隔离模块依赖。
- ModuleRegistry:维护已安装模块的内存状态,提供查找、列表、启停操作。
最容易犯的错是把状态耦合进加载器里。有人习惯在 ClassLoader 里加“是否已启动”的标记,导致卸载时必须丢弃整个加载器,无法在后续重新触发启动。实际上注册表存的是模块引用,包含实例和加载器,卸载时从注册表摘除引用,加载器交给 GC 回收;更新时扫描器发现新版本,先走卸载逻辑,再走新增逻辑。三者保持解耦,整个生命周期管理才能清晰起来。
扫描周期我设置为 5 秒轮询。频率太高没必要,太低会让运维等待时间变长。Java 自带的 WatchService 理论上有事件通知能力,但在网络存储、挂载目录、容器环境里很不稳定,我更推荐用一个定时任务去遍历目录,逻辑直观且跨平台表现稳定。
3. 核心实现:加载、注册、插拔一个业务模块
3.1 自研 ModuleClassLoader,先解决类加载隔离
标准 URLClassLoader 基于双亲委派机制,默认“父类先加载”。如果直接用它加载模块 jar,模块里某个第三方库一旦和主程序存在不同版本,类会优先由主程序的加载器加载,模块里想用的新版本完全用不上。这个问题的本质是父委托抢占了模块自身的类加载机会。
解决方法是写一个 child-first 的 ModuleClassLoader,对模块范围内的类先到 jar 里找,找不到再走父加载器,同时把一小部分必须共享的包交给父加载器,比如核心接口包、日志包、Spring 框架本身。
public class ModuleClassLoader extends URLClassLoader { private final List<String> parentFirstPackages; public ModuleClassLoader(URL[] urls, ClassLoader parent, List<String> parentFirstPackages) { super(urls, parent); this.parentFirstPackages = parentFirstPackages; } @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> loaded = findLoadedClass(name); if (loaded == null) { if (isParentFirst(name)) { loaded = super.loadClass(name, false); } else { try { loaded = findClass(name); } catch (ClassNotFoundException e) { loaded = super.loadClass(name, false); } } } if (resolve) { resolveClass(loaded); } return loaded; } } private boolean isParentFirst(String name) { return parentFirstPackages.stream().anyMatch(name::startsWith); } }使用方式:
List<String> parentFirst = List.of( "org.slf4j.", "com.example.runtime.api.", "org.springframework." ); URL[] urls = new URL[]{jarFile.toUri().toURL()}; ModuleClassLoader loader = new ModuleClassLoader(urls, Thread.currentThread().getContextClassLoader(), parentFirst);用生活化的方式理解 ClassLoader:它像一个工具箱。双亲委派相当于“优先用公共工具箱里的工具,找不到再用自己的”,而 child-first 相当于“自己抽屉里的优先”。模块 jar 里的私有依赖当然应该优先找自己的抽屉,但核心接口类型必须走公共工具箱,否则同一型号的工具被两个抽屉各放了一份,彼此互不兼容。
3.2 ServiceLoader 装配:从 jar 到运行实例
类加载器解决了“类的世界”的问题,接下来要解决“怎么找到模块的入口”。模块 jar 里需要放一个 Java 标准的服务描述文件,路径是:
META-INF/services/com.example.runtime.core.BusinessModule文件内容写实现类全名:
com.example.points.PointsModule装配的核心逻辑如下:
public ModuleRef install(Path moduleJar, ModuleContext context) { ClassLoader original = Thread.currentThread().getContextClassLoader(); ModuleClassLoader loader = null; try { URL[] urls = new URL[]{moduleJar.toUri().toURL()}; loader = new ModuleClassLoader(urls, context.getApplicationContext().getClassLoader(), parentFirstPackages); Thread.currentThread().setContextClassLoader(loader); ServiceLoader<BusinessModule> services = ServiceLoader.load(BusinessModule.class, loader); Optional<BusinessModule> first = services.findFirst(); BusinessModule module = first.orElseThrow(() -> new ModuleRuntimeException("no BusinessModule implementation found in " + moduleJar)); module.start(context); return new ModuleRef(module.moduleId(), module, loader); } finally { Thread.currentThread().setContextClassLoader(original); } }ModuleRef 是一个简单的记录对象:
public record ModuleRef(String moduleId, BusinessModule instance, ClassLoader classLoader) { }这段代码里有一个细节很多人会忽略:Thread.currentThread().setContextClassLoader。JDK 在跨越类加载器调用时依赖线程上下文类加载器,Spring、日志框架在模块启动时也会用到。如果不设置,模块代码加载资源时会跑到主应用的类加载器上,结果找不到模块自己的配置。设置后记得在 finally 里恢复原加载器,避免污染主线程。
如果一个 jar 里想包含多个业务模块,可以在 META-INF/services 文件里写多行实现类,加载时逐个启动,注册表会按 moduleId 区分。不过实际项目中一个 jar 对应一个模块更清晰,也更容易做版本回滚。
3.3 怎么让模块对外提供服务:统一入口和动态路由
模块实例启动后,需要让外部能调用到它的能力。我这里没有做复杂的运行时 Controller 注册,而是用一个统一网关:路径中的 moduleId 决定找哪个模块实例,action 决定调用实例的哪个方法。
@RestController public class ModuleGatewayController { private final ModuleRegistry registry; public ModuleGatewayController(ModuleRegistry registry) { this.registry = registry; } @PostMapping("/api/module/{moduleId}/{action}") public Object invoke(@PathVariable String moduleId, @PathVariable String action, @RequestBody(required = false) Map<String, Object> params) { ModuleRef ref = registry.lookup(moduleId); if (ref == null) { throw new ModuleRuntimeException("module not found: " + moduleId); } BusinessModule module = ref.instance(); try { Method method = module.getClass().getMethod(action, Map.class); return method.invoke(module, params); } catch (NoSuchMethodException e) { throw new ModuleRuntimeException("action not found: " + action, e); } catch (Exception e) { throw new ModuleRuntimeException("action invoke failed: " + action, e); } } }模块实现类里面对应地写一个 public 方法即可:
public class PointsModule implements BusinessModule { private ModuleContext context; @Override public void start(ModuleContext context) { this.context = context; context.register("points", this); } @Override public void stop() { // 释放模块内部资源 } public Map<String, Object> redeem(Map<String, Object> params) { return Map.of("result", "ok", "points", 100); } }外部请求这样调用:
curl -X POST http://localhost:8080/api/module/points/redeem \ -H 'Content-Type: application/json' \ -d '{"uid":"u10001"}'统一路由方案的优点是主程序完全不需要知道模块内部有哪些 Controller 注解,卸载时也不需要反注册映射。如果真希望模块像普通 Spring MVC 一样使用 @GetMapping、@RestController,可以用 RequestMappingHandlerMapping 的 registerMapping 方法在运行时注册 HandlerMethod,但你要付出额外的反注册成本,而且卸载顺序一旦出错,映射残留会造成诡异的路由故障。我最终选择的是统一网关,简单可靠,对业务模块足够用。
3.4 卸载:比加载更需要小心的反向操作
很多人把卸载想得太简单,以为从 Map 里 remove 掉就行。实际上加载是往类加载器里“加引用”,卸载是对内存“断引用”,难点在于把主程序里所有指向模块实例的引用全部摘除。
public synchronized void uninstall(String moduleId) { ModuleRef ref = registry.unregister(moduleId); if (ref == null) { return; } try { ref.instance().stop(); } catch (Exception e) { log.error("module stop failed: {}", moduleId, e); } finally { try { ((URLClassLoader) ref.classLoader()).close(); } catch (IOException e) { log.error("classloader close failed: {}", moduleId, e); } } }stop 方法里模块必须处理三类资源:线程池要 shutdown,定时任务要 cancel,数据库连接或外部客户端要 close。如果模块里有一个调度任务,卸载时没有停掉,注册表里虽然已经没有引用,但调度器还持有模块内部的 Runnable,类加载器无法被 GC 回收,过段时间 Metaspace 持续上涨,最终 OOM。
ClassLoader.close() 只能释放 jar 文件句柄,不能保证类被卸载。要让它真正被回收,关键是让持有模块实例的引用链完全断裂。所以 stop 方法的顺序我会固定为:先取消对外路由,再停内部任务,最后关闭外部连接和句柄。顺序不能乱,先断流量,再释放资源,模块才能干干净净地离开内存。
4. 插拔过程中的坑,我一个个踩过来
4.1 NoSuchMethodError:版本不一致是头号杀手
模块加载起来不代表能用。我在第一次给抽奖模块做热更新时,主程序使用的是某个通用工具包 2.1 版本,模块 pom 里依赖的却是 1.8 版本。因为 child-first 策略,模块自己的 1.8 版本优先被加载,初看没有问题,但模块里有一段代码调用了 2.1 版本才有的新方法,运行到那里直接抛 NoSuchMethodError。
这类错误几乎不会在启动时报出来,只有跑到那一行业务代码时才爆炸,排查成本非常高。后来我定了一条硬规则:所有模块的第三方依赖版本由 parent pom 统一管理,任何新依赖必须先进 parent,再在模块中引用。模块确实需要特殊版本时,必须在依赖树里做 explicit exclusion,并写明理由,尽可能保证整个运行时只有一套版本。
排查这类问题时,mvn dependency:tree和jdeps很有用。先看模块依赖树里到底引入了哪些版本,再用 jdeps 检查模块 class 文件引用的方法签名,对比实际加载到的类,基本能定位是哪一层版本错位。
4.2 ClassCastException:同一个接口,两种加载器各加载一份
最典型的场景是把模块实例传回主程序后,做类型判断时抛 ClassCastException,代码如下:
BusinessModule module = (BusinessModule) loadedObj;如果接口类 com.example.runtime.core.BusinessModule 被主程序类加载器和模块类加载器各加载了一次,JVM 会认为它们是两个完全不同的类型,强转必然失败。解决方式很明确:必须把 runtime-api 包加入 parentFirst 名单,让接口类始终由主程序的类加载器加载,模块侧拿到的只是同一个 Class 对象。
还有一类隐蔽场景和线程上下文类加载器相关。当模块回调主程序时,如果主程序代码依赖线程上下文类加载器去加载类,而当前线程上下文是模块的加载器或多个类加载器混合,也容易出现双份类。处理方式是在跨模块调用的关键入口统一切换类加载器,并在 finally 里恢复。
4.3 Spring 扫描不到模块里的类,怎么办
最容易被误解的是“模块 jar 放到目录里了,Spring 应该自动扫到 Bean”。实际上 Spring Boot 的 @ComponentScan 默认只扫描主应用所在包及子包,模块 jar 根本不在扫描路径内,而且模块类加载器是在运行时才创建的,启动阶段的扫描完全不会感知到模块类。
我的解决方向是彻底放弃让 Spring 扫描模块类。模块通过 ModuleContext 获取主程序的 Spring Bean,自身能力通过 ModuleRegistry 暴露,再由统一网关调用。如果你确实需要模块也拥有 Spring Bean 的生命周期,可以用 DefaultListableBeanFactory 动态注册 singleton:
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory(); beanFactory.registerSingleton(beanName, moduleService);但卸载时要去掉这个 Bean,否则 Spring 容器还持有模块服务实例引用,会造成泄漏。我第一版实现就是这么做的,后来发现要在卸载流程里维护 Bean 名单、处理自动注入关系,复杂度远超收益,最后改回了注册中心方案。有能力做是一回事,要不要做,建议想清楚。
4.4 Windows 下删除 jar 时提示“文件被占用”
这个问题在开发环境很常见。热更新时替换 jar 文件,Windows 直接报“文件正在被另一个进程使用”或删除失败。原因是 jar 被 URLClassLoader 加载后,文件句柄在 close 之后才会释放;如果模块内部又通过 FileSystems.newFileSystem 读取 jar 资源,关闭不及时,Windows 下的文件锁会更顽固。
我在流程里加了两条约定:所有从 jar 读取资源的入口显式 close;卸载模块做完 classLoader.close() 后,设置一个短暂的等待窗口,比如等待 1 秒再允许文件替换,给句柄释放留出时间。如果 Windows 本地依旧报占用,可以加 JVM 参数-Dsun.zip.disableMemoryMapping=true规避 ZipFile 内存映射导致的文件锁。生产环境多为 Linux,这个坑主要影响本地调试,不用因为它推翻整体设计。
4.5 热更新后线程和连接池不释放,怎么查
做积分模块热更新时,我反复触发 install/uninstall 几十次后,进程线程数从两百涨到八百多,Metaspace 也一路走高,最终只能重启恢复。排查后发现 root cause 是 stop() 里没有关闭模块内部的 ScheduledThreadPoolExecutor。
这里给一份自查清单,stop() 里必须逐项处理:
- 关闭线程池,用 shutdownNow 并确认线程终止。
- 取消所有 ScheduledFuture 定时任务。
- 关闭模块自己创建的数据库连接池。
- 清理 ThreadLocal,尤其是池化线程里残留的旧值。
- 从主程序的事件总线反注册模块添加的监听器。
还有一个更隐蔽的情况:模块启动时向外部消息中心注册过消息回调,回调对象是模块内部创建的匿名类,消息中心一直持有它,模块类加载器就一直在泄漏。解决方案是模块启动时把所有对外注册的 hook 记录进一个清理清单,stop 时逐个反注册。带上完整生命周期管理的模块,才能经得住反复热更新的考验。
5. 上线后的效果与一点运营建议
5.1 动态模块给我们的发布节奏带来的变化
项目切换为动态模块模式后,业务模块的版本发布基本变成了三步:上传 jar 到部署目录、触发一次 reload、通过网关发探针请求验证。整个过程不需要重启主应用,也不需要等连接池预热。一个典型的操作过程是:
# 上传新版本 scp points-module-1.3.0.jar appnode:/data/modules/points/1.3.0/ # 触发 reload 接口 curl -X POST http://localhost:8080/api/module/points/reload # 验证新版本逻辑 curl -X POST http://localhost:8080/api/module/points/redeem \ -d '{"uid":"1"}' -H 'Content-Type: application/json'这个变化本质上是把“发布”从重操作变成轻操作。线上团队不再需要协调重启窗口,灰度也更容易:先在测试节点上传新包并观察指标,确认稳定后再批量同步上传、逐个触发 reload。一旦发现异常,把版本目录切回旧版本重新 reload 一次,就是一次完整的回滚。发布窗口从原来的按小时计缩小到按分钟计,对业务连续性的价值非常明显。
5.2 监控模块状态的运营手段
模块可以插拔了,就必须把模块状态暴露到运维体系里。我在主程序里加了一个管理端点,返回所有已注册模块的 moduleId、版本、加载时间、最近操作,同时提供 reload、uninstall 等运维接口。在健康检查组件里也加入模块存活状态,模块失效时能直接进入告警平台,而不是等到用户访问失败才发现。
动态加载的排查比普通接口调试难得多,所以日志必须详细。每次 install 和 uninstall 都打印加载时长、类加载器标识、模块版本;对外提供的 reload 接口调用时,记录完整的请求来源和时间,方便事后回溯哪个节点做了什么操作、当时依赖的是什么版本。运维脚本里还应该约定,上传 jar 前先做文件 hash 校验,避免传输不完整导致加载到半截文件。
我个人用完这整套方案后的感受是,Spring Boot 3 下的动态模块并不神秘,核心就是把接口契约、类加载器隔离、生命周期管理这三块做扎实。不用被“模块化平台”“插件引擎”这些词吓住,先按最小可用实现把加载和卸载跑通,再去补监控和回滚能力。等你体会到不重启服务完成线上业务模块变更的顺滑感,就会觉得这套投入非常值得。