别再写面条代码了:搜广推系统中的动态图编排设计与演进

以前做搜索模块,我最怕接手别人留下的代码。

点开 SearchServiceImpl.java,里面一个 search() 方法一千多行。代码里密密麻麻充斥着这种逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
List<Item> results = new ArrayList<>();
// 先搞点向量召回
results.addAll(vectorRecall.search(req));

// 要是结果不够,再补点热门
if (results.size() < 100) {
results.addAll(hotRecall.search(req));
}

// 某个特殊打标的商品,强制干掉
filterService.filterSensitive(results);

// 如果是测试流量,走A模型,否则走B模型
if (ABTest.isHit("exp_group_1", req.getUserId())) {
modelAScorer.score(results);
} else {
modelBScorer.score(results);
}
// ......

刚开始觉得挺好懂的,逻辑直白。但随着业务发展,召回源变成了十几个,排序模型三天两头换,偶尔还要做各种大促活动的特殊逻辑插拔。这时候这坨代码就成了一座屎山。每次想加个哪怕是打印日志的逻辑,或者想把串行的两个召回改成并行执行来压低 RT(响应时间),都得小心翼翼地重构,心惊胆战地发版。

我们陷入了反思:业务逻辑应该是一张“图”,而不是一段“代码”。

迷雾中的选型:为什么是 LiteFlow?

阿里有 TPP,内部自研的强悍执行引擎,大家心生向往。但在开源世界自己搞一套,选型就成了个头疼的事。

有人提议用 Spring Cloud Data Flow,有人说上 Activiti 工作流引擎。都被我否了。
为什么?因为搜推系统的请求是核心在线流量,动辄几百上千 QPS,要求 P99 的延迟不能超过百毫秒。那些传统的工作流引擎为了保证事务和持久化,频繁与数据库交互,太“重”了。我们要的是一个在纯内存中急速流转的轻量级 DAG(有向无环图)执行引擎

最后我们锁定了 LiteFlow

它的设计哲学很合胃口:你用 Java 写好一个个原子的、无状态的“算子(节点)”,这就好比你造好了一块块乐高积木。怎么让这些积木拼成一辆车?不在 Java 里写,用它特有的 EL(表达式语法)写在配置里。

拆解与重组:从代码到蓝图

我们做了一次痛苦的重构。把原本糅合在一起的方法,强行拆成了几十个独立的 @LiteflowComponent 类。每个类只干一件事:纯召回、纯过滤、纯打分。

然后,那段一千行的面条代码,变成了一段类似这样的极简 EL 配置(可以存在数据库或 Nacos 里):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<chain name="core_search_chain">
THEN(
prepareParams, // 拼装参数
WHEN(
vectorRecall, // 向量召回(并行!)
hotRecall, // 热门召回(并行!)
actionRecall // 行为召回(并行!)
).ignoreError(true), // 就算某个召回挂了,别的继续跑,不影响大局

mergeAndDeduplicate, // 汇总去重

SWITCH(abTestNode).to( // 动态路由
modelARank,
modelBRank
),

packResponse // 打包返回
)
</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,不能手填配置,这时候这套花哨的玩法该怎么落地?这又是另一个折磨人的故事了,我们下周接着聊。