上一篇【第44篇】Elasticsearch分布式索引原理——分片路由与写入流程
下一篇【第46篇】Elasticsearch分布式文档更新原理


摘要

分布式检索是Elasticsearch应对海量数据场景的核心能力,但其原理远比单机搜索复杂。本文以Query Then Fetch两阶段模型为主线,首先详解Query阶段:协调节点如何将搜索请求广播到所有分片,各分片独立执行本地搜索后返回Top N文档的ID与分数,协调节点再合并全局排序;然后深入Fetch阶段:协调节点如何根据全局Top N结果向目标分片发起MultiGet请求获取完整文档内容。通过完整的API示例和ASCII流程图,读者将透彻理解两阶段搜索的数据流向和网络开销。此外,本文还深入探讨分布式环境下的相关性评分问题——各分片独立统计IDF导致的评分不一致,以及DFS搜索类型的解决策略与代价。关键词:Elasticsearch分布式搜索、Query Then Fetch、DFS搜索、相关性评分、两阶段搜索。


一、分布式检索的挑战

在单机环境下,搜索过程非常直观:Lucene在本地索引中查找匹配文档并按照相关性评分排序返回。但在分布式环境下,索引被拆分成了多个分片,分散在不同的节点上。这就引出了一个核心问题:

如何在一个由多个分片组成的分布式索引中执行搜索并返回全局正确的排序结果?

简单方案是不可行的——我们不能简单地将所有分片的数据全部拉取到协调节点再排序,因为全网数据量可能达到TB级别,全量数据传输会直接耗尽网络带宽和内存。

Elasticsearch采用了一种精巧的两阶段策略:Query Then Fetch


二、Query Then Fetch 两阶段搜索模型

2.1 架构总览

                    ┌──────────────────────────┐
                    │       客户端 (Client)       │
                    └───────────┬──────────────┘
                                │ ① 发送搜索请求
                                ▼
                    ┌──────────────────────────┐
                    │    协调节点 (Node 1)        │
                    │  - 接收请求                 │
                    │  - 路由分发                 │
                    │  - 结果合并                 │
                    │  - 全局排序                 │
                    └────┬───────┬───────┬─────┘
                    ②广播│       │       │②广播
                  ┌──────▼──┐ ┌──▼──────┐ ┌──▼──────┐
                  │  Node 2  │ │ Node 3  │ │ Node 4  │
                  │ Shard 0  │ │ Shard 1 │ │ Shard 2 │
                  │ (主分片)  │ │ (主分片) │ │ (主分片) │
                  └─────────┘ └─────────┘ └─────────┘
                       │           │           │
                    ③返回    ③返回        ③返回
                   top N     top N        top N
                  (ID+分数) (ID+分数)    (ID+分数)
                       │           │           │
                       └─────┬─────┴─────┬─────┘
                             │           │
                             ▼           │
                    ┌────────────────────┴─────┐
                    │    协调节点 (Node 1)        │
                    │  ④ 全局归并排序             │
                    │  ⑤ 确定最终Top N           │
                    └──────────┬───────────────┘
                               │ ⑥ 向目标分片请求完整文档
                               ▼
                    ┌──────────┴───────────────┐
                    │    协调节点 → 目标分片      │
                    │    ⑦ Fetch阶段            │
                    │    - 获取完整_source       │
                    │    - 组装最终结果           │
                    └──────────┬───────────────┘
                               │ ⑧ 返回结果
                               ▼
                    ┌──────────────────────────┐
                    │       客户端 (Client)       │
                    └──────────────────────────┘

2.2 两阶段的核心设计哲学

Query Then Fetch模型的设计目标是在保证结果正确性的前提下最小化网络传输开销

  • Query阶段:只传输文档ID和评分(几十字节),不传输完整的 _source(可能几KB到几MB)
  • Fetch阶段:仅对最终确定需要返回的少量文档获取完整内容

这样,分片→协调节点→目标分片的两轮通信虽然增加了请求次数,但极大地减少了数据传输总量。


三、Query阶段详解

3.1 步骤分解

Query阶段完成从"用户查询"到"全局排序文档ID列表"的转换。具体包括以下子步骤:

Query阶段内部流程:

① 协调节点接收查询请求
   - 解析DSL查询语句
   - 确定搜索涉及的所有分片(主分片或副本分片的活跃列表)

② 协调节点向每个目标分片广播查询请求
   - 每个分片收到的是一个完整的、独立的查询
   - 请求中包含 size=from+size(确保获取足够多结果)

③ 每个分片独立执行本地搜索
   ┌──────────────────────────────────────────┐
   │ 分片本地搜索步骤:                         │
   │  a. 查询语法分析(Query Parsing)          │
   │  b. 倒排索引查找(Term Lookup)            │
   │  c. 文档列表合并(AND/OR/交集/并集)        │
   │  d. 相关性评分计算(独立计算,可能不准确)   │
   │  e. 返回top(from+size)个文档的ID+评分      │
   └──────────────────────────────────────────┘

④ 协调节点收集所有分片的返回结果
   - 假设有5个分片,每个返回from+size=20个文档
   - 协调节点收到 5×20=100 个文档的ID+评分

⑤ 协调节点进行全局归并排序
   - 将100个文档按评分降序排列
   - 截取最终的from+size条(如第0-9条)

3.2 Query阶段的代码体现

// 发起一个跨分片搜索请求
GET /shop/_search
{
  "from": 0,
  "size": 10,
  "query": {
    "match": {
      "title": "iPhone 14"
    }
  },
  "sort": [
    { "_score": "desc" }
  ]
}

// 在这个查询中:
// - 如果shop索引有5个主分片
// - Query阶段每个分片返回 top 10 个文档的(_id + _score)
// - 协调节点收集 5×10=50 个文档
// - 全局排序后取前10个
// - 进入Fetch阶段获取这10个文档的完整内容

3.3 Query阶段的关键细节

每个分片返回多少文档?

默认情况下,每个分片返回 from + size 条结果。例如,查询 from=90, size=10 时(即第10页,每页10条),每个分片返回100条。

这就引出了深度分页的性能问题

  • 当我们请求第1000页时,每个分片需要返回 (1000-1)×10 + 10 = 10000
  • 协调节点需要处理 5×10000 = 50000 条数据的内存排序

两个重要的限制参数:

PUT /shop/_settings
{
  "index.max_result_window": 10000   // 默认为10000,超过需用search_after
}

四、Fetch阶段详解

4.1 步骤分解

Query阶段结束后,协调节点已确定了最终需要返回的文档ID列表。Fetch阶段的任务就是去获取这些文档的完整内容。

Fetch阶段内部流程:

① 协调节点确定目标文档分片分布
   - 从Query阶段结果中得到最终的N个文档ID
   - 通过路由公式反算每个文档所在的分片

② 协调节点向目标分片发起MultiGet请求
   ┌────────────────────────────────────────┐
   │ 按分片聚合请求(高效批量获取):          │
   │                                         │
   │ → Node2 (Shard 0): [doc_3, doc_7, doc_15]
   │ → Node3 (Shard 1): [doc_1, doc_9]        │
   │ → Node4 (Shard 2): [doc_2, doc_5, doc_6] │
   └────────────────────────────────────────┘

③ 各目标分片根据文档ID返回完整文档
   - 返回_source字段、stored fields等

④ 协调节点组装最终响应
   - 按排序顺序排列文档
   - 添加hit元数据(_index, _id, _score等)
   - 返回给客户端

4.2 两阶段的网络开销分析

假设:

  • 5个分片
  • 查询 from=0, size=10
  • 每个文档 _source 约1KB
  • 每个文档ID+分数约50字节
Query阶段网络传输:
  分片→协调节点: 5 × 10 × 50字节 = 2.5KB

Fetch阶段网络传输:
  协调节点→分片: 请求10个文档ID ≈ 300字节
  分片→协调节点: 10 × 1KB = 10KB

总网络传输: 约12.8KB

如果不分两阶段(每个分片返回完整文档):
  分片→协调节点: 5 × 10 × 1KB = 50KB(近4倍差距)

随着分片数增加或文档体积增大,两阶段优化带来的节省效果更加明显。


五、分布式相关性评分问题

5.1 问题根源:各分片独立统计IDF

Query阶段每个分片独立计算BM25评分。问题在于BM25中的IDF(逆文档频率)需要知道全局的文档频率统计:

IDF(qi) = log(1 + (N - n(qi) + 0.5) / (n(qi) + 0.5))

其中 N = 总文档数, n(qi) = 包含词qi的文档数

在分布式环境下,每个分片只有自己那部分数据的统计信息:

全局索引: 总共100万文档,"Elasticsearch"出现1000次
  → IDF = log(1 + (1000000 - 1000 + 0.5) / (1000 + 0.5)) ≈ log(1000) ≈ 6.9

Shard 0 (30万文档): "Elasticsearch"出现800次
  → IDF = log(1 + (300000 - 800 + 0.5) / (800 + 0.5)) ≈ log(375) ≈ 5.9  ← 偏低

Shard 1 (30万文档): "Elasticsearch"出现100次
  → IDF = log(1 + (300000 - 100 + 0.5) / (100 + 0.5)) ≈ log(3000) ≈ 8.0  ← 偏高

Shard 2 (40万文档): "Elasticsearch"出现100次
  → IDF = log(1 + (400000 - 100 + 0.5) / (100 + 0.5)) ≈ log(4000) ≈ 8.3  ← 更高

结果是:同一个文档在不同分片上的评分因为IDF的差异而不一致。当文档不均匀分布时(实际生产环境几乎必然如此),最终全局排序可能出现偏差——来自小IDF分片的文档得分可能系统性地偏低。

5.2 问题场景示例

假设搜索词"Elasticsearch":
  Shard A: 1000篇文档,其中800篇包含"Elasticsearch" → IDF很低(词很常见)
  Shard B: 1000篇文档,其中10篇包含"Elasticsearch"  → IDF很高(词很稀有)

  Shard A中一篇标题为"Elasticsearch入门"的文档:
    - 本地得分: 3.5(因为本地IDF低)
  Shard B中一篇标题为"Elasticsearch入门"的文档:
    - 本地得分: 8.2(因为本地IDF高)

全局排序时,Shard B的文档可能排在Shard A前面,
尽管两篇文档的相关性实际上是相同的。

5.3 影响程度评估

数据分布情况 IDF偏差程度 对排序的影响
文档均匀分布 ★☆☆☆☆ 几乎无影响,各分片IDF接近
按时间分片(日志) ★★☆☆☆ 轻微影响,IDF随时间波动
按业务线分片(routing) ★★★★☆ 较大影响,相同词在不同分片频率差异大
小样本查询 ★★★☆☆ 样本足够大时影响可忽略

六、DFS Query Then Fetch:解决评分不一致

6.1 DFS搜索类型

DFS(Distributed Frequency Search)是Elasticsearch提供的另一种搜索方式,通过在Query阶段之前先执行一次"预查询",获取全局的词频统计信息,然后传递给各分片用于准确的IDF计算。

DFS Query Then Fetch 执行流程:

┌─────────────────────────────────────────────┐
│  预Query阶段(DFS Phase)                     │
│  ① 协调节点向所有分片请求各Term的文档频率      │
│  ② 各分片返回本地频率统计                     │
│  ③ 协调节点汇总为全局频率统计                 │
└────────────────┬────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────┐
│  Query阶段(使用全局IDF)                     │
│  ④ 协调节点携带全局频率统计广播Query到各分片   │
│  ⑤ 各分片使用全局频率计算准确的IDF和评分      │
│  ⑥ 返回文档ID + 准确评分                      │
└────────────────┬────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────┐
│  Fetch阶段(与普通Query Then Fetch相同)       │
│  ⑦ 协调节点全局排序后获取完整文档             │
└─────────────────────────────────────────────┘

6.2 三种搜索类型对比

搜索类型 网络往返次数 评分准确性 性能开销 适用场景
query_then_fetch 2次(Query + Fetch) ★★☆☆☆ ★★★★★(最优) 默认类型,日常搜索
dfs_query_then_fetch 3次(DFS + Query + Fetch) ★★★★★ ★★★☆☆ 相关性要求极高的场景
query_and_fetch(仅单个分片时) 1次 ★★★★★ ★★★★★ 仅一个分片时自动启用

6.3 使用DFS搜索

// 使用DFS搜索类型
GET /shop/_search?search_type=dfs_query_then_fetch
{
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  }
}

// 也可以通过_profile分析各阶段的耗时
GET /shop/_search?search_type=dfs_query_then_fetch
{
  "profile": true,
  "query": {
    "match": {
      "title": "Elasticsearch"
    }
  }
}

6.4 何时使用DFS?

建议使用DFS的场景:

  • 文档在不同分片上分布极不均匀(如按业务线routing)
  • 搜索结果较少(<100条),文档频率统计差异被放大
  • 相关性排序是业务核心指标(如电商搜索、文档检索)

无需使用DFS的场景:

  • 文档在各分片均匀分布
  • 日志搜索、数据分析等不需要精确相关性排序
  • 大数据量搜索(DFS的额外网络开销不值得)
  • 使用自定义排序或function_score的场景

七、深度分页与替代方案

7.1 深度分页的问题

Query Then Fetch模型在处理深度分页时会遇到严重的性能瓶颈:

第1000页 (from=9990, size=10):
  每个分片需要返回:10000 个文档ID+评分
  5个分片:协调节点合并 50000 个文档
  内存压力极大,CPU归并排序开销高

根本原因:Query阶段必须保证"前面的文档"不会在后续跳出来

7.2 search_after:深度分页的推荐方案

// 使用 search_after 替代深度分页
GET /shop/_search
{
  "size": 10,
  "query": {
    "match": { "title": "iPhone 14" }
  },
  "sort": [
    { "price": "asc" },
    { "_id": "asc" }
  ],
  "search_after": [5999, "doc_123"]  // 上一页最后一条的sort值
}
方案 适用深度 性能 实时性要求
from+size <10000 浅分页优秀,深分页糟糕 支持跳页
search_after 无限制 始终高效 不支持跳页
scroll 无限制 高效 快照模式,不实时

八、总结与最佳实践

核心要点回顾

  1. Query Then Fetch两阶段模型通过"先传ID+分数再取完整文档"的设计,实现了网络传输和内存使用的双重优化,是Elasticsearch分布式搜索的基石。

  2. Query阶段各分片独立搜索并返回Top N文档ID,协调节点全局归并排序;Fetch阶段仅对最终保留的少量文档进行MultiGet获取。

  3. 分布式IDF不一致是Query Then Fetch模型的固有问题,每个分片独立统计词频导致评分偏差,数据分布越不均匀问题越明显。

  4. DFS搜索通过增加一次预查询获取全局词频,以额外的一次网络往返为代价换取准确的评分,适用于相关性要求高的场景。

最佳实践清单

实践建议 详细说明
默认使用query_then_fetch 绝大多数场景下性能最优,保持默认设置
相关性敏感场景启用DFS 电商搜索、知识库检索等场合适用dfs_query_then_fetch
避免深度分页 from+size之和不宜超过10000,深翻场景使用search_after
监控profile输出 使用"profile": true 分析各阶段耗时分布
副本分片分担查询 搜索负载高时增加副本数,利用副本分片并行搜索
preference参数控制 使用preference参数绑定用户到固定分片,用于A/B测试或缓存

上一篇【第44篇】Elasticsearch分布式索引原理——分片路由与写入流程
下一篇【第46篇】Elasticsearch分布式文档更新原理


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐