做引擎、搜推这一行,逃不掉的一个宿命就是“无休止的策略迭代”。
还记得我碰到的一个真实场景:大促前夜,业务方跑过来说,“刚才发现个漏洞,黑灰产在刷量,必须马上把这批用户的权重降级。立刻,马上生效。”
按照标准的微服务发布流程:提交代码 -> 走 CI 流水线编译 -> 打 Docker 镜像 -> K8s 滚动发布。就算是一路绿灯,最快也要 15 分钟。但在这种时候,15 分钟的业务损失是难以承受的。
业务方问:“能不能填个配置,一秒钟生效?”
我:“如果是简单的阈值可以,但你这是复杂的过滤+重新查库打分的逻辑,需要写代码的。”
这就是典型的矛盾:我们需要单体应用(Monolith)的运行期高性能,却又极其渴望脚本语言般的秒级发布能力。
当时我们考察了几个方案。一是上 Groovy 脚本,但事实证明,一旦业务逻辑变复杂,需要注入各种 Spring Bean、调用多个外部 RPC、处理多线程时,Groovy 就显得捉襟见肘,难以维护。二是将策略层无限拆分微服务,但对于一此动辄百 QPS 请求、对延迟 RT 要求极高的核心搜索链路,多一次 RPC 带来的网络开销和序列化成本,是不被允许的。
最终,我们决定重走一遍阿里 TPP(淘宝个性化平台)趟过的老路:做基于 Java 核心的 Host-Plugin 宿主插件架构。
被逼出来的自定义 ClassLoader
提到热插拔,很多人第一反应是 OSGi。千万别,那玩意太重了,依赖关系搞得人痛不欲生。其实从底层来看,所谓的“代码热替换”,本质上是对类加载机制的操纵。
Java 的双亲委派模型是为了安全和稳定,保证核心类不被篡改。但这也意味着,只要一个类被 AppClassLoader 加载了,在 JVM 生命周期内你就别想替换它。
我们要做的第一件事,就是打破双亲委派。
我们需要为每一个业务插件,哪怕是同一个插件的不同版本,分配一个独立的 HotSwapClassLoader。类比一下,这就好比我们给每个插件在 JVM 进程里建了一个独立的“集装箱”。
很多网上的 Demo 写自定义 ClassLoader,一跑起来满屏的 NoClassDefFoundError 或者 ClassCastException。坑在哪?在于不能全打破。
核心基础包(java.*, javax.*, 甚至我们定义在 Host 里的基础接口)必须走双亲委派让父加载器加载,否则就会出现“你传过来的 User 对象,跟我加载的 User 对象不是同一个类型”的诡异现象。而真正的业务逻辑类(比如 com.atlas.plugin.*),则必须被我们的自定义加载器拦截下来,强制自己在本地加载。
Spring 的“禁区”:如何在一个 JVM 里面开个小灶?
类加载的问题解决了,真正的噩梦在 Spring 这里。
业务插件不是光靠纯 Java 代码运行的,它要查 Redis,要调 gRPC 服务,要用各种 @Service。这就意味着,插件里也得有个 Spring 容器。
但如果你直接在插件里跑一个 Spring Boot,它会试图去读各种环境变量、启动 Tomcat、抢占端口,直接就炸了。
我们需要的,是在 Host 的 Spring Context 之下,挂靠一个“子 Context”。Spring MVC 的 DispatcherServlet 就是这么干的,我们也可以。
当一个新的插件 Jar 包传上来,我的 PluginManager 会创建一个轻量级的 AnnotationConfigApplicationContext,并把这个 Context 的 ClassLoader 设置为刚才的 HotSwapClassLoader。最关键的一步,是显式调用 setParent(hostContext)。
有了这层父子关系,奇迹就发生了。插件里的代码可以肆无忌惮地使用 @Autowired 注入 Host 里的 RedisTemplate 或是 MyBatis Mapper。但反过来,Host 是死活看不到插件里的 Bean 的。这就形成了天然的单向隔离——插件随便搞,搞崩了也不会把 Host 整个应用的上下文污染。
永远跨不过去的坎:Metaspace OOM
如果你只做到了热加载,那是给研发埋了一颗定时炸弹。在生产环境,最惨痛的教训往往是被 Metaspace 撑爆导致的 OOM 甚至进程挂掉。
“加载”容易,“卸载”极难。
Java 没有提供直接卸载 Class 的 API。类的卸载,完全依赖于垃圾回收器认为这个类加载器(ClassLoader)没有任何存活的对象实例指向它。
在一次压测中,我们连续点击了几十次热更,发现 Metaspace 持续增长,死活不降。用 VisualVM dump 出堆栈一看,头骨发凉:有的引用残留在 Spring 的内部缓存里,有的是因为忘了清 ThreadLocal。
ThreadLocal 就是热部署的克星。只要某个工作线程池里的线程设置了 ThreadLocal,并且 Value 是插件里的对象实例(甚至仅仅是引用了插件的 Class),那么这个线程不死,ClassLoader 就别想被 GC 回收。
为了填这些坑,我们不得不:
- 规范所有插件禁止直接使用原生 ThreadLocal,必须走框架封装的 Context。
- 在 Spring 容器 close 触发销毁事件时,利用反射强行清理一些框架的内部缓存(比如 JDK 的
Introspector和一些 JSON 序列化库的缓存)。 - 引入优雅停机(Graceful Shutdown),用读写锁或者引用计数,确保当前正在执行旧版本逻辑的请求全部跑完,再实施卸载。
后记
手搓一个热插拔引擎,过程是痛苦的,甚至有很多黑魔法的味道。但做出来的那一刻,看着管理后台点击一个“Upload”,整个业务逻辑瞬间重载,且 QPS 一点没掉,那种成就感是难以言表的。
这其实不仅仅是一个技术痛点的解决。它本质上,是把基础架构的稳定性和上层业务的敏捷性,进行了解耦。底层修高速公路,上层负责造不同的车。
但这只是第一步。当你有了十几个热更的插件,怎么把它们串联起来?“第一步做向量召回,第二步看情况做热门兜底,第三步打分,最后根据 AB 测试的流量过不同的模型”。怎么把这些零散的算子粘合起来?
这就引出了下一个话题:业务链路的动态图编排设计。这个我们下篇再聊。