深度解析 Elasticsearch 搜索过程:Query Then Fetch 两阶段详解

深度解析 Elasticsearch 搜索过程:Query Then Fetch 两阶段详解

)

|-----------------------------|
| 🌺The Begin🌺点点关注,收藏不迷路🌺 |

前言

搜索是 Elasticsearch 最核心的功能之一,但很多开发者对 ES 内部如何执行搜索请求一知半解。为什么搜索分为两个阶段?协调节点做了什么?分片如何返回结果?本文将围绕官方定义的 "Query Then Fetch" 两阶段模型,逐步拆解分布式搜索的完整流程。


一、搜索流程全景图

1.1 两阶段概览

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        客户端发送搜索请求                             │
│                    GET /my_index/_search?q=keyword                   │
└─────────────────────────────────┬───────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                      【第一阶段:Query 阶段】                         │
│  目的:定位到哪些文档匹配,但不返回文档内容                            │
│                                                                      │
│  1. 协调节点将请求广播到所有分片(主或副本)                          │
│  2. 每个分片本地查询,返回文档ID + 排序值到优先队列                   │
│  3. 协调节点合并各分片结果,生成全局排序列表                          │
└─────────────────────────────────┬───────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                      【第二阶段:Fetch 阶段】                         │
│  目的:根据 Query 阶段得到的文档ID,获取完整的文档内容                │
│                                                                      │
│  1. 协调节点向相关分片发送 MultiGet 请求                             │
│  2. 分片返回完整文档内容                                             │
│  3. 协调节点组装结果,返回给客户端                                    │
└─────────────────────────────────────────────────────────────────────┘

1.2 为什么需要两个阶段?

如果合并为一个阶段 两阶段分离的好处
每个分片返回完整文档 → 大量网络传输 Query 阶段只传ID和排序值,数据量极小
协调节点需要丢弃超出 size 的文档 Fetch 阶段只取最终需要的文档
无法做全局排序 先全局排序,再按需获取

一句话总结先定位,后取数,避免网络和内存浪费。


二、示例集群环境

为便于理解,设定以下集群状态:

配置项
索引名 my_index
主分片数 5
副本数 1
总分片数 10(5主 + 5副本)
文档总数 10,000 条

搜索请求

json 复制代码
GET /my_index/_search
{
  "from": 0,
  "size": 10,
  "query": {
    "match": {
      "title": "elasticsearch"
    }
  },
  "sort": [
    {"_score": "desc"}
  ]
}

三、第一阶段:Query 阶段

3.1 步骤一:协调节点广播请求

复制代码
                                    ┌─────────────────┐
                                    │    客户端        │
                                    └────────┬────────┘
                                             │
                                             ▼
                                    ┌─────────────────┐
                                    │   协调节点       │
                                    │ (接收搜索请求)   │
                                    └────────┬────────┘
                                             │
              ┌──────────────┬───────────────┼───────────────┬──────────────┐
              │              │               │               │              │
              ▼              ▼               ▼               ▼              ▼
        ┌──────────┐  ┌──────────┐    ┌──────────┐    ┌──────────┐  ┌──────────┐
        │  分片0   │  │  分片1   │    │  分片2   │    │  分片3   │  │  分片4   │
        │ (主/副本) │  │ (主/副本) │    │ (主/副本) │    │ (主/副本) │  │ (主/副本) │
        └──────────┘  └──────────┘    └──────────┘    └──────────┘  └──────────┘

关键要点

  • 协调节点向所有分片发送请求(每个分片只命中主或副本中的一个)
  • 采用轮询策略选择主还是副本(负载均衡)
  • 请求参数完全相同(query、from、size、sort)

3.2 步骤二:每个分片本地查询

每个分片收到请求后,独立执行以下操作:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    单个分片内部处理流程                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. 解析查询语句 → 生成 Lucene Query                              │
│                     ▼                                            │
│  2. 遍历倒排索引 → 找到匹配的文档ID列表                           │
│                     ▼                                            │
│  3. 计算每个文档的 _score(相关性评分)                          │
│                     ▼                                            │
│  4. 按排序字段排序(默认按 _score 降序)                          │
│                     ▼                                            │
│  5. 截取 [from, from+size] 范围的文档(本地优先队列)             │
│                     ▼                                            │
│  6. 返回 (文档ID + 排序值) 给协调节点(不返回文档内容)           │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

示例 :假设每个分片有 2000 条匹配文档,from=0, size=10,每个分片只返回前 10 条(按排序值)。

json 复制代码
// 每个分片返回给协调节点的数据格式
{
  "shard": 0,
  "hits": [
    {"_id": "doc_123", "_score": 9.5, "sort_values": [9.5]},
    {"_id": "doc_456", "_score": 9.2, "sort_values": [9.2]},
    ...
    {"_id": "doc_789", "_score": 8.1, "sort_values": [8.1]}  // 共10条
  ]
}

3.3 步骤三:协调节点合并排序

协调节点收到所有分片的返回结果后:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    协调节点合并流程                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  输入:5个分片 × 10条结果 = 50条候选记录                         │
│                     ▼                                            │
│  1. 将所有50条记录放入全局优先队列                                │
│                     ▼                                            │
│  2. 按排序规则重新排序(_score 降序)                            │
│                     ▼                                            │
│  3. 截取 [0, 10] 条(即最终需要的10条文档)                      │
│                     ▼                                            │
│  4. 记录这10条文档分别来自哪个分片                                │
│      文档A → 分片0                                               │
│      文档B → 分片2                                               │
│      文档C → 分片1                                               │
│      ...                                                         │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

此时 Query 阶段结束,协调节点知道:

  • 哪 10 条文档需要返回
  • 每条文档所在的分片位置

四、第二阶段:Fetch 阶段

4.1 步骤一:协调节点发起 MultiGet 请求

协调节点根据 Query 阶段的结果,向相关分片批量获取文档内容:

复制代码
                        协调节点
                           │
        ┌──────────────────┼──────────────────┐
        │                  │                  │
        ▼                  ▼                  ▼
   ┌─────────┐        ┌─────────┐        ┌─────────┐
   │ 分片0   │        │ 分片1   │        │ 分片2   │
   │ 需要文档 │        │ 需要文档 │        │ 需要文档 │
   │ [A, D]  │        │ [C, E]  │        │ [B, F]  │
   └─────────┘        └─────────┘        └─────────┘
json 复制代码
// 协调节点发送的 MultiGet 请求示例
GET /_mget
{
  "docs": [
    {"_index": "my_index", "_id": "doc_A", "_shard": "0"},
    {"_index": "my_index", "_id": "doc_D", "_shard": "0"},
    {"_index": "my_index", "_id": "doc_C", "_shard": "1"},
    ...
  ]
}

4.2 步骤二:分片返回完整文档

每个分片从 Lucene 段Translog 中读取完整的文档源(_source):

json 复制代码
// 分片返回的文档内容
{
  "_id": "doc_A",
  "_index": "my_index",
  "_score": 9.5,
  "_source": {
    "title": "Elasticsearch 入门教程",
    "content": "本文介绍 Elasticsearch 的基本概念...",
    "timestamp": "2026-01-26T10:00:00Z"
  }
}

4.3 步骤三:协调节点组装并返回

协调节点收集所有文档,按 Query 阶段排好的顺序组装,返回给客户端:

json 复制代码
{
  "took": 15,
  "timed_out": false,
  "_shards": {
    "total": 10,
    "successful": 10,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 10000,
      "relation": "eq"
    },
    "max_score": 9.5,
    "hits": [
      {
        "_index": "my_index",
        "_id": "doc_A",
        "_score": 9.5,
        "_source": { "title": "Elasticsearch 入门教程", ... }
      },
      {
        "_index": "my_index",
        "_id": "doc_B",
        "_score": 9.3,
        "_source": { "title": "Elasticsearch 高级查询", ... }
      }
      // ... 共10条
    ]
  }
}

五、深度剖析:关键设计细节

5.1 为什么 Query 阶段返回 from+size 条而非只返回 size 条?

如果只返回 size 条 实际做法(返回 from+size 条)
每个分片返回前 10 条 每个分片返回前 50 条(from=0, size=10 → 0+10=10?等等)

纠正 :实际上每个分片返回 from + size 条,而不是 size 条!

原因 :假设 from=90, size=10,全局第 91-100 条文档可能分散在各分片的前 100 条中。如果每个分片只返回 10 条,会丢失数据。

示例计算

复制代码
from = 90, size = 10
每个分片需要返回:from + size = 100 条(本地排序后的前100条)

协调节点收到:5个分片 × 100条 = 500条候选记录
↓ 全局排序后取 [90, 100) 共10条

5.2 深度分页问题

上述机制导致深度分页性能极差

页码 from 每个分片返回数 协调节点处理数
第1页 0 10 5×10=50
第10页 90 100 5×100=500
第100页 990 1000 5×1000=5000
第1000页 9990 10000 5×10000=50000

解决方案

  • Search After:基于上一页最后一条文档的排序值继续查询
  • Scroll API:用于大量数据导出(不再推荐)
  • Point in Time (PIT):ES 7.10+ 引入的游标机制

5.3 分片选择策略(主 vs 副本)

协调节点决定每个分片查询主还是副本,遵循负载均衡原则:

复制代码
默认策略:自适应副本选择(Adaptive Replica Selection)
- 优先选择响应最快的节点
- 考虑节点历史延迟和队列长度

好处

  • 分摊主节点压力
  • 提升查询吞吐量

5.4 搜索类型(已废弃)

早期 ES 支持多种搜索类型,现已统一为 query_then_fetch

搜索类型 说明 状态
query_and_fetch 查询取回合并 废弃
dfs_query_then_fetch 全局词频计算 废弃(默认已优化)
query_then_fetch 标准两阶段 当前唯一

六、完整时序图

复制代码
客户端          协调节点        分片0(主)       分片1(副本)      分片2(主)     ...
  │               │               │               │               │
  │──搜索请求────▶│               │               │               │
  │               │               │               │               │
  │               │──Query请求───▶│               │               │
  │               │──Query请求──────────────────▶│               │
  │               │──Query请求──────────────────────────────────▶│
  │               │               │               │               │
  │               │◀──返回ID列表──│               │               │
  │               │◀──返回ID列表──────────────────│               │
  │               │◀──返回ID列表──────────────────────────────────│
  │               │               │               │               │
  │               │ (合并排序)     │               │               │
  │               │               │               │               │
  │               │──Fetch请求────▶│               │               │
  │               │──Fetch请求──────────────────▶│               │
  │               │               │               │               │
  │               │◀──完整文档────│               │               │
  │               │◀──完整文档──────────────────│               │
  │               │               │               │               │
  │◀──最终结果────│               │               │               │

七、性能优化建议

优化点 建议 效果
控制返回字段 使用 _source 只返回必要字段 减少网络传输
避免 depth 分页 使用 search_after 替代 from/size 性能提升 10-100x
合理设置分片数 单分片 30-50GB 避免过度分片
使用索引排序 index.sort 减少排序开销 查询提速 30%+
开启自适应副本 默认已开启 负载更均衡

八、常见面试题

Q1:Query 阶段和 Fetch 阶段各自做了什么?

回答

Query 阶段 :协调节点将搜索请求广播到所有分片,每个分片本地查询后返回 文档ID + 排序值(不返回文档内容)。协调节点合并所有结果,生成全局排序列表,确定最终需要获取的文档ID及所在分片。

Fetch 阶段 :协调节点向相关分片发送 MultiGet 批量请求 ,获取完整文档内容(_source),组装后返回给客户端。

Q2:为什么需要两个阶段,不能合二为一?

回答

如果合为一个阶段,每个分片会返回大量完整文档,造成巨大的网络开销。例如 from=990, size=10,每个分片需要返回 1000 条完整文档,5 个分片就是 5000 条,但最终只取 10 条。两阶段分离后,Query 阶段只传轻量级的 ID 和排序值(约 100 字节/条),Fetch 阶段只取最终的 10 条,效率大幅提升。

Q3:什么是深度分页问题?如何解决?

回答

深度分页指跳转到很深页码(如第 1000 页)的情况。由于 Query 阶段每个分片必须返回 from+size 条记录,翻页越深,协调节点处理的数据量越大(第 1000 页需要每个分片返回 10000 条,5 分片处理 50000 条)。

解决方案

  • Search After:使用上一页最后一条文档的排序值,像游标一样向前翻页
  • Point in Time (PIT):固定时间点的快照视图,结合 search_after 使用
  • Scroll API:适合大量数据导出(已不推荐用于实时搜索)

Q4:协调节点如何决定查询主分片还是副本分片?

回答

ES 7.x 后默认使用 自适应副本选择(Adaptive Replica Selection):协调节点会维护每个节点的响应延迟、历史失败率、搜索线程池队列长度等指标,动态选择当前最优的节点(主或副本)发送请求。这样既实现了负载均衡,又能自动避开慢节点。


九、总结

维度 Query 阶段 Fetch 阶段
目的 定位匹配文档 获取完整内容
传输数据 文档ID + 排序值 完整 _source
数据量 小(每个分片 from+size 条) 大(仅 size 条)
涉及的节点 所有分片 只包含最终文档的分片
关键操作 倒排索引检索+本地排序 Lucene 读取 _source
时间复杂度 O(分片数 × 查询开销) O(文档条数)

核心要点

  1. 搜索采用 Query Then Fetch 两阶段模型
  2. Query 阶段:坐标 + 不取 → 轻量级定位
  3. Fetch 阶段:取数 + 组装 → 按需获取
  4. 深度分页用 Search After 替代 from/size
  5. 协调节点通过 自适应副本选择 实现负载均衡

十、面试加分回答

面试官:请详细描述 Elasticsearch 的搜索过程。

候选人

"ES 的搜索采用 Query Then Fetch 两阶段模型。

Query 阶段 :协调节点将请求广播到所有分片(主或副本之一)。每个分片独立执行查询,从倒排索引中找到匹配文档,计算 _score 并按排序字段截取 from+size 条结果,只返回文档ID和排序值给协调节点。协调节点合并所有分片结果,进行全局排序,最终确定需要返回的 size 条文档及其所在分片。这一阶段的核心价值是 轻量级传输,避免早期传输大量无用数据。

Fetch 阶段 :协调节点向包含目标文档的分片发送 MultiGet 批量请求,获取完整的 _source 内容,按 Query 阶段排好的顺序组装,返回给客户端。

这里有一个关键设计:每个分片必须返回 from+size 条记录而非 size 条,因为全局第 N 条可能来自任意分片的前 N 条。这也导致了深度分页问题,解决方案是使用 Search After + Point in Time 代替传统的 from/size 翻页。

另外,ES 通过 自适应副本选择 来决定查询主还是副本,优先选择响应最快的节点,兼顾负载均衡和性能。"


|---------------------------|
| 🌺The End🌺点点关注,收藏不迷路🌺 |

相关推荐
大傻^2 小时前
07_Elasticsearch知识体系之集群架构高可用与快照恢复实战
elasticsearch·架构·jenkins
zandy10112 小时前
体系化AI创新赋能产业升级 联想集团树立智能时代企业创新标杆
大数据·人工智能
陕西企来客3 小时前
2026 西安 GEO 优化技术解析:前沿技术与行业规范深度企来客科技行业白皮书声明
开发语言·搜索引擎·php
春日见3 小时前
五分钟入门强化学习DDPG
大数据·人工智能·算法·机器学习·计算机视觉
潜创微科技3 小时前
2026年办公KVM切换器方案服务商选型参考:技术能力与服务体验双维度评估
大数据
万岳科技系统开发3 小时前
互联网医院小程序搭建怎么做?从0开始建设完整平台
大数据·小程序
小真zzz3 小时前
当“虚构的解决方案”成为试金石:搜极星如何将市场幻想变为可验证的现实?
搜索引擎·ai·大模型·deepseek
RFID舜识物联网3 小时前
耐高温RFID:让喷涂线从“数据断点”走向“全链贯通”
大数据·人工智能·嵌入式硬件·物联网·汽车