Skip to content

Elasticsearch 查询 Query 与 Fetch 全过程

很多面试回答会说“ES 查询分为 Query Phase 和 Fetch Phase”。这只是标题。真正要讲清楚的是:请求为什么先到协调节点,为什么每个分片只返回 TopN,为什么深分页慢,filter 为什么通常更快,排序、聚合、高亮分别在哪些环节增加成本

这一页用商品搜索把 ES 查询全过程拆开。

这一页解决这些问题

问题你要掌握到什么程度
查询请求怎么在集群里流转知道协调节点、目标分片、本地查询、全局合并
Query Phase 做什么知道每个分片本地召回、过滤、打分、排序 TopN
Fetch Phase 做什么知道按 doc id 拉取 _source、高亮和返回字段
深分页为什么慢知道每个分片都要取 from + size,协调节点再丢弃
filter 为什么快知道它不算分,适合硬条件和缓存
商业项目怎么设计查询知道商品、订单、资产检索如何组装受控 DSL

一个商业搜索请求

用户在商品搜索页输入:

text
关键词:蓝牙降噪耳机
品牌:SoundMax
价格:100 到 500
状态:上架
排序:综合排序
页码:第 1 页,每页 20 条

后端不应该把前端传来的内容原样拼成任意 DSL,而是把业务参数翻译成受控查询:

json
{
  "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用于提高排序,不一定必须命中

查询整体流程

mermaid
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 通常要查询这个索引的所有相关主分片或副本分片。每个分片保存一部分数据,所以必须让每个分片都参与,才能得到完整结果。

mermaid
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 的目标是:每个分片在自己的数据里找到最相关的候选结果,并返回轻量信息给协调节点

分片本地会做这些事:

mermaid
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状态、权限、价格过滤范围大小、缓存命中
scoreBM25、boost、function score候选文档数量和脚本复杂度
sort按字段或分数排序doc values、堆排序

第三步:协调节点合并 TopN

假设有 3 个分片,每个分片返回前 20 条。协调节点要把这些局部结果合并成全局前 20 条。

text
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 才去对应分片取完整内容。

mermaid
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 面试和线上优化高频点。

假设:

json
{
  "from": 10000,
  "size": 20
}

这不是“跳过 10000 条后直接取 20 条”。在分布式场景里,每个分片都要准备足够多的候选结果。

mermaid
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 示例:

json
{
  "size": 20,
  "query": {
    "term": {
      "status": "ON_SALE"
    }
  },
  "sort": [
    { "updatedAt": "desc" },
    { "productId": "asc" }
  ],
  "search_after": [1783230000000, 1001]
}

注意排序字段必须稳定,通常加一个唯一字段作为兜底排序,比如 productId

filter 为什么通常更快

filtermust 最大的区别是:filter 不参与相关性评分。

mermaid
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 valuesbucket 太多、范围太大、嵌套聚合深限制时间范围、预聚合、控制 bucket
高亮postings/term vectors/source大字段、高亮片段多只对必要字段高亮
wildcardterm 字典扫描前缀 *abc 很难定位ngram、search_as_you_type
script score脚本执行每个候选都计算预计算字段、限制候选集

商业场景:订单后台检索

订单检索通常不是纯全文搜索,而是精确过滤、时间范围和权限过滤更多。

mermaid
flowchart TD
    A["运营后台输入查询条件"] --> B["后端校验租户和权限"]
    B --> C["组装 bool query"]
    C --> D["订单状态、机构、时间放 filter"]
    C --> E["手机号后四位、商品名按字段选择查询"]
    D --> F["ES 返回订单列表"]
    E --> F
    F --> G["详情页回订单服务或 MySQL 校验事实状态"]

订单搜索设计要特别注意:

  1. 权限条件必须由后端加,不能让前端自己传。
  2. 查询结果用于展示和定位订单,不能作为支付、退款、发货的最终依据。
  3. 列表页只返回必要字段,详情页再查订单服务。
  4. 时间范围必须有限制,避免全历史大范围聚合。

可运行 Demo:Java 组装受控查询

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 语法,而是查询设计:

  1. 前端传业务参数,后端翻译成受控 DSL。
  2. 关键词走 match
  3. 品牌、状态、价格走 filter
  4. _source 只返回列表需要的字段。

常见坑

后果正确做法
前端直接传 ES DSL越权、慢查询、脚本攻击、拖垮集群后端只开放业务参数
所有条件都放 must不必要评分,性能差,排序混乱硬条件放 filter
深分页无限开放协调节点内存和 CPU 压力大限制页码,用 search_after/PIT
返回完整 _source网络和序列化成本高列表页字段裁剪
大范围聚合查询慢、内存压力大限制范围,预聚合
脚本排序滥用每个候选执行脚本预计算排序字段

排查方法

问题排查顺序
查询慢slowlog、profile、DSL、分片数、深分页、聚合、wildcard、脚本
结果少查 filter 是否误伤、权限条件、状态条件、分词
排序怪_explain、BM25、boost、业务排序字段、缺失值
高亮慢字段长度、片段数量、是否必要
线上偶发慢hot threads、GC、磁盘 IO、线程池 rejected、热点分片

常用命令:

bash
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"

面试标准回答

text
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为什么写入成功不等于可搜索
倒排索引与 BM25Query Phase 中召回和评分的底层原理
Mapping 与查询termmatch、bool、分页、聚合
性能优化与排查慢查询和搜不到的排查链路
集群分片、副本、协调节点与健康状态