本文从整体架构出发,逐层深入讲解 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 | must、should |
✅ 计算 _score |
❌ 不缓存 |
| Filter Context | filter、must_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 两阶段模型:
为什么要两阶段?
- 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 等后处理
源码级分析
关键类索引
| 类名 | 所在模块 | 职责 |
|---|---|---|
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;
时间耗时拆解
三种时间的本质区别
| 时间指标 | 含义 | 公式 |
|---|---|---|
timeout |
Lucene 查询阶段的超时阈值,最小值 1ms | 配置值 |
took |
ES 内部实际处理时间,不含网络和队列等待 | now - startTime |
| 端到端时间 | 客户端感知的完整耗时 | took + 网络传输 + 队列等待 |
典型规律:端到端时间 > took > timeout 阈值
慢查询分析思路
| 现象 | 可能原因 |
|---|---|
| 端到端慢,ES 慢日志记录短 | 网络抖动、队列阻塞、客户端 GC |
| took 大,单个 Shard 慢 | 查询复杂、数据量大、Segment 过多 |
timed_out=true 但 took 远大于 timeout |
timeout 是 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 Data | Doc Values | |
|---|---|---|
| 存储位置 | JVM 堆(高风险) | 磁盘 .dvd/.dvm,mmap OS Page Cache |
| 构建时机 | 查询时动态重建 | 写入时预构建 |
| 支持字段 | text(唯一选择) |
keyword、integer、date 等 |
| 性能 | 首次慢,常驻堆 | 稳定,不占 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. 聚合查询规范
- 聚合嵌套深度不超过 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 查询阶段
参考资料
- Elasticsearch 官方文档 — Search your data
- Elasticsearch 官方文档 — Query and filter context
- Elasticsearch 官方文档 — Tune for search speed
- Elasticsearch 官方文档 — Caching
- Lucene 官方文档 — IndexSearcher
- Lucene 官方文档 — TimeLimitingCollector
- 《Elasticsearch: The Definitive Guide》— Chapter 9: Search in Depth
- 《深入理解 Elasticsearch》— 第 4 章:查询执行机制
评论 (0)
发表评论