ES 系列 #03:ES 的查询过程分析

发布于 2026-05-26 10:25 👁 8 次阅读
#源码解析#elasticsearch#lucene

本文从整体架构出发,逐层深入讲解 ES 的查询执行过程:从客户端请求到协调节点分发,到数据节点的 Lucene 查询,再到结果合并返回。同时结合源码分析关键路径,并给出生产环境的最佳实践。


目录

章节 说明
整体架构与查询类型 ES 节点角色与两阶段查询模型
查询执行全链路 从客户端到 Lucene 的完整调用链
源码级分析 关键类与方法的源码追踪
时间耗时拆解 took / timeout / 端到端时间的本质区别
缓存机制 Query Cache、Request Cache、Field Data Cache
查询最佳实践 字段类型、查询语法、结构优化
性能优化手段 机器、集群、索引、查询四个层面

整体架构与查询类型

节点角色

节点类型 职责
Master Node 集群元数据管理(索引创建/删除、分片分配),不参与查询
Coordinating Node 接收客户端请求,分发到数据节点,汇总结果后返回
Data Node 存储分片数据,执行实际的 Lucene 查询
Ingest Node 写入预处理管道,与查询无关

每个 Data Node 默认也承担 Coordinating 角色。生产环境建议独立部署 Coordinating Node 以隔离查询聚合的 CPU/内存压力。

两类查询上下文

ES 的 bool 查询中存在两种执行上下文,行为差异显著:

上下文 代表子句 计算相关性得分 结果是否缓存
Query Context mustshould ✅ 计算 _score ❌ 不缓存
Filter Context filtermust_not ❌ 不计算得分 ✅ 缓存到 Query Cache

核心原则:不需要相关性排序的条件,一律放 filter,利用缓存加速查询。

GET /_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "手机" }}
      ],
      "filter": [
        { "term": { "status": "online" }},
        { "range": { "price": { "gte": 100, "lte": 5000 }}}
      ]
    }
  }
}

查询执行全链路

两阶段查询模型(Query Then Fetch)

ES 默认的搜索采用 Query Then Fetch 两阶段模型:

query then fetch

为什么要两阶段?

协调节点的工作

TransportSearchAction.doExecute()
  │
  ├── 解析索引名 → 确定涉及的 Shard 列表
  ├── 按 Shard 分组,选择路由节点(主分片 or 副本)
  ├── 并发发送 Query Phase 请求到各数据节点
  ├── 等待所有分片响应(或超时)
  ├── SearchPhaseController.merge() 合并 Query 结果
  ├── 并发发送 Fetch Phase 请求(只针对命中的 doc_id)
  └── 组装最终响应,返回客户端

数据节点的工作

SearchService.executeQueryPhase()
  │
  ├── 创建 SearchContext(绑定 Searcher、Query、From/Size 等)
  ├── QueryPhase.execute()
  │     ├── 构建 Lucene Query(Query DSL → Lucene Query)
  │     ├── 创建 Collector(TopScoreDocCollector / TopFieldDocCollector)
  │     ├── 包装 TimeLimitingCollector(如果设置了 timeout)
  │     └── IndexSearcher.search(query, collector)
  │           └── 遍历所有 Segment,对每个 Segment 执行查询
  │
  └── FetchPhase.execute()
        ├── 根据 doc_id 定位 Segment
        ├── 读取 _source(或 stored fields)
        └── 执行 highlighting、script fields 等后处理

源码级分析

关键类索引

类名 所在模块 职责
TransportSearchAction server 协调节点入口,doExecute 发起两阶段查询
SearchPhaseController server 合并各 Shard 的 Query Phase 结果
SearchService server 数据节点侧,管理 SearchContext 生命周期
QueryPhase server 数据节点执行 Lucene 查询的核心
FetchPhase server 数据节点执行文档 Fetch 的核心
IndexSearcher Lucene Lucene 查询入口,遍历 Segment
BooleanQuery Lucene 对应 ES bool query 的 Lucene 实现
TimeLimitingCollector Lucene 超时控制,包装实际 Collector

QueryPhase.execute() 核心逻辑

// org.elasticsearch.search.query.QueryPhase
public static void execute(SearchContext searchContext, IndexSearcher searcher)
        throws QueryPhaseExecutionException {

    // 1. 构建 Lucene Query
    Query query = searchContext.query();

    // 2. 创建 Collector(按需选择 TopDocs 或 TopField)
    Collector collector = createCollector(searchContext);

    // 3. 如果设置了 timeout,包装 TimeLimitingCollector
    final boolean timeoutSet =
        searchContext.timeoutInMillis() != SearchService.NO_TIMEOUT.millis();
    if (timeoutSet && collector != null) {
        collector = Lucene.wrapTimeLimitingCollector(
            collector,
            searchContext.timeEstimateCounter(),
            searchContext.timeoutInMillis()
        );
    }

    try {
        // 4. 执行 Lucene 查询(遍历所有 Segment)
        searcher.search(query, collector);

    } catch (TimeLimitingCollector.TimeExceededException e) {
        // timeout 触发:标记 searchTimedOut=true,但返回已收集的部分结果
        queryResult.searchTimedOut(true);

    } catch (Lucene.EarlyTerminationException e) {
        // terminateAfter 触发:提前终止
        queryResult.terminatedEarly(true);
    }
}

关键点:timeout 触发后,ES 不会中断查询并抛异常,而是标记 timed_out=true 并返回已收集到的部分结果。这意味着 timed_out=true 时结果是不完整的。

SearchPhaseController.merge() 合并逻辑

// org.elasticsearch.action.search.SearchPhaseController
public InternalSearchResponse merge(...) {

    boolean timedOut = false;

    for (AtomicArray.Entry<QuerySearchResultProvider> entry : queryResults) {
        QuerySearchResult result = entry.value.queryResult();

        // 只要任意一个 Shard 超时,整体标记为超时
        if (result.searchTimedOut()) {
            timedOut = true;
        }

        totalHits += result.topDocs().totalHits;
        // 取各 Shard 最大分数
        maxScore = Math.max(maxScore, result.topDocs().getMaxScore());
    }

    // 全局排序,取 Top N
    TopDocs mergedTopDocs = TopDocs.merge(topN, shardTopDocs);

    return new InternalSearchResponse(
        searchHits, aggregations, suggest, shardResults,
        timedOut, terminatedEarly
    );
}

AbstractAsyncAction 与 took 的计算

// org.elasticsearch.action.search.AbstractAsyncAction
// 构造函数中记录 startTime
this.clusterStateVersion = clusterState.version();
this.startTime = System.currentTimeMillis();  // ← took 的起点

// 请求完成时计算 took
long took = System.currentTimeMillis() - startTime;

时间耗时拆解

三种时间的本质区别

es query time

时间指标 含义 公式
timeout Lucene 查询阶段的超时阈值,最小值 1ms 配置值
took ES 内部实际处理时间,不含网络和队列等待 now - startTime
端到端时间 客户端感知的完整耗时 took + 网络传输 + 队列等待

典型规律端到端时间 > took > timeout 阈值

慢查询分析思路

现象 可能原因
端到端慢,ES 慢日志记录短 网络抖动、队列阻塞、客户端 GC
took 大,单个 Shard 慢 查询复杂、数据量大、Segment 过多
timed_out=truetook 远大于 timeout timeout 是 Lucene 层超时,took 是整体处理时间,二者统计口径不同
慢日志少,但端到端慢查询多 慢日志阈值设置过高,或慢日志只记录了部分时间段

缓存机制

ES 有三层缓存,理解它们对查询优化至关重要:

Query Cache(Filter Cache)

// filter 中的 term/range 查询会被缓存
"filter": [
  { "term": { "status": "online" }},    // ✅ 会缓存
  { "range": { "price": { "gte": 100 }}} // ✅ 会缓存
]
// must 中的 match 查询不会缓存
"must": [
  { "match": { "title": "手机" }}        // ❌ 不缓存
]

Request Cache(Shard-level Cache)

GET /my_index/_search?request_cache=true
{
  "size": 0,
  "aggs": { "status_count": { "terms": { "field": "status" }}}
}

Field Data Cache

Field Data 是倒排索引的逆向结构,全量加载到 JVM 堆,用于 text 字段的排序和聚合。

为什么需要 Field Data

倒排索引只能回答"哪些文档包含这个词",无法回答"某个文档的字段值是什么"。排序和聚合恰好需要后者,所以 ES 在查询时把倒排索引翻转重建:

倒排索引(搜索用):       Field Data(排序/聚合用,堆内存):
  "手机" → [doc0, doc2]     doc0 → [手机, 苹果, 旗舰]
  "苹果" → [doc0, doc1]     doc1 → [电脑, 苹果]
  "旗舰" → [doc0]           doc2 → [手机, 安卓]

为什么 text 字段无法用 Doc Values

text 经过分词,一个字段变成多个 term("苹果旗舰手机" → [苹果, 旗舰, 手机]),没有单一原始值可以列存,只能在查询时从倒排索引动态重建,结果放进堆里。

代价

100 万文档 × 平均 5 个 term × 每 term ~30 bytes = 150 MB 堆(仅一个字段)
1 亿文档 → ~15 GB 堆

解决方案:需要聚合/排序的字段改用 keyword,走 Doc Values 而非 Field Data。

Doc Values(列式存储)

Doc Values 是写入时预构建的列存结构,存在磁盘上,通过 mmap 进入 OS Page Cache,不占 JVM 堆

_source(行存,原始 JSON):        Doc Values(列存,按字段切分):
  doc0: {name:"iPhone", price:8999}   price 列: doc0→8999, doc1→5999, ...
  doc1: {name:"MacBook", price:5999}  name 列:  doc0→"iPhone", doc1→"MacBook", ...

排序/聚合时只需顺序读目标列文件,完全不碰其他字段,IO 友好,速度远快于 Field Data。

Field Data Doc Values
存储位置 JVM 堆(高风险) 磁盘 .dvd/.dvm,mmap OS Page Cache
构建时机 查询时动态重建 写入时预构建
支持字段 text(唯一选择) keywordintegerdate
性能 首次慢,常驻堆 稳定,不占 JVM 堆
默认状态 关闭(需手动开启) 开启

查询最佳实践

字段类型选择

场景 推荐类型 原因
精确匹配(ID、状态码) keyword 不分词,Term 查询性能好
全文搜索 text 分词后建倒排索引
数字精确查找(无范围查询) keyword 5.x 后 numeric Term 查询性能下降约 80%
数字范围查询 long/integer BKD 树结构,range 查询性能优
需要排序/聚合的数字 long/integer 支持 Doc Values

查询语法优化

1. 避免模糊查询的性能陷阱

查询类型 性能 限制
match / match_phrase ✅ 好 推荐替代模糊查询
wildcard ⚠️ 差 查询词不超过 32 字符,禁用 *词* 前后通配
regexp ⚠️ 差 需计算 DFA,复杂模式会打满 CPU
fuzzy ⚠️ 差 同样需计算 DFA
prefix ⚠️ 差 查询词不超过 32 字符

wildcard/regexp/fuzzy 需要计算有穷自动机(DFA),查询词过长会导致集群 CPU 飙满直至不可用。

2. 嵌套查询的性能代价

查询类型 性能损耗
nested 慢约 10 倍
parent-child 慢约 100 倍

Nested:每个嵌套对象是独立的 Lucene 文档

普通 object 类型写入时直接打平,导致跨对象条件查询出错("手机 AND price=299"也能匹配耳机的价格)。nested 类型通过把每个嵌套对象存成独立隐藏 Lucene 文档来保留对象边界:

Lucene segment 内部(block 结构):
  [隐藏doc: product=手机, price=8999]  ← nested doc 1
  [隐藏doc: product=耳机, price=299]   ← nested doc 2
  [父doc: ...]                          ← 紧跟其后

查询时执行 ToParentBlockJoinQuery:先找匹配的隐藏 nested doc,再沿 segment 向后扫描找到父文档(block join)。代价:1 个文档有 100 个嵌套对象 → 段里实际存了 101 个 Lucene 文档,段合并代价与体积均增加。

Parent-Child:全局 ordinal 映射常驻堆内存

父子文档是完全独立的 Lucene 文档,没有 block 结构约束。为了在查询时关联父子,ES 必须在 JVM 堆中维护一张全局 ordinal 映射表,并在每次查询时全量扫描子文档,再通过 ordinal 映射找到父文档。数据量大时映射表可达数百 MB,且父子写入/删除都要维护此映射。

nested parent-child
存储方式 同一 segment block 内相邻 完全独立文档,任意位置
关联机制 block join(段内局部扫描) 全局 ordinal 映射(堆内存)
内存影响 段体积膨胀 fielddata 常驻堆
适用场景 文档内固定嵌套结构(订单明细) 独立实体的父子关系(商品+评论)

尽量在写入时打平数据结构,将 join 逻辑放在客户端处理。

3. 聚合查询规范

4. 其他注意事项

查询结构优化

1. 深度翻页

2. 控制返回字段

{
  "_source": {
    "includes": ["id", "title", "price"],
    "excludes": ["*.raw", "description"]
  },
  "query": { "term": { "status": "online" }}
}

只返回业务需要的字段,减少 IO 和网络传输。

3. 别名管理


性能优化手段

机器配置

集群配置

# elasticsearch.yml
index.store.preload: ["nvd", "dvd"]  # nvd=倒排索引规范文件, dvd=Doc Values

索引配置

优化项 建议
单分片大小 不超过 30GB
单索引大小 不超过 200GB
副本数量 副本越多查询性能越好(并发读),但写入压力增加
只读历史索引 执行 _forcemerge 合并为 1 个 Segment,大幅提升查询速度
text 字段聚合 设置 fielddata=true,或改用 keyword 类型
不需要排序的 keyword 关闭 doc_values,节省存储和内存

强制合并 Segment(只读索引):

POST /my_index/_forcemerge?max_num_segments=1

⚠️ 只对不再写入的历史索引执行,对写入中的索引执行会严重影响写入性能。

索引排序(Index Sorting)

对于按固定字段排序的查询,在索引创建时配置 Index Sorting,可以让 Lucene 在写入时就按该字段排序,查询时提前剪枝:

PUT /my_index
{
  "settings": {
    "index.sort.field": "create_time",
    "index.sort.order": "desc"
  }
}

附:查询耗时分析速查

端到端时间很长,ES 慢日志短
  → 检查:网络抖动、节点间延迟、队列积压、客户端 GC

took 大,慢日志多
  → 检查:查询复杂度、Segment 数量、索引大小、聚合深度

timed_out=true,但结果部分返回
  → 正常行为:ES 返回超时前已收集的结果,结果不完整,业务需要处理

慢日志少,端到端慢查询多
  → 慢日志阈值设置过高,或慢日志只覆盖了 Lucene 查询阶段

参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论