Elasticsearch 查询 Query 与 Fetch 全过程
很多面试回答会说“ES 查询分为 Query Phase 和 Fetch Phase”。这只是标题。真正要讲清楚的是:请求为什么先到协调节点,为什么每个分片只返回 TopN,为什么深分页慢,filter 为什么通常更快,排序、聚合、高亮分别在哪些环节增加成本。
这一页用商品搜索把 ES 查询全过程拆开。
这一页解决这些问题
| 问题 | 你要掌握到什么程度 |
|---|---|
| 查询请求怎么在集群里流转 | 知道协调节点、目标分片、本地查询、全局合并 |
| Query Phase 做什么 | 知道每个分片本地召回、过滤、打分、排序 TopN |
| Fetch Phase 做什么 | 知道按 doc id 拉取 _source、高亮和返回字段 |
| 深分页为什么慢 | 知道每个分片都要取 from + size,协调节点再丢弃 |
| filter 为什么快 | 知道它不算分,适合硬条件和缓存 |
| 商业项目怎么设计查询 | 知道商品、订单、资产检索如何组装受控 DSL |
一个商业搜索请求
用户在商品搜索页输入:
关键词:蓝牙降噪耳机
品牌:SoundMax
价格:100 到 500
状态:上架
排序:综合排序
页码:第 1 页,每页 20 条后端不应该把前端传来的内容原样拼成任意 DSL,而是把业务参数翻译成受控查询:
{
"from": 0,
"size": 20,
"query": {
"bool": {
"must": [
{ "match": { "title": "蓝牙降噪耳机" } }
],
"filter": [
{ "term": { "brand.keyword": "SoundMax" } },
{ "term": { "status": "ON_SALE" } },
{ "range": { "price": { "gte": 100, "lte": 500 } } }
],
"should": [
{ "term": { "tags.keyword": { "value": "官方自营", "boost": 2 } } }
]
}
}
}这里的分工很重要:
| 条件 | 放在哪里 | 原因 |
|---|---|---|
| 关键词 | must match | 需要全文检索和相关性评分 |
| 品牌、状态、价格 | filter | 是硬条件,不需要参与评分 |
| 运营标签、销量权重 | should 或 function score | 用于提高排序,不一定必须命中 |
查询整体流程
flowchart TD
A["客户端发送搜索请求"] --> B["协调节点接收"]
B --> C["解析 Query DSL"]
C --> D["确定目标分片"]
D --> E["Query Phase:分片本地查询"]
E --> F["每个分片返回本地 TopN"]
F --> G["协调节点合并全局 TopN"]
G --> H["Fetch Phase:拉取文档内容"]
H --> I["返回命中、分页、高亮、聚合"]协调节点像一个总调度员。它自己不一定有数据,但负责把查询发到相关分片,拿到每个分片的候选结果,再合并成最终结果。
第一步:确定目标分片
如果查询没有指定 routing,ES 通常要查询这个索引的所有相关主分片或副本分片。每个分片保存一部分数据,所以必须让每个分片都参与,才能得到完整结果。
flowchart TD
A["协调节点"] --> B["shard 0"]
A --> C["shard 1"]
A --> D["shard 2"]
B --> E["本地 TopN"]
C --> F["本地 TopN"]
D --> G["本地 TopN"]
E --> H["协调节点合并"]
F --> H
G --> H副本也可以处理查询。ES 会在主分片和副本之间选择一个可用副本执行查询,用来分摊读压力。
第二步:Query Phase 做什么
Query Phase 的目标是:每个分片在自己的数据里找到最相关的候选结果,并返回轻量信息给协调节点。
分片本地会做这些事:
flowchart TD
A["分片收到查询"] --> B["分析 match 查询词"]
B --> C["查倒排索引"]
C --> D["得到候选 docId"]
D --> E["执行 filter 过滤"]
E --> F["计算 BM25 或业务评分"]
F --> G["按分数或排序字段取本地 TopN"]
G --> H["返回 docId、score、sort values"]这里通常不会立即把完整 _source 返回给协调节点,因为完整文档可能很大。先返回 docId、score、sort values,可以减少网络传输。
| 环节 | 做什么 | 成本来源 |
|---|---|---|
| 分词 | 把查询词拆成 token | 分词器复杂度 |
| 倒排索引查找 | 根据 token 找 posting list | 候选文档数量 |
| filter | 状态、权限、价格过滤 | 范围大小、缓存命中 |
| score | BM25、boost、function score | 候选文档数量和脚本复杂度 |
| sort | 按字段或分数排序 | doc values、堆排序 |
第三步:协调节点合并 TopN
假设有 3 个分片,每个分片返回前 20 条。协调节点要把这些局部结果合并成全局前 20 条。
shard0: A1 A2 A3 ... A20
shard1: B1 B2 B3 ... B20
shard2: C1 C2 C3 ... C20
coordinator merge -> global top 20如果按 _score 排序,协调节点比较分数。如果按价格、时间排序,协调节点比较排序字段。
这解释了为什么分片数量不是越多越好。分片多了,本地查询虽然并行,但协调节点要处理更多请求和更多候选结果,网络、CPU、内存开销都会上升。
第四步:Fetch Phase 做什么
Query Phase 得到的是“哪些文档排在前面”。Fetch Phase 才去对应分片取完整内容。
flowchart TD
A["协调节点拿到全局 TopN"] --> B["按 docId 找到所在分片"]
B --> C["向分片请求 _source"]
C --> D["读取 stored fields 或 _source"]
D --> E["执行高亮、字段过滤"]
E --> F["返回最终结果"]Fetch Phase 的成本常见来自:
| 成本 | 说明 |
|---|---|
_source 很大 | 商品详情、富文本、大 JSON 会增加 IO 和网络 |
| 高亮 | 需要重新分析文本或读取 offset |
| 返回字段过多 | 后端和前端都浪费传输 |
| inner_hits | 嵌套文档返回成本高 |
所以搜索列表页不要返回完整详情,只返回列表展示需要的字段。
深分页为什么慢
from + size 是 ES 面试和线上优化高频点。
假设:
{
"from": 10000,
"size": 20
}这不是“跳过 10000 条后直接取 20 条”。在分布式场景里,每个分片都要准备足够多的候选结果。
flowchart TD
A["from=10000,size=20"] --> B["每个分片取 10020 条候选"]
B --> C["协调节点合并大量候选"]
C --> D["丢弃前 10000 条"]
D --> E["返回 20 条"]如果有 5 个分片,每个分片可能都要返回 10020 条候选给协调节点,协调节点合并 50100 条后再丢弃前 10000 条。页码越深,浪费越严重。
| 方案 | 适用场景 | 说明 |
|---|---|---|
| 限制最大页数 | 普通搜索页 | 例如只允许翻到前 100 页 |
search_after | 无限滚动、下一页 | 使用上一页最后一条 sort values |
| PIT | 翻页期间保持一致视图 | Point In Time,避免翻页期间数据变化导致重复/遗漏 |
| Scroll | 后台批处理导出 | 不推荐给用户实时分页 |
search_after 示例:
{
"size": 20,
"query": {
"term": {
"status": "ON_SALE"
}
},
"sort": [
{ "updatedAt": "desc" },
{ "productId": "asc" }
],
"search_after": [1783230000000, 1001]
}注意排序字段必须稳定,通常加一个唯一字段作为兜底排序,比如 productId。
filter 为什么通常更快
filter 和 must 最大的区别是:filter 不参与相关性评分。
flowchart TD
A["查询条件"] --> B{"是否需要影响排序分数"}
B -- "需要" --> C["放 must 或 should"]
B -- "不需要" --> D["放 filter"]
D --> E["可使用 bitset 和缓存"]状态、租户、权限、类目、价格范围这类条件通常只是“要不要这条记录”,不需要影响文本相关性,所以放 filter 更合适。
| 条件 | 推荐位置 | 说明 |
|---|---|---|
| 商品名包含关键词 | must | 要参与相关性评分 |
| 商品状态上架 | filter | 硬条件 |
| 当前用户可见组织 | filter | 权限条件 |
| 类目、品牌 | filter | 筛选条件 |
| 官方自营提高排序 | should | 命中后加分 |
聚合、排序、高亮各自为什么慢
| 功能 | 底层依赖 | 慢的原因 | 优化方向 |
|---|---|---|---|
| 排序 | doc values | 候选集大、排序字段缺失、脚本排序 | 缩小范围、使用 keyword/number/date、避免脚本 |
| 聚合 | doc values | bucket 太多、范围太大、嵌套聚合深 | 限制时间范围、预聚合、控制 bucket |
| 高亮 | postings/term vectors/source | 大字段、高亮片段多 | 只对必要字段高亮 |
| wildcard | term 字典扫描 | 前缀 *abc 很难定位 | ngram、search_as_you_type |
| script score | 脚本执行 | 每个候选都计算 | 预计算字段、限制候选集 |
商业场景:订单后台检索
订单检索通常不是纯全文搜索,而是精确过滤、时间范围和权限过滤更多。
flowchart TD
A["运营后台输入查询条件"] --> B["后端校验租户和权限"]
B --> C["组装 bool query"]
C --> D["订单状态、机构、时间放 filter"]
C --> E["手机号后四位、商品名按字段选择查询"]
D --> F["ES 返回订单列表"]
E --> F
F --> G["详情页回订单服务或 MySQL 校验事实状态"]订单搜索设计要特别注意:
- 权限条件必须由后端加,不能让前端自己传。
- 查询结果用于展示和定位订单,不能作为支付、退款、发货的最终依据。
- 列表页只返回必要字段,详情页再查订单服务。
- 时间范围必须有限制,避免全历史大范围聚合。
可运行 Demo:Java 组装受控查询
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.json.JsonData;
import java.io.IOException;
import java.util.List;
public class ProductSearchService {
private final ElasticsearchClient client;
public ProductSearchService(ElasticsearchClient client) {
this.client = client;
}
public List<ProductDoc> search(String keyword, String brand, int minPrice, int maxPrice) throws IOException {
SearchResponse<ProductDoc> response = client.search(s -> s
.index("product_search")
.from(0)
.size(20)
.query(q -> q.bool(b -> b
.must(m -> m.match(mm -> mm.field("title").query(keyword)))
.filter(f -> f.term(t -> t.field("brand.keyword").value(brand)))
.filter(f -> f.term(t -> t.field("status").value("ON_SALE")))
.filter(f -> f.range(r -> r
.field("price")
.gte(JsonData.of(minPrice))
.lte(JsonData.of(maxPrice))
))
))
.source(src -> src.filter(f -> f.includes(
"productId", "title", "brand", "price", "status"
))),
ProductDoc.class
);
return response.hits().hits().stream()
.map(Hit::source)
.toList();
}
public record ProductDoc(Long productId, String title, String brand, Integer price, String status) {}
}这个 Demo 的重点不是 API 语法,而是查询设计:
- 前端传业务参数,后端翻译成受控 DSL。
- 关键词走
match。 - 品牌、状态、价格走
filter。 _source只返回列表需要的字段。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 前端直接传 ES DSL | 越权、慢查询、脚本攻击、拖垮集群 | 后端只开放业务参数 |
所有条件都放 must | 不必要评分,性能差,排序混乱 | 硬条件放 filter |
| 深分页无限开放 | 协调节点内存和 CPU 压力大 | 限制页码,用 search_after/PIT |
返回完整 _source | 网络和序列化成本高 | 列表页字段裁剪 |
| 大范围聚合 | 查询慢、内存压力大 | 限制范围,预聚合 |
| 脚本排序滥用 | 每个候选执行脚本 | 预计算排序字段 |
排查方法
| 问题 | 排查顺序 |
|---|---|
| 查询慢 | slowlog、profile、DSL、分片数、深分页、聚合、wildcard、脚本 |
| 结果少 | 查 filter 是否误伤、权限条件、状态条件、分词 |
| 排序怪 | _explain、BM25、boost、业务排序字段、缺失值 |
| 高亮慢 | 字段长度、片段数量、是否必要 |
| 线上偶发慢 | hot threads、GC、磁盘 IO、线程池 rejected、热点分片 |
常用命令:
curl -X GET "http://localhost:9200/product_search/_search?pretty" \
-H "Content-Type: application/json" \
-d '{ "profile": true, "query": { "match": { "title": "蓝牙耳机" } } }'
curl "http://localhost:9200/_cat/thread_pool/search?v"
curl "http://localhost:9200/_nodes/hot_threads"面试标准回答
ES 查询通常分 Query Phase 和 Fetch Phase。客户端请求先到协调节点,协调节点把查询发到相关分片。Query Phase 中,每个分片在本地根据倒排索引召回候选文档,执行 filter,计算相关性评分或排序值,并返回本地 TopN 的 docId、score、sort values。协调节点把多个分片的本地 TopN 合并成全局 TopN。
Fetch Phase 中,协调节点根据全局 TopN 再到对应分片拉取 _source、stored fields、高亮等内容,组装最终响应。
深分页慢是因为 from 很大时,每个分片都要取 from + size 条候选,协调节点合并后再丢弃前面的结果,页码越深浪费越大。商业项目一般限制页码,用 search_after 和 PIT 做深度翻页。硬过滤条件应该放 filter,因为不参与评分,适合状态、租户、权限、价格范围等条件。关联知识点
| 知识点 | 说明 |
|---|---|
| 写入、Refresh 与 Segment | 为什么写入成功不等于可搜索 |
| 倒排索引与 BM25 | Query Phase 中召回和评分的底层原理 |
| Mapping 与查询 | term、match、bool、分页、聚合 |
| 性能优化与排查 | 慢查询和搜不到的排查链路 |
| 集群 | 分片、副本、协调节点与健康状态 |
