ElasticsearchNotes

第 25 章:查询执行原理

zjc 于 2026-01-25 发布

这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 搜索请求看起来只是一条 JSON,但背后是协调节点、多个分片、Lucene 查询树、缓存、排序队列和 fetch 阶段的协作。理解这个过程,才能解释深分页、查询超时、缓存命中、评分差异和查询取消。

25.1 查询的两个阶段

Elasticsearch 默认搜索分为 query phase 和 fetch phase。

sequenceDiagram
    participant C as Client
    participant N as Coordinating Node
    participant S1 as Shard 0
    participant S2 as Shard 1
    participant S3 as Shard 2
    C->>N: search request
    N->>S1: query phase
    N->>S2: query phase
    N->>S3: query phase
    S1-->>N: top N doc id + score + sort values
    S2-->>N: top N doc id + score + sort values
    S3-->>N: top N doc id + score + sort values
    N->>N: merge and choose final top size
    N->>S1: fetch selected docs
    N->>S2: fetch selected docs
    N->>S3: fetch selected docs
    S1-->>N: _source / highlight
    S2-->>N: _source / highlight
    N-->>C: final response

25.1.1 Query Phase

每个分片执行:

  1. 解析查询 DSL;
  2. 构造 Lucene 查询树;
  3. 找到候选文档;
  4. 计算评分或应用 filter;
  5. _score 或 sort values 排序;
  6. 返回前 from + size 条文档标识。

这一阶段不返回 _source,只返回足够的排序信息和 doc id。

25.1.2 Fetch Phase

协调节点归并出最终结果后,只去相关分片取被选中的文档内容:

因此,返回 20 条结果通常比返回 1000 条便宜得多。深分页的问题不在最后一页展示多少条,而在于每个分片必须为前 from + size 条维护排序队列。

25.2 查询树改写

DSL 会被解析成 Lucene 查询对象,并在执行前改写。例如:

GET products-v3/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "轻薄笔记本" } }
      ],
      "filter": [
        { "term": { "status": "on_sale" } },
        { "range": { "price": { "lte": 8000 } } }
      ]
    }
  }
}

可能被改写为类似结构:

BooleanQuery
  MUST: BM25 scored query for title
  FILTER: TermQuery(status=on_sale)
  FILTER: IndexOrDocValuesQuery(price <= 8000)

查询改写会考虑:

这也是为什么 Mapping 会影响查询性能,而不仅是影响磁盘占用。

25.3 Query 与 Filter

query 上下文回答“这个文档是否匹配,匹配得怎么样”,会计算相关性评分;filter 上下文回答“是或不是”,不评分,结果可缓存。

GET products-v3/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "笔记本" } }
      ],
      "filter": [
        { "term": { "status": "on_sale" } },
        { "range": { "price": { "gte": 3000, "lte": 8000 } } },
        { "term": { "category_id": "cat_101" } }
      ]
    }
  }
}

判断标准:

条件 建议
关键词、标题、描述 must / should,参与评分
状态、类目、标签、权限 filter
时间范围、价格范围 filter
需要影响相关性的运营加权 shouldfunction_score
只做统计范围限制 post_filter 或聚合外部条件

经验法则:能放 filter 的条件不要放 must。权限过滤尤其应该放 filter,并可被缓存。

25.4 DFS Query Then Fetch

BM25 评分需要词项在分片内的统计信息。默认 query then fetch 使用分片本地统计,不同分片对同一词的 IDF 可能略有差异。

GET products-v3/_search?dfs_query_then_fetch
{
  "query": {
    "match": { "title": "elasticsearch" }
  }
}

DFS Query Then Fetch 会先收集全局词频,再执行评分,分数更接近单索引语义,但多一轮通信,通常更慢。

是否使用 DFS:

场景 建议
数据量大、分片统计均匀 默认即可
小索引、低频词、评分奇怪 可测试 DFS
高 QPS 在线搜索 谨慎使用
排序主要由业务因子决定 默认即可

生产搜索通常不会盲目开启 DFS,而是通过评测集判断收益。

25.5 归并与排序

协调节点拿到每个分片的前 from + size 条结果后,按排序键归并。

排序键可能是:

如果排序键存在相同值,结果顺序可能不稳定。常见处理:

GET products-v3/_search
{
  "sort": [
    { "sales_30d": "desc" },
    { "_id": "asc" }
  ]
}

_id 做 tie-breaker 能得到稳定顺序,但 _id 是字符串,排序成本较高。业务系统也可以引入单调递增的 sequence_noupdated_seq

25.6 深分页

25.6.1 From / Size

from = 10000, size = 20
每个分片需要取前 10020 条
协调节点最多归并 分片数 * 10020 条排序候选

默认限制:

index.max_result_window = 10000

查看和修改:

GET products-v3/_settings/index.max_result_window

PUT products-v3/_settings
{
  "index.max_result_window": 20000
}

不要为了业务随手调大。深分页会放大内存和 CPU 压力,还可能被恶意请求利用。

25.6.2 Search After

Search After 适合无跳页的连续翻页:

GET products-v3/_search
{
  "size": 20,
  "sort": [
    { "sales_30d": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [10000, "sku_10001"]
}

每个结果会返回 sort 值,下一页把它作为游标:

"sort": [12300, "sku_10020"]

限制:

25.6.3 Point In Time

PIT 保存一个轻量的索引视图:

POST products-v3/_pit?keep_alive=2m

返回:

{
  "id": "pit_id_base64"
}

带 PIT 查询:

GET /_search
{
  "size": 20,
  "pit": {
    "id": "pit_id_base64",
    "keep_alive": "2m"
  },
  "sort": [
    { "sales_30d": "desc" },
    { "_id": "asc" }
  ],
  "search_after": [10000, "sku_10001"]
}

删除 PIT:

DELETE /_pit
{
  "id": "pit_id_base64"
}

PIT 会持有 searcher 相关资源,必须设置合理 keep_alive,用完及时删除。

25.6.4 Scroll

Scroll 适合离线导出:

POST products-v3/_search?scroll=2m
{
  "size": 1000,
  "sort": ["_doc"]
}

Scroll 快照成本高,不适合普通用户分页。新代码应优先考虑 PIT + search after,导出场景也应设置清楚超时和清理机制。

25.7 缓存

Elasticsearch 有多类缓存:

缓存 作用对象 失效时机
Node Query Cache filter 查询结果位图 段结构变化、容量淘汰
Request Cache size=0 请求结果 索引 refresh、分段变化
Field Data Cache text 聚合等正排需求 建议避免使用
Page Cache 操作系统磁盘页 内存压力
Shard Request Cache 聚合与 suggestion refresh 后失效

查看缓存:

GET products-v3/_stats/query_cache,request_cache,fielddata

Request Cache 只对 size=0 的请求生效:

GET products-v3/_search
{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "range": { "created_at": { "gte": "now-1d/d", "lte": "now/d" } } }
      ]
    }
  },
  "aggs": {
    "brands": {
      "terms": { "field": "brand_id" }
    }
  },
  "request_cache": true
}

如果查询里使用 now 精确到毫秒,会导致缓存几乎无法命中。把时间取整为当天、当小时,能显著提高复用率。

25.8 查询取消与超时

搜索可以设置超时:

GET products-v3/_search
{
  "timeout": "2s",
  "query": {
    "match_all": {}
  }
}

timeout 是尽力而为的检查点,不保证请求一定被立即取消。响应中的 timed_out 表示部分结果可能不完整:

"timed_out": true

查询取消来源包括:

查看当前搜索任务:

GET /_tasks?actions=*search*&detailed
GET /_cat/tasks?v

取消任务:

POST /_tasks/task_id:1/_cancel

应用侧必须设置请求超时和最大返回值,不能把用户输入直接变成无约束查询。

25.9 查询线程池

搜索请求使用 search thread pool:

GET /_cat/thread_pool/search?v&h=node_name,name,active,queue,rejected,completed
GET /_nodes/stats/indices/search

常见压力来源:

治理顺序通常是:

  1. 限制 from/size 和索引范围;
  2. 改写查询,缩小 filter;
  3. 优化 Mapping 和字段类型;
  4. 使用预聚合;
  5. 拆分冷热集群;
  6. 增加节点或分片;
  7. 调整线程池,但这通常是最后一步。

25.10 Explain 与 Profile

25.10.1 Explain

explain 查看文档为什么匹配:

GET products-v3/_search
{
  "explain": true,
  "query": {
    "match": { "title": "轻薄笔记本" }
  }
}

也可以解释指定文档:

GET products-v3/_explain/sku_10001
{
  "query": {
    "match": { "title": "轻薄笔记本" }
  }
}

explain 本身昂贵,只适合排查单条请求,不应在高 QPS 常规查询中开启。

25.10.2 Profile

Profile API 查看查询耗时分布:

GET products-v3/_search
{
  "profile": true,
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "笔记本" } }
      ],
      "filter": [
        { "term": { "status": "on_sale" } }
      ]
    }
  }
}

关注:

Profile 会增加开销,输出也很大。适合测试环境或低峰单请求诊断。

25.11 查询优化清单

本章小结

一次搜索由 query phase 找到每个分片的候选结果,再由 fetch phase 取回最终文档内容。查询扇出、深分页、缓存、评分和排序队列共同决定了性能。优化搜索时,先缩小数据范围和返回规模,再优化 DSL 和 Mapping,最后才考虑扩容。

思考题

  1. Query phase 和 fetch phase 分别做什么?
  2. 为什么深分页会放大内存和 CPU 成本?
  3. Search After 为什么必须配合稳定排序?
  4. Request Cache 为什么对 size=0 请求更有价值?
  5. 遇到查询延迟毛刺,你会用哪些工具定位?