深度解析 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🌺点点关注,收藏不迷路🌺 |

相关推荐
大大大大晴天20 分钟前
OpenMetadata VS DataHub VS Atlas VS Gravitino
大数据
Qyr993 小时前
2026全球汽车碳刷行业发展全景分析报告
大数据
蓝速科技4 小时前
便民服务大厅 AI 数字人一体机场景适配与落地指南丨蓝速科技
大数据·网络·数据结构·人工智能·自然语言处理·数据分析·运维开发
精益数智工坊4 小时前
云计算环境数据怎么同步?云计算集成方案有哪些?
大数据·人工智能·数据挖掘·数据可视化
huashengzsj6 小时前
什么是DMS经销商管理系统?国内经销商管理系统有哪些品牌
大数据
IT毕设梦工厂6 小时前
计算机毕业设计选题推荐:基于大数据的草鱼价格分析与可视化|毕业设计选题|计算机毕设|选题推荐|毕设指导|项目定制|源码|高质量项目
大数据·hadoop·python·数据挖掘·数据分析·课程设计·大数据毕设项目
辛迪聊物业数字化7 小时前
智慧社区物业管理软件新手部署与实操指南
大数据·php
字节跳动数据平台7 小时前
模型再强,为什么 Demo 还是进不了生产?
大数据
南京兴帝文化传媒有限公司7 小时前
本地生活服务GEO优化实战:宁国花店地图关键词布局带来订单翻倍的技术路径
大数据·人工智能·生活·geo 优化·geo优化避坑·ai搜索获客
跨境小彭7 小时前
Temu运营避坑:制造地点信息填写规范、后果及批量实操教程
大数据·运维·自动化·跨境电商·temu