通用型聚合搜索设计文档
1. 文档定位
本文沉淀一套可复用的聚合搜索设计思路,适用于需要在一个搜索入口中接入多种业务数据、内部数据库、搜索引擎或外部 API 的系统。
它不限定具体业务。商品、课程、文章、用户、视频、图片、公众号文章、企业信息等,都可以按照这套思路接入。
本文解决的核心问题是:
用户只输入一次关键词,系统如何统一调度多个不同的数据源,并以稳定、可扩展的方式返回结果?
2. 什么是聚合搜索
聚合搜索是指:系统对外提供一个统一的搜索入口,内部根据搜索类型或业务规则,调用多个独立的数据源,最后将结果组合返回。
这里的“聚合”是业务能力聚合,不是 Elasticsearch 的 aggs 统计聚合。
聚合接口并不要求所有数据存放在同一个地方:
统一搜索接口
↓
多个数据源
├─ MySQL
├─ Elasticsearch
├─ 第三方 API
├─ 网页抓取
└─ 其他内部服务
3. 适用场景
当系统出现下面任意需求时,可以考虑聚合搜索:
- 一个搜索框需要同时搜索多个业务对象。
- 不同业务对象的存储位置和查询方式不同。
- 希望前端只调用一个接口。
- 后续可能持续增加新的搜索类型。
- 某些结果来自外部 API 或第三方平台。
- 希望把数据源的具体实现细节隔离在后端内部。
不适合强行聚合的场景包括:
- 业务只需要查询单张表,且没有扩展计划。
- 所有结果必须进行严格的全局相关性排序,但不同来源无法提供统一评分。
- 外部数据源不稳定,且系统没有容错、缓存和限流能力。
4. 设计目标
一套合格的聚合搜索设计,通常需要满足以下目标:
- 统一入口:前端不需要知道每个数据源的接口地址。
- 统一协议:不同数据源尽量使用一致的关键词、分页和结果封装方式。
- 解耦实现:新增一种数据源时,尽量不修改核心聚合逻辑。
- 独立演进:每个数据源可以独立更换数据库、搜索引擎或第三方服务。
- 可控失败:单个数据源异常时,可以根据业务要求选择整体失败或局部降级。
- 可观测:能够知道每个数据源耗时、命中数和失败原因。
- 可治理:明确数据来源、授权范围、更新频率和合规边界。
5. 推荐架构
推荐将系统拆成以下角色:
Controller
↓
Facade / Orchestrator
↓
DataSourceRegistry
↓
DataSource Adapter
↓
具体查询实现
各层职责如下:
| 角色 | 主要职责 | 不负责什么 |
|---|---|---|
| Controller | 接收请求、参数初步校验、返回统一响应 | 不直接操作数据库或第三方 API |
| Facade / Orchestrator | 分发任务、并发执行、聚合结果、统一异常策略 | 不承载每个数据源的查询细节 |
| Registry | 根据类型找到对应数据源 | 不执行具体查询 |
| DataSource Adapter | 将统一请求转换为数据源专属请求 | 不决定全局业务流程 |
| Repository / Client | 访问 MySQL、ES、外部 API 等 | 不组装跨数据源结果 |
| Result Assembler | 统一结果格式、分类或排序 | 不隐藏关键错误 |
6. 统一数据源接口
最小可行接口可以设计成:
public interface DataSource<T> {
Page<T> doSearch(String searchText, long pageNum, long pageSize);
}
实际项目中建议逐步扩展成统一请求对象:
public interface SearchDataSource<T> {
SearchResult<T> search(SearchContext context);
}
统一上下文可以包含:
关键词
搜索类型
页码
分页大小
排序方式
过滤条件
当前用户
请求 traceId
超时时间
统一接口的价值不在于让所有数据源内部实现相同,而在于让聚合层调用方式稳定。
例如:
用户数据源:MySQL LIKE 或专门的用户索引
商品数据源:ES 全文检索
图片数据源:第三方图片 API
公众号数据源:授权的内容服务 API
它们内部完全不同,但上层都可以按照统一协议调用。
7. 两种聚合模式
7.1 指定类型搜索
用户明确选择一种搜索类型时,只调用对应数据源:
type = article
↓
ArticleDataSource
↓
返回文章结果
适合分类页、单独的搜索标签页和需要更丰富筛选条件的场景。
7.2 全类型搜索
用户没有指定类型时,同时调用多个数据源:
type = all / empty
↓
并发调用用户、文章、图片、视频等数据源
↓
分别整理结果
↓
返回分类聚合结果
全类型搜索有两种返回方式:
分类返回
{
"users": [],
"articles": [],
"pictures": []
}
优点是结构清晰、实现简单,不同来源之间不需要比较分数。
统一列表返回
{
"items": [
{ "type": "article", "score": 0.91, "data": {} },
{ "type": "user", "score": 0.75, "data": {} }
]
}
这种方式需要解决跨数据源评分、字段统一、结果去重和排序问题,复杂度明显更高。除非业务明确要求,否则建议优先采用分类返回。
8. 接口契约设计
8.1 请求模型
通用请求可以设计为:
{
"searchText": "关键词",
"type": "article",
"current": 1,
"pageSize": 10,
"sortField": "createTime",
"sortOrder": "desc"
}
建议约定:
| 字段 | 建议规则 |
|---|---|
searchText |
去除首尾空格,限制最大长度,必要时做敏感词处理 |
type |
使用稳定的枚举值,不直接使用中文展示名称 |
current |
从 1 开始,禁止小于 1 |
pageSize |
设置上限,防止一次返回过多数据 |
sortField |
使用白名单,禁止直接拼接用户输入 |
sortOrder |
只允许 asc 或 desc |
8.2 响应模型
指定类型时可以统一返回:
{
"code": 0,
"data": {
"type": "article",
"records": [],
"total": 0,
"current": 1,
"pageSize": 10,
"tookMs": 32
},
"message": "ok"
}
全类型分类返回可以设计为:
{
"code": 0,
"data": {
"users": {
"records": [],
"total": 0
},
"articles": {
"records": [],
"total": 0
},
"pictures": {
"records": [],
"total": null
},
"meta": {
"partial": false,
"failedSources": []
}
},
"message": "ok"
}
外部 API 通常无法准确返回总数,可以允许 total 为 null,不要伪造一个看似精确的数量。
9. 数据源注册与路由
不建议在 Facade 中堆积大量 if-else:
if ("user".equals(type)) {
// 搜用户
} else if ("article".equals(type)) {
// 搜文章
} else if ("picture".equals(type)) {
// 搜图片
}
可以使用注册器:
Map<String, SearchDataSource<?>> dataSources = Map.of(
"user", userDataSource,
"article", articleDataSource,
"picture", pictureDataSource
);
路由流程:
请求 type
↓
校验 type 是否支持
↓
Registry 查找 DataSource
↓
调用统一 search 方法
新增数据源时,通常只需要:
- 定义结果对象。
- 编写数据源适配器。
- 接入具体 Repository 或第三方 Client。
- 注册类型。
- 增加接口和异常测试。
10. 并发聚合设计
全类型搜索通常适合并发调用:
CompletableFuture<Result<User>> userTask =
CompletableFuture.supplyAsync(() -> userSource.search(context));
CompletableFuture<Result<Article>> articleTask =
CompletableFuture.supplyAsync(() -> articleSource.search(context));
CompletableFuture.allOf(userTask, articleTask).join();
但并发不是越多越好,需要考虑:
- 线程池大小是否受控。
- 外部 API 是否有并发限制。
- 单个数据源超时是否会拖慢整体接口。
- 是否需要隔离不同数据源的线程池。
- 是否需要取消超时任务。
- 是否需要记录每个分支的耗时。
生产环境建议使用自定义线程池,而不是无条件使用公共线程池:
aggregate-search-pool
├─ 核心线程数
├─ 最大线程数
├─ 队列长度
└─ 拒绝策略
11. 异常与降级策略
聚合接口需要先明确业务选择:
11.1 整体失败
任何一个关键数据源失败,整个请求返回错误。
适合:所有数据源都是核心结果,缺少任何一个都会导致业务不可用。
11.2 局部降级
某个数据源失败时,保留其他成功结果,并返回失败来源信息:
{
"meta": {
"partial": true,
"failedSources": ["picture"]
}
}
适合:图片、推荐、第三方内容等非核心数据源。
11.3 空结果降级
数据源超时后返回空列表,并记录监控告警。该方式必须在响应中标记来源不可用,否则前端和用户会误以为确实没有搜索结果。
建议为每个数据源定义:
| 配置项 | 说明 |
|---|---|
| 超时时间 | 超过时间后终止或忽略该分支 |
| 是否核心 | 决定失败时整体失败还是局部降级 |
| 重试次数 | 外部网络错误是否重试 |
| 是否缓存 | 是否允许返回短时间旧数据 |
| 失败提示 | 是否向前端展示 |
12. 分页、排序与去重
12.1 分类分页
每个数据源独立分页:
用户第 1 页
文章第 1 页
图片第 1 页
这是最简单、最稳定的方案。不同来源不需要共享总数和排序分数。
12.2 全局分页
如果要求所有类型混合成一条列表,就必须解决:
- 不同数据源的评分如何归一化。
- 哪类结果应该优先展示。
- 不同来源的
total如何合并。 - 相同内容如何去重。
- 下一页如何保持稳定。
通常需要引入统一的排序分数:
finalScore = sourceWeight × sourceScore
但这只是工程折中,不代表不同数据源的分数天然可比。
12.3 去重策略
跨数据源去重时,优先选择稳定业务标识:
source + sourceId
不要只用标题或 URL 判断重复,因为标题可能相同,URL 也可能发生变化。
13. Elasticsearch 与聚合搜索的关系
聚合搜索不等于“所有数据都放入 ES”。ES 只是其中一种数据源。
常见组合是:
聚合入口
├─ 用户 → MySQL
├─ 文章 → Elasticsearch + MySQL 回源
├─ 图片 → 第三方 API
└─ 视频 → 视频平台 API
当某类内容需要全文搜索、分词、相关性排序和高性能分页时,可以为该类型建立独立 ES 索引。
如果 ES 只是索引副本,建议采用:
主数据库保存权威数据
ES 保存搜索字段和排序所需字段
搜索命中后按 ID 回源主数据库
这样可以避免把点赞数、库存、权限、审核状态等高频变化或高敏感字段完全交给搜索索引维护。
14. 数据同步设计
使用 ES 的数据源必须回答三个问题:
- ES 中的数据从哪里来?
- 数据什么时候更新?
- 更新失败后如何补偿?
常见同步方式:
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 定时同步 | 实现简单,有一定延迟 | 学习项目、低实时场景 |
| 业务双写 | 延迟低,但一致性处理复杂 | 小型系统、可接受补偿 |
| 消息队列 | 解耦、可重试 | 中大型业务 |
| Binlog / Canal | 减少业务侵入,接近实时 | 生产级数据同步 |
| Logstash 等管道 | 配置化、适合批量同步 | 数据管道场景 |
同步文档至少需要记录:
- 全量初始化方法。
- 增量同步触发方式。
- 删除和逻辑删除如何处理。
- 重试和死信处理。
- 索引重建方法。
- 同步延迟指标。
- 数据校验和对账方式。
15. 外部数据源接入
对于第三方 API、网页抓取和内容合作接口,不能只记录“调用地址”,还需要记录业务边界:
15.1 接入前确认
- 是否有公开 API 或正式授权。
- 是否需要账号认证或商业资质。
- 是否允许搜索、存储和展示返回内容。
- 是否限制调用频率、并发数和 IP。
- 是否要求展示来源和版权信息。
- 数据是否允许缓存或建立自己的索引。
15.2 运行时保护
- 连接超时和读取超时。
- 重试次数和退避策略。
- 限流和熔断。
- 结果缓存。
- 外部结构变化监控。
- 敏感信息和密钥保护。
- 来源字段和更新时间记录。
15.3 合规边界
网页可访问不代表可以任意抓取、保存和再分发。接入前应遵守目标平台的服务条款、robots 规则、版权要求和适用法律法规。
16. 安全设计
聚合搜索常见安全风险包括:
| 风险 | 典型问题 | 防护方式 |
|---|---|---|
| SQL 注入 | 排序字段直接拼接 SQL | 字段白名单 |
| ES 查询注入 | 直接接受任意查询 DSL | 服务端构造查询 |
| SSRF | 用户控制外部请求地址 | 固定域名白名单 |
| 爬虫滥用 | pageSize 过大、频繁请求 | 限流、分页上限、验证码 |
| 密钥泄露 | API Key 出现在前端或日志 | 后端保管、脱敏日志 |
| 内容风险 | 外部结果含恶意链接或脚本 | 输出编码、内容审核 |
| 权限绕过 | 搜索返回不应公开的数据 | 在数据源层执行权限过滤 |
搜索只是入口,不应绕过业务权限。每个数据源都要明确“哪些结果允许当前用户看到”。
17. 可观测性
建议为一次聚合请求生成 traceId,并记录:
traceId
searchType
searchText(必要时脱敏)
sourceName
sourceStatus
sourceLatency
resultCount
cacheHit
errorCode
推荐监控指标:
- 聚合接口总耗时。
- 各数据源平均耗时和 P95/P99。
- 各数据源错误率。
- 超时次数。
- 空结果比例。
- 缓存命中率。
- ES 查询耗时和命中数。
- 外部 API 配额消耗。
18. 测试清单
18.1 单元测试
- 每个数据源能根据统一请求生成正确的内部查询。
- Registry 能正确路由类型。
- Facade 能正确组装分类结果。
- 指定类型和全类型分支行为正确。
- 分页、排序和过滤参数校验正确。
18.2 异常测试
- 某个数据源超时。
- 某个数据源返回空结果。
- 某个数据源返回格式错误。
- ES 不可用。
- MySQL 回源失败。
- 外部 API 达到限流阈值。
- 所有数据源同时失败。
18.3 一致性测试
- 主数据库新增数据后是否最终可搜索。
- 修改标题后旧标题是否会消失。
- 删除或逻辑删除后是否仍会返回。
- ES 有脏数据但主数据库不存在时是否能清理。
- 同一请求中的结果顺序是否稳定。
18.4 性能测试
- 单类型搜索吞吐量。
- 全类型并发搜索耗时。
- 最慢数据源对整体响应的影响。
- 外部 API 慢响应时的线程池占用。
- 大关键词、高并发和大分页请求。
19. 推荐落地步骤
可以按照下面的顺序设计一套新的聚合搜索:
- 明确搜索对象和用户期望的结果形态。
- 列出每个对象的真实数据来源。
- 判断每个来源是否需要全文检索、缓存或同步。
- 确定分类返回还是统一列表返回。
- 设计统一请求和响应模型。
- 定义数据源接口。
- 为每个来源编写独立适配器。
- 使用 Registry 完成类型路由。
- 在 Facade 中实现指定类型和全类型流程。
- 明确超时、重试、降级和权限策略。
- 增加日志、指标和链路追踪。
- 用测试验证正常、异常和一致性场景。
20. 可复用文档模板
以后设计类似聚合能力时,可以直接复制下面的结构:
# XXX 聚合搜索业务文档
## 1. 业务目标
- 用户为什么需要统一搜索?
- 搜索结果解决什么问题?
## 2. 搜索范围
| 类型 | 标识 | 数据来源 | 状态 |
| --- | --- | --- | --- |
## 3. 接口契约
### 请求
### 响应
### 分页和排序
## 4. 整体架构
- Controller
- Facade
- Registry
- DataSource
- Repository / Third-party Client
## 5. 数据源说明
### 5.1 数据源 A
### 5.2 数据源 B
## 6. 指定类型搜索流程
## 7. 全类型搜索流程
## 8. 并发、超时与降级
## 9. 数据同步与一致性
## 10. 权限、安全与合规
## 11. 缓存与性能
## 12. 监控与日志
## 13. 测试和验收标准
## 14. 后续扩展点
## 15. 源码或服务定位
21. 最终原则
设计聚合搜索时,可以记住四句话:
- 一个入口,不等于一个存储。
- 统一调用,不等于内部实现相同。
- 并发查询,不等于可以忽略超时和降级。
- 能搜到数据,不等于拥有随意抓取和展示数据的权利。
真正可复用的聚合设计,应该让新增数据源变得容易,让异常边界变得清楚,让前端调用保持稳定,也让后续的性能、一致性和合规治理有明确落点。