以前做搜索模块,我最怕接手别人留下的代码。
点开 SearchServiceImpl.java,里面一个 search() 方法一千多行。代码里密密麻麻充斥着这种逻辑:
1 | List<Item> results = new ArrayList<>(); |
刚开始觉得挺好懂的,逻辑直白。但随着业务发展,召回源变成了十几个,排序模型三天两头换,偶尔还要做各种大促活动的特殊逻辑插拔。这时候这坨代码就成了一座屎山。每次想加个哪怕是打印日志的逻辑,或者想把串行的两个召回改成并行执行来压低 RT(响应时间),都得小心翼翼地重构,心惊胆战地发版。
我们陷入了反思:业务逻辑应该是一张“图”,而不是一段“代码”。
迷雾中的选型:为什么是 LiteFlow?
阿里有 TPP,内部自研的强悍执行引擎,大家心生向往。但在开源世界自己搞一套,选型就成了个头疼的事。
有人提议用 Spring Cloud Data Flow,有人说上 Activiti 工作流引擎。都被我否了。
为什么?因为搜推系统的请求是核心在线流量,动辄几百上千 QPS,要求 P99 的延迟不能超过百毫秒。那些传统的工作流引擎为了保证事务和持久化,频繁与数据库交互,太“重”了。我们要的是一个在纯内存中急速流转的轻量级 DAG(有向无环图)执行引擎。
最后我们锁定了 LiteFlow。
它的设计哲学很合胃口:你用 Java 写好一个个原子的、无状态的“算子(节点)”,这就好比你造好了一块块乐高积木。怎么让这些积木拼成一辆车?不在 Java 里写,用它特有的 EL(表达式语法)写在配置里。
拆解与重组:从代码到蓝图
我们做了一次痛苦的重构。把原本糅合在一起的方法,强行拆成了几十个独立的 @LiteflowComponent 类。每个类只干一件事:纯召回、纯过滤、纯打分。
然后,那段一千行的面条代码,变成了一段类似这样的极简 EL 配置(可以存在数据库或 Nacos 里):
1 | <chain name="core_search_chain"> |
这段配置,我们叫它业务蓝图 (Blueprint)。
这样一来,开发团队的职责也清晰了。懂底层的 Java 开发,专门去优化“算子”的性能,搞各种并发控制和连接池;懂业务的策略同学,专门去调整这个“蓝图”,怎么搭积木能让转化率最高。这就叫真正的关注点分离。
踩坑:并行世界里的“幽灵”问题
理想很丰满,但现实中踩的坑一点不少。最大的麻烦出在多线程并行上。
在传统的单线程模式下,我们习惯用 ThreadLocal 传递一些隐式上下文,比如当前用户的 UserID、设备的 traceId 等等。
好家伙,在 LiteFlow 里一用 WHEN 关键字,底层直接启了线程池把任务拉平出去跑了。于是,在 vectorRecall 这个算子里一取 ThreadLocal,立马报出 NPT(NullPointerException),因为子线程根本拿不到父线程塞在 ThreadLocal 里的值。
这就是多线程环境下的上下文丢失问题。
网上的常规建议是换阿里的 TransmittableThreadLocal (TTL)。TTL 确实是个好东西,能自动做线程拷贝。但在极高频的在线系统中,全链路泛滥使用 ThreadLocal 并不优雅,而且会有隐蔽的内存泄漏风险。
我们最后定下的架构规约是:在引擎内部,彻底封杀隐式的上下文传递。
我们把参数封装成一个重型的、强类型的 SearchContext 对象。每次流转,LiteFlow 的源码支持我们将这个 Context 显式地当作参数传过每一个算子。通过 this.getContextBean(SearchContext.class) 获取。
大家虽然觉得每次都要传个对象有点麻烦,但这规避了极大地调试成本。在多线程编程里,越显式、越“笨”的设计,往往最后证明越靠谱。
结语
结合上一篇的“热插拔”架构,现在的我们已经可以做到:一不发版,二不重启,随时随地调整搜索链路的计算图模型。
这是一种掌控感,也是工程架构演进带给业务的真正红利。
但在一些特殊的业务线,比如我们要向一些金融大客户或保密单位输出这套系统,情况就截然不同了。服务器都在物理隔离(Air Gap)的安全屋里,没有外网,没有 CI/CD,不能手填配置,这时候这套花哨的玩法该怎么落地?这又是另一个折磨人的故事了,我们下周接着聊。