如何在运行时动态加载和卸载 Skill,而不中断 Agent 主进程?
面试官:“如果我想在运行时动态加载或卸载某个 Skill,而不重启 Agent 主进程,你会怎么设计?”
候选人:
“这其实就是热插拔能力。核心思想是:把每个 Skill 当成一个独立的插件,用隔离的类加载器加载,通过注册中心动态感知上下线,配合优雅停机确保飞行中任务不丢失。 我分四个关键机制来讲。
机制一:自定义类加载器 + 版本隔离
要让 JVM 能在运行时加载、卸载一个类,最大的障碍是默认类加载器一旦加载了类,就不会主动卸载,只有当类加载器实例本身被 GC 时,它加载的类才会被回收。
所以,每个 Skill 都应该使用独立的、专门的类加载器来加载,比如 SkillClassLoader。这个加载器只负责加载该 Skill 的 jar 包或类路径,和应用主类加载器完全隔离。
-
加载:当需要上线一个 Skill 时,创建一个新的
SkillClassLoader实例,用它加载 Skill 的主类,然后通过反射或工厂创建 Skill 实例。 -
卸载:要卸载一个 Skill 时,先把该 Skill 的所有实例置为无效,断开所有引用,然后把对应的
SkillClassLoader置为 null。只要没有其他强引用指向这个类加载器,下次 GC 时它就会被回收,连带着它加载的所有 Skill 类一起卸载。这就是“通过回收类加载器来卸载类”的原理。
为了确保隔离彻底,通常还会:
-
不同 Skill 用不同的类加载器,避免互相污染。
-
指定父加载器为
PlatformClassLoader(Java 9+),避免把核心库也隔离进去。
机制二:注册中心的动态发现
加载卸载只是“物理”层面的操作,Agent 怎么感知到 Skill 的变化,要靠注册中心的事件驱动。
-
每个 Skill 上线时,向注册中心(Redis、Zookeeper、Nacos 等)注册自己的元信息,并发送一个
skill:online事件。 -
Agent 主进程内有一个 Skill Manager 组件,它监听注册中心的变更通知。当监听到
skill:online事件时,才触发上述的类加载和实例化;当监听到skill:offline事件时,触发卸载流程。 -
为了保证 Agent 主线程完全不被阻塞,整个加载/卸载过程可以放在异步线程池中执行。这样即便加载一个较重的 Skill 消耗了几秒,也不会让主线程卡住。
于是,开发运维人员只需要通过控制台或 CI 工具,把 Skill 的 jar 包放到指定目录,然后在注册中心里“启用”它,Agent 就会自动感知并热加载。
机制三:优雅停机与飞行中任务保护
卸载一个 Skill 时最怕的是“这个 Skill 还有任务在执行”。粗暴地直接销毁实例,可能导致交易中断或数据不一致。所以一定要走优雅停机流程:
-
标记停止:Skill Manager 先把该 Skill 标记为
STOPPING状态,拒绝所有新请求。新来的任务会被路由到其他同功能 Skill,或者返回“服务暂时不可用”。 -
等待存量任务完成:Skill 内部通常有一个计数器或任务队列(比如
CompletableFuture列表)。卸载线程调用skill.shutdown(),Skill 开始拒绝新任务,并每隔一小段时间检查正在进行中的任务数。等待直到任务归零,或者达到最大等待超时(如 30 秒)。 -
强制退出与清理:超时后仍然有任务,则中断线程,强制取消,并记录告警。然后释放 Skill 占用的数据库连接、缓存等外部资源。
-
注销类加载器:最后将 Skill 实例置 null,清除注册中心信息,并让类加载器可被 GC。
这个过程如果设计成两阶段,效果更好:
-
第一阶段:停止接受新请求(预卸载)。
-
第二阶段:等待存量任务清空后真正销毁。
机制四:版本共存与灰度切换
动态加载还有一个高阶需求:同一个 Skill 可能有新旧两个版本,如何做到平滑切换?
-
注册中心里的
Skill标识带上version字段,如send_email:1.3.0和send_email:1.4.0。 -
Agent 的路由层在匹配 Skill 时,可以根据灰度策略,把部分用户的请求路由到新版本,老用户继续用旧版本。
-
当新版本稳定后,下掉旧版本 Skill,卸载其类加载器。整个过程用户几乎无感知。