查询慢是 Elasticsearch 最常见的生产问题,但「慢」只是一个现象,真正要回答的是:慢在哪个阶段、慢在哪个分片、慢在哪个子查询。Elasticsearch 提供了三层诊断工具:profile API 精确到每个查询子句的毫秒耗时,慢查询日志按阈值持续采样线上请求,热点线程 API 揭示 CPU 到底消耗在哪段代码。三者组合,能把「查询变慢了」这种模糊描述,收敛成一个可验证、可复现、可修复的具体结论。本文按「发现问题 → 定位阶段 → 定位分片 → 定位线程 → 修复验证」的顺序展开。
1. 查询执行模型与剖析入口
一句话总结: 一次搜索要经过协调节点分发、各分片 query 阶段打分、fetch 阶段取回文档三步,剖析工具正是按这个模型逐层下钻。
1.1 一次搜索的完整链路
客户端请求先到达协调节点,协调节点把查询广播到索引的每个分片(含副本,默认按轮询选择)。每个分片在本地执行 query 阶段,计算匹配文档与得分,返回 doc_id 与排序值;协调节点归并各分片结果,选出 top N,再向对应分片发起 fetch 阶段取回 _source。最终合并、排序、返回。
理解这条链路很重要,因为耗时可能来自任何一段:query 阶段慢说明查询条件或打分重,fetch 阶段慢说明取回的字段太多,协调节点慢说明分片数过多或结果集太大。
1.2 三层诊断工具
- profile API:对单条查询做逐子句剖析,返回各阶段耗时与命中数,适合精确分析。
- 慢查询日志:按
query、fetch两个阈值记录线上慢请求,适合发现共性问题。 - 热点线程 API:抓取节点上正在消耗 CPU 的线程栈,适合定位突发卡顿。
1.3 开启剖析
在搜索请求体里加 "profile": true 即可:
curl -X GET "localhost:9200/logs/_search" -H "Content-Type: application/json" -d'
{
"profile": true,
"query": {
"bool": {
"must": [ { "match": { "message": "timeout" } } ],
"filter": [ { "term": { "service": "gateway" } } ]
}
}
}
'
剖析有额外开销,只用于诊断单条查询,绝不要放进生产常规请求。
2. profile API 逐阶段耗时
一句话总结: profile 返回 query 与 fetch 两棵耗时树,每层给出 time、time_in_nanos 与命中数,逐层下钻即可找到真正的耗时热点。
2.1 返回结构
响应中 profile.shards 是每个分片一份剖析结果,每份含 query 与 fetch 两段:
{
"profile": {
"shards": [
{
"id": "[node1][0]",
"searches": [
{
"query": [
{
"type": "BooleanQuery",
"description": "message:timeout service:gateway",
"time_in_nanos": 4820000,
"breakdown": {
"score": 1200000,
"build_scorer": 2800000,
"next_doc": 620000,
"advance": 200000
}
}
],
"fetch": { "time_in_nanos": 310000 }
}
]
}
]
}
}
2.2 breakdown 各字段含义
breakdown 把每个查询子句的耗时拆成若干原子操作:
- create_weight:构建权重对象,含分析器与词典加载,首次查询较慢。
- build_scorer:构建打分器,term 查询常在这里做倒排表定位。
- next_doc:逐个取下一个匹配文档,是遍历的主要开销。
- advance:跳到指定文档号,用于多子句求交。
- score:计算相关性得分,关闭打分(filter 上下文)时为零。
- match:
match类查询执行匹配的开销。
哪个字段大,瓶颈就在哪:next_doc 大说明匹配文档太多,build_scorer 大说明倒排表定位慢,score 大说明打分函数重。
2.3 用 filter 上下文消掉打分
把纯过滤条件放进 filter 而不是 must,ES 会跳过打分:
{
"profile": true,
"query": {
"bool": {
"must": [ { "match": { "message": "timeout" } } ],
"filter": [
{ "term": { "service": "gateway" } },
{ "range": { "@timestamp": { "gte": "now-1h" } } }
]
}
}
}
对照剖析结果,能看到 filter 子句的 score 为 0,且 next_doc 明显下降——这是最直接的优化收益。
2.4 分片间差异
如果某些分片耗时远高于其他分片,说明数据分布不均(热点分片)或该分片正在合并段。剖析结果里每个分片独立计时,横向对比即可发现倾斜。
3. 慢查询日志配置与阈值
一句话总结: 慢查询日志分 query 与 fetch 两个阶段、warn/info/debug 三个级别,按阈值持续采样线上慢请求,是发现共性问题的主力。
3.1 两级阈值
慢日志对 query 阶段与 fetch 阶段分别设阈值,因为两者瓶颈原因不同:query 慢通常是查询重,fetch 慢通常是取回字段多或 _source 太大。
PUT /logs/_settings
{
"index.search.slowlog.threshold.query.warn": "5s",
"index.search.slowlog.threshold.query.info": "1s",
"index.search.slowlog.threshold.fetch.warn": "1s",
"index.search.slowlog.threshold.fetch.info": "500ms"
}
阈值支持 -1 关闭、0 全量记录。级别从宽到严:warn 只记最慢的,debug 记全部。
3.2 全局与索引级配置
慢日志可以配在索引级,也可以配在集群级作为默认值:
PUT /_cluster/settings
{
"persistent": {
"cluster.search.request.slowlog.threshold.query.warn": "5s"
}
}
索引级配置优先于集群级。日志量大时建议索引级精细配置,只对关键业务索引开启。
3.3 日志格式解读
慢日志落在 logs/<cluster>.index_search_slowlog.log,一行一条:
[2026-10-01T09:12:33,120][WARN ][i.s.s.query] [node1] [logs][0]
took[6.2s], took_millis[6200], total_hits[42], types[], stats[],
search_type[QUERY_THEN_FETCH], total_shards[12], source[...]
关键字段是 took_millis、total_hits、total_shards 与 source(原始查询体)。total_shards 过大说明查询扫了太多分片,total_hits 巨大说明缺少有效过滤。
3.4 从慢日志到候选查询
慢日志的价值在于「聚类」:把同类慢查询的 source 归并,统计出现频次与耗时分布,找出 Top N 高频慢查询,逐个用 profile 剖析。不要试图优化每一条慢日志,先解决影响面最大的那几条。
3.5 日志轮转与开销
慢日志本身也占 IO,阈值过松(如 0ms)会产生海量日志并拖慢写入。生产建议 warn 阈值设在 p99 耗时附近,info 略低,debug 仅在排查期临时开启。同时配置日志轮转,避免磁盘写满。
4. 热点分片与热点线程定位
一句话总结: 热点分片是数据分布或查询分布不均的结果,热点线程揭示 CPU 消耗在哪段代码,两者分别从「数据」与「执行」两个角度定位瓶颈。
4.1 识别热点分片
_cat/indices 看索引级指标,_cat/shards 看分片级:
GET /_cat/shards/logs-000001?v&h=index,shard,prirep,docs,store,node&s=store:desc
如果某分片 docs 或 store 远大于同索引其他分片,说明路由键分布不均。查询侧热点则表现为「同一分片 CPU 持续偏高」,用 _cat/thread_pool/search 观察各节点队列:
GET /_cat/thread_pool/search?v&h=node_name,active,queue,rejected
4.2 热点分片的成因
- 路由键基数低:自定义
_routing只用了少数字段值,数据全挤到个别分片。 - 时间倾斜:按天滚动时,今天的分片写入远大于历史分片。
- 查询倾斜:多租户场景下大租户的查询总落在同一组分片。
对策分别是提高路由键基数、让滚动阈值更细、或给大租户独立索引。
4.3 热点线程 API
节点 CPU 飙高时抓线程栈:
GET /_nodes/hot_threads
返回各节点 CPU 占用最高的线程及其调用栈,常见模式:
Lucene Merge Thread:后台段合并,属正常但可调优。search线程池中的TermScorer:查询匹配文档过多。write线程池:写入压力大或 translog 刷盘频繁。refresh:refresh 间隔过短导致频繁生成新段。
4.4 结合线程池指标
_cat/thread_pool 的 rejected 计数是限流信号:一旦出现拒绝,说明线程池队列已满,请求被直接丢弃。此时要么优化查询降负载,要么扩容节点。搜索线程池大小通常为 CPU 核数 × 3 / 2 + 1,不是越大越好——过多线程会加剧上下文切换。
4.5 慢日志与热点线程的配合
慢日志告诉你「哪些查询慢」,热点线程告诉你「CPU 花在哪」。若慢日志里查询本身不慢,但节点 CPU 高,多半是后台任务(merge/refresh)或聚合计算;若两者都指向同一查询,则是查询本身的问题,直接用 profile 剖析。
5. search 阶段拆解与常见瓶颈
一句话总结: query 阶段慢在匹配与打分,fetch 阶段慢在取回与反序列化,协调阶段慢在归并与分片数量,三类瓶颈对策完全不同。
5.1 query 阶段瓶颈
- 匹配文档过多:
next_doc占主导,说明过滤条件没筛掉足够数据。加时间范围或高选择性term过滤。 - 通配与正则:
wildcard、regexp会逐词条扫描,build_scorer极大。改用keyword+ 前缀索引或match_phrase_prefix。 - 脚本查询:
script查询无法利用倒排索引,必须遍历全部文档。尽量预计算成字段。 - 模糊匹配:
fuzzy编辑距离扫描词典,代价随词典规模增长。
5.2 fetch 阶段瓶颈
- 取回字段过多:
_source全量返回大文档。用_source: { includes: [...] }或stored_fields只取需要字段。 - 深分页:
from + size超过max_result_window会全量归并。改用search_after。 - 高亮计算:
highlight会重新分析匹配片段,对长文本代价显著。
{
"query": { "match": { "message": "timeout" } },
"_source": { "includes": ["@timestamp", "service", "level"] },
"size": 20
}
5.3 协调节点瓶颈
分片数过多时,协调节点要归并所有分片的候选结果。默认每个分片返回 from + size 条候选,10 个分片查 100 条就是 1000 条候选在协调节点排序。分片越多,协调开销越大。对策是控制分片数,或用 preference 让同一用户的查询落到固定副本。
5.4 缓存与剖析的关系
ES 有 request cache(仅 filter 上下文、size=0 的聚合)与 query cache(段级)。剖析中若 build_scorer 异常小,可能命中了缓存;反之首次查询会包含词典加载开销。对比「冷查询」与「热查询」的剖析结果,能区分一次性开销与常态开销。
5.5 聚合查询的剖析
聚合的耗时体现在 profile 之外的 aggregations 段。terms 聚合基数高时内存与耗时陡增,可用 shard_size 与 size 控制精度与开销的平衡。基数极高的分组考虑用 composite 分页或预聚合。
6. 从剖析到优化的闭环
一句话总结: 优化不是猜的,而是「测量 → 假设 → 改动 → 复测」的循环,每次只改一处并用同一查询对比剖析结果。
6.1 建立基准
优化前先固定查询、固定数据集、清空缓存后测量基线耗时。POST /logs/_cache/clear 清缓存,"profile": true 拿基线剖析,记录关键子句的 time_in_nanos。
6.2 单变量改动
一次只改一个变量:把 must 改 filter、加时间范围、换掉 wildcard、减少返回字段。改完复测同一查询,对比剖析树的耗时变化。多变量同时改,无法归因。
6.3 常见优化手法清单
| 手法 | 适用场景 | 预期收益 |
|---|---|---|
| 过滤条件移入 filter | 不需要打分的条件 | 消除 score 开销 |
| 加时间范围 | 日志、时序 | 减少扫描分片与段 |
| keyword 替代 text 精确匹配 | 枚举类字段 | 避免分析开销 |
| search_after 替代 from/size | 深分页 | 避免全量归并 |
| _source 裁剪 | 大文档 | 降低 fetch 耗时 |
| 预计算字段 | script 查询 | 转成倒排可索引 |
6.4 索引侧的配合
查询优化有天花板,很多瓶颈最终要回到索引设计:字段类型选对(keyword vs text)、映射避免过多字段、必要时 doc_values 关闭、对只读索引 force merge 减少段数。查询与索引是一体两面,profile 结果里 build_scorer 高往往提示该优化映射与段结构。
6.5 验证与回归
优化后除了看单次耗时,还要看慢日志里该查询是否消失、线程池 rejected 是否归零。把优化前后的剖析结果归档,形成可复用的调优记录。
7. 生产排查实战
一句话总结: 生产排查遵循「告警 → 慢日志聚类 → 热点线程 → profile → 修复 → 复测」的固定流程,避免盲目重启或扩容。
7.1 一次典型的排查过程
告警显示搜索 p99 从 200ms 涨到 3s。第一步看 _cat/thread_pool/search,发现某节点 queue 持续非零;第二步抓 _nodes/hot_threads,栈指向 TermScorer.nextDoc;第三步查慢日志,发现同一类查询 total_hits 达到百万级且无时间过滤;第四步对该查询做 profile,确认 next_doc 占 80% 耗时;第五步给查询补上时间范围,复测 p99 回到 220ms。
7.2 何时该扩容
如果 profile 显示查询结构已优化、慢日志里查询本身不慢,但线程池仍持续拒绝,说明是吞吐不足而非单查询慢,此时扩容节点或增加副本分流才是正确选择。反之,查询本身有优化空间时扩容只是掩盖问题。
7.3 长期治理
把慢查询日志接入监控,对慢查询数量、Top 慢查询、线程池拒绝数设告警;定期巡检热点分片;把 profile 纳入慢查询复现的标准动作。诊断工具只有形成例行流程,才能从「救火」变成「预防」。
8. 总结
| 环节 | 要点 |
|---|---|
| 执行模型 | 协调分发 → query 打分 → fetch 取回 |
| profile API | 逐子句剖析,看 breakdown 定位热点操作 |
| filter 上下文 | 无需打分的条件放 filter,score 归零 |
| 慢查询日志 | query/fetch 双阈值,按频次聚类找共性问题 |
| 热点分片 | _cat/shards 看分布,成因是路由或时间倾斜 |
| 热点线程 | _nodes/hot_threads 看 CPU 栈,配合线程池拒绝数 |
| search 阶段 | query 慢在匹配打分,fetch 慢在取回字段 |
| 优化闭环 | 单变量改动、复测剖析、验证慢日志 |
| 扩容判断 | 查询已优化仍拒绝才扩容,否则掩盖问题 |
查询诊断的核心是「用数据说话」:profile 给出耗时分布,慢日志给出线上分布,热点线程给出 CPU 分布,三者交叉验证才能排除猜测。把这套方法论固化下来,Elasticsearch 的性能问题就从玄学变成工程。查询本身的写法与打分调优见《Query DSL 与相关性打分》与《相关性与打分进阶》,索引结构与缓存机制见《性能调优与缓存策略》,集群层面的监控告警见《集群运维与监控》。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。