深度解析 Elasticsearch 搜索过程:Query Then Fetch 两阶段详解
深度解析 Elasticsearch 搜索过程:Query Then Fetch 两阶段详解
- 前言
- 一、搜索流程全景图
- 1.1 两阶段概览
- 1.2 为什么需要两个阶段?
- 二、示例集群环境
- 三、第一阶段:Query 阶段
- 3.1 步骤一:协调节点广播请求
- 3.2 步骤二:每个分片本地查询
- 3.3 步骤三:协调节点合并排序
- 四、第二阶段:Fetch 阶段
- 4.1 步骤一:协调节点发起 MultiGet 请求
- 4.2 步骤二:分片返回完整文档
- 4.3 步骤三:协调节点组装并返回
- 五、深度剖析:关键设计细节
- 5.1 为什么 Query 阶段返回 `from+size` 条而非只返回 `size` 条?
- 5.2 深度分页问题
- 5.3 分片选择策略(主 vs 副本)
- 5.4 搜索类型(已废弃)
- 六、完整时序图
- 七、性能优化建议
- 八、常见面试题
- Q1:Query 阶段和 Fetch 阶段各自做了什么?
- Q2:为什么需要两个阶段,不能合二为一?
- Q3:什么是深度分页问题?如何解决?
- Q4:协调节点如何决定查询主分片还是副本分片?
- 九、总结
- 十、面试加分回答
)
🌺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 条 |
搜索请求:
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 条(按排序值)。
// 每个分片返回给协调节点的数据格式{"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] │ └─────────┘ └─────────┘ └─────────┘// 协调节点发送的 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):
// 分片返回的文档内容{"_id":"doc_A","_index":"my_index","_score":9.5,"_source":{"title":"Elasticsearch 入门教程","content":"本文介绍 Elasticsearch 的基本概念...","timestamp":"2026-01-26T10:00:00Z"}}4.3 步骤三:协调节点组装并返回
协调节点收集所有文档,按 Query 阶段排好的顺序组装,返回给客户端:
{"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(文档条数) |
核心要点:
- 搜索采用Query Then Fetch两阶段模型
- Query 阶段:坐标 + 不取→ 轻量级定位
- Fetch 阶段:取数 + 组装→ 按需获取
- 深度分页用Search After替代
from/size - 协调节点通过自适应副本选择实现负载均衡
十、面试加分回答
面试官:请详细描述 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🌺点点关注,收藏不迷路🌺 |
