1. 为什么需要策略化召回平台
在搜索与推荐系统中,召回环节承担着从海量数据中筛选候选集的重要职责。传统基于Elasticsearch的召回方案通常面临三个典型问题:
- 策略僵化:业务规则变更需要修改代码并重新部署,响应周期长
- 效果黑盒:不同召回策略的效果对比缺乏实时数据支撑
- 资源浪费:为每个业务场景单独维护ES集群导致资源利用率低下
我们团队在电商搜索业务中曾遇到一个典型案例:大促期间需要临时调整商品召回权重(提升库存充足商品的排序),从需求提出到全量上线耗时2天,错过了流量高峰。这种经历促使我们设计了一套支持热更新的策略化召回平台。
2. 核心架构设计
2.1 系统分层模型
平台采用四层架构设计,各层之间通过Protobuf协议通信:
code复制[接入层] -> [策略层] -> [执行层] -> [存储层]
接入层提供RESTful API和gRPC两种接口,关键设计点包括:
- 请求参数标准化(包含user_id、scene_type等必填字段)
- 支持A/B测试流量标记(通过X-Experiment-Header传递)
- 请求耗时监控(基于Micrometer实现)
策略层的核心组件是规则引擎,我们对比了三种方案:
| 方案 | 优点 | 缺点 | 选型理由 |
|---|---|---|---|
| Drools | 成熟稳定 | 学习成本高 | 不适合动态规则 |
| Aviator | 轻量高效 | 功能有限 | 最终选择 |
| Groovy | 灵活强大 | 安全风险 | 性能较差 |
选择Aviator主要考虑其:
- 支持表达式预编译(性能提升40%+)
- 内置安全沙箱机制
- 与Java生态无缝集成
2.2 弹性执行设计
执行层采用线程池隔离方案确保稳定性:
java复制// 线程池配置示例
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // corePoolSize
50, // maximumPoolSize
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue(1000),
new ThreadFactoryBuilder().setNameFormat("es-recall-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
关键参数调优经验:
- 队列容量不宜过大(避免OOM)
- 拒绝策略选择CallerRunsPolicy(保证流量不丢失)
- 监控线程活跃数(通过Prometheus暴露指标)
3. ES集群优化实践
3.1 索引设计规范
我们制定了严格的索引模板规范:
json复制{
"order": 0,
"index_patterns": ["goods_*"],
"settings": {
"number_of_shards": 10,
"number_of_replicas": 1,
"refresh_interval": "30s"
},
"mappings": {
"dynamic": "strict",
"properties": {
"spu_id": {"type": "keyword"},
"weight": {"type": "double"},
"tags": {
"type": "nested",
"properties": {...}
}
}
}
}
重要提示:避免使用动态映射(dynamic: true),这会导致字段类型污染问题
3.2 查询性能优化
通过Profile API发现的典型问题及解决方案:
-
深度分页性能差
- 使用search_after替代from/size
- 配合PIT(Point In Time)保证一致性
-
嵌套查询开销大
- 将nested查询改写为parent-child关系
- 对嵌套文档数量设置上限(通过script_score实现)
-
聚合计算耗时长
- 启用docvalue_fields替代fielddata
- 对高基数字段使用cardinality+precision_threshold
4. 策略管理实现
4.1 规则配置DSL
设计类SQL的规则配置语言:
code复制WHEN
scene_type == 'search'
AND user_level > 3
THEN
BOOST(title:{{keyword}}^2, category:{{keyword}})
WITH
timeout = 500,
track_total_hits = false
语法特性包括:
- 变量插值({{variable}})
- 条件表达式(支持&&、||等运算符)
- 执行参数控制(timeout等)
4.2 灰度发布方案
采用双阶段发布策略:
- 流量嗅探阶段:将5%流量导入新策略,只记录不生效
- 效果验证阶段:对比新老策略的CTR、停留时长等核心指标
关键监控指标看板:
- 召回率(Recall@100)
- 响应时间P99
- 错误码分布(特别是429状态码)
5. 生产环境踩坑实录
5.1 字段类型冲突
现象:某次商品属性变更后,召回结果异常
根因:动态映射导致price字段在不同分片分别被识别为double和text
解决方案:
- 通过reindex API重建索引
- 添加明确的字段mapping
- 部署前置校验脚本
5.2 集群脑裂问题
某次机房网络抖动后,出现查询结果不一致情况。处理过程:
- 通过_cat/nodes?v确认主分片分布
- 调整discovery.zen.minimum_master_nodes为(N/2)+1
- 设置indices.recovery.max_bytes_per_sec限制恢复流量
6. 性能压测数据
使用JMeter模拟的真实流量测试结果(8C16G节点):
| QPS | 平均延迟 | 错误率 | 备注 |
|---|---|---|---|
| 500 | 68ms | 0% | 基线 |
| 1000 | 112ms | 0.2% | 出现GC |
| 1500 | 238ms | 1.5% | 开始丢包 |
优化措施:
- 调整JVM堆大小为12G(预留4G给系统)
- 启用G1垃圾回收器
- 对查询DSL进行预处理缓存
这套架构已在日均千万级查询的生产环境稳定运行两年多,策略变更生效时间从小时级缩短到分钟级。一个意外的收获是:由于统一了召回入口,我们发现了多个业务线重复建设的ES集群,最终节省了40%的硬件成本。
