目录
正在加载目录…

ES 的查询过程分析

发布于 2026-05-26 10:25 · 最后编辑于 2026-07-31 15:52 · 字数 4,807 👁 105 次阅读

本文从整体架构出发,逐层深入讲解 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 Contextmustshould✅ 计算 _score❌ 不缓存
Filter Contextfiltermust_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

为什么要两阶段?

  • Phase 1 只传输轻量的 (doc_id, score) 元组,避免传输大量文档数据
  • Phase 2 只拉取最终需要的少量文档,减少 IO

协调节点的工作

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 等后处理

源码级分析

关键类索引

类名所在模块职责
TransportSearchActionserver协调节点入口,doExecute 发起两阶段查询
SearchPhaseControllerserver合并各 Shard 的 Query Phase 结果
SearchServiceserver数据节点侧,管理 SearchContext 生命周期
QueryPhaseserver数据节点执行 Lucene 查询的核心
FetchPhaseserver数据节点执行文档 Fetch 的核心
IndexSearcherLuceneLucene 查询入口,遍历 Segment
BooleanQueryLucene对应 ES bool query 的 Lucene 实现
TimeLimitingCollectorLucene超时控制,包装实际 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

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

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

慢查询分析思路

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

缓存机制

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

Query Cache(Filter Cache)

  • 作用域:单个 Shard 级别,缓存 filter context 的查询结果(bitset)
  • 触发条件:只有 filter 子句的结果会被缓存,query 子句不缓存
  • 淘汰策略:LRU,默认占 JVM Heap 的 10%
  • 失效条件:Segment 合并后缓存失效
// filter 中的 term/range 查询会被缓存
"filter": [
  { "term": { "status": "online" }},    // ✅ 会缓存
  { "range": { "price": { "gte": 100 }}} // ✅ 会缓存
]
// must 中的 match 查询不会缓存
"must": [
  { "match": { "title": "手机" }}        // ❌ 不缓存
]

Request Cache(Shard-level Cache)

  • 作用域:整个查询请求,缓存聚合结果
  • 触发条件size=0 的纯聚合查询,且索引数据未变化
  • 典型场景:Dashboard 类固定报表查询
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 堆
  • 第一次聚合/排序时全量加载,之后常驻堆内存(LRU 淘汰)
  • 多字段并发聚合 → 堆内存叠加 → OOM 或触发 circuit breaker
  • text 字段的 fielddata 默认关闭,须显式开启 "fielddata": true,以此提醒代价

解决方案:需要聚合/排序的字段改用 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 DataDoc Values
存储位置JVM 堆(高风险)磁盘 .dvd/.dvm,mmap OS Page Cache
构建时机查询时动态重建写入时预构建
支持字段text(唯一选择)keywordintegerdate
性能首次慢,常驻堆稳定,不占 JVM 堆
默认状态关闭(需手动开启)开启

查询最佳实践

字段类型选择

场景推荐类型原因
精确匹配(ID、状态码)keyword不分词,Term 查询性能好
全文搜索text分词后建倒排索引
数字精确查找(无范围查询)keyword5.x 后 numeric Term 查询性能下降约 80%
数字范围查询long/integerBKD 树结构,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,且父子写入/删除都要维护此映射。

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

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

3. 聚合查询规范

  • 聚合嵌套深度不超过 3 层,深度越深 CPU 和 IO 消耗越大
  • terms 聚合的 size 参数控制合理,避免返回过多 bucket
  • 纯聚合查询设置 size=0,利用 Request Cache

4. 其他注意事项

  • terms 查询的入参个数不超过 100 个
  • bool 查询的子查询总数不超过 1024 个
  • 范围查询扫描行数不超过 100,000 行
  • 禁止 match_all 全量查询
  • 禁止 查询时使用 script 动态计算,应提前计算好存入 ES

查询结构优化

1. 深度翻页

  • size 建议不超过 1000,最大不超过 10000(超大 size 是 Full GC 的常见原因)
  • 遍历数据使用 Scroll 或 Search After + PIT,参见 ES 分页

2. 控制返回字段

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

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

3. 别名管理

  • 别名绑定的索引不超过 20 个,过多会导致单次查询遍历大量分片
  • 定期解绑或清理不再使用的历史索引

性能优化手段

机器配置

  • 数据节点内存建议 16G 以上,内存越大文件系统缓存越大
  • JVM Heap 分配物理内存的 50%,剩余 50% 留给 Lucene 的 OS Page Cache
  • Heap 不超过 30-32GB(超过后 JVM 无法使用压缩指针,反而变慢)
  • 数据节点 CPU 建议 8 核以上(聚合和排序 CPU 密集)

集群配置

  • 分片均衡:确保同一索引的分片均匀分布在各节点
  • 冷热数据隔离:历史/低频数据迁移至冷集群,减少热集群压力
  • 预热文件系统缓存:对查询热点索引,预加载倒排索引和 Doc Values 到 Page Cache
# 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)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论