☰
Elasticsearch Query DSL 执行计划剖析:query 与 filter 上下文、缓存命中与慢查询定位
2026/10/10 6:14:12 网站建设 项目流程

Elasticsearch Query DSL 执行计划剖析:query 与 filter 上下文、缓存命中与慢查询定位

1. 从一次“看起来很简单”的慢查询说起

假设你负责一个订单搜索接口:用户输入商品关键词,再加上“状态必须是已完成”“店铺 ID 属于当前商家”“创建时间在最近 30 天”三个过滤条件。索引里大概 2 亿条订单,集群 6 个数据节点。上线初期查询稳定在 80ms,后来数据涨到 2 亿条,接口开始偶发 2s 以上延迟。

你第一反应可能是“数据量变大了,加节点”。但把慢查询日志拉出来后发现:真正慢的那批请求,QPS 并不高,条件也几乎一样,只是分片分布在不同节点上。更奇怪的是,同一条 DSL 在测试环境 5000 万数据下只要 30ms。这说明瓶颈未必在数据量,而在这条 DSL 被 Elasticsearch 翻译成了什么样的执行计划。

本篇要解决的就是这个问题:当一条 Query DSL 发到 Elasticsearch,它到底先做什么、后做什么,为什么有些条件能被缓存而有些不能,为什么慢查询日志只给你一个耗时数字却很难定位根因,以及怎样用_explain、profile、慢日志三件套把问题收敛到具体子句。

先记住一句话模型:Elasticsearch 的一次搜索,本质是把 Query DSL 翻译成一棵“查询树 + 过滤器树”,先靠过滤器树快速砍掉绝大多数文档,再对剩下的文档做相关性打分,最后合并、排序、返回。其中“砍”的部分能缓存,“打分”的部分通常不能缓存。

客户端请求 | v 协调节点(Coordinating Node) | 解析 Query DSL v 查询树 + 过滤器树 | v 每个目标分片(Shard)本地执行 |---- filter 子句 ---> 查 query cache / 倒排索引 ---> 得到候选文档集合 |---- query 子句 --> 对候选文档计算 _score v 分片返回 (docId, score, sortValues) | v 协调节点合并排序,取 top N,回查 _source v 返回客户端

这张图不需要一次记住全部细节,后面每一节都会把其中一个环节拆开。关键是先建立“过滤器先砍,打分后算,合并最后做”的顺序感。

2. 全局框架:Query DSL 被拆成了哪几种角色

把一次搜索拆成四类角色,理解成本会低很多。

第一类是查询子句(query clause),它参与相关性打分,典型代表是match、match_phrase、query_string。第二类是过滤子句(filter clause),它只回答“是或否”,不改变_score,典型代表是term、range、terms、exists。第三类是聚合与排序,它们作用在已经命中的文档集合上。第四类是分片与协调逻辑,负责把结果合并、排序、回查。

它们的连接方式是:Query DSL 顶层有一个query对象,里面可以用bool把多种子句组合起来。bool里的must、should、must_not、filter会把子句分派到“打分路径”或“过滤路径”。过滤路径的产物是位图(bitset),位图可以被 query cache 缓存;打分路径的产物是每个文档的分数,通常难以跨请求复用。

角色典型子句是否打分能否被 query cache 缓存典型用途
query contextmatch、match_phrase、query_string是否(片段级有限缓存)全文检索、相关性排序
filter contextterm、terms、range、exists否是精确筛选、范围筛选
must_not反向过滤否是排除黑名单、无效状态
聚合terms、date_histogram否否统计、分桶

这里最容易误解的是:bool的filter和must在结果上看起来一样,都能过滤文档,但打分语义和缓存语义完全不同。把不该打分的条件写进must,不仅浪费 CPU,还可能让缓存失效,这是生产环境最常见的性能问题之一。

3. query context:为什么它能排序,却常常拖慢查询

3.1 一句话理解打分

相关性打分要回答一个问题:“这个文档和用户输入的关键词有多匹配?”Elasticsearch 默认使用 BM25,它大致看三件事:词项在文档里出现多少次、词项在整个索引里有多稀有、文档长度是否过长。这些计算依赖词频、文档频率和字段长度归一化因子。

因为这些统计量是全局的,而分片是局部的,所以打分只能在每个分片内部先算本地分,再由协调节点合并。这就是为什么match查询在多个分片上会带来额外开销。

3.2 一个最小场景

假设商品索引有 1000 万条数据,字段title使用标准分词器。用户搜索“无线 蓝牙 耳机”。如果 DSL 写成:

{"query":{"bool":{"must":[{"match":{"title":"无线 蓝牙 耳机"}}],"must_not":[{"term":{"status":"deleted"}}]}}}

这里match会分词成“无线”“蓝牙”“耳机”三个词项,然后对每个文档计算 BM25 分数。如果用户根本不关心排序,只想要“标题里包含这些词的商品”,那这次打分就是纯浪费。相比之下,把同样的条件写进filter,Elasticsearch 只需要求交集位图,速度通常快数倍,而且结果可缓存。

3.3 打分路径的执行顺序

在一个分片内,must子句的执行并不是“先全表扫一遍”。如果bool里同时有filter和must,Elasticsearch 会优先执行过滤条件,把候选文档集合缩小,再对候选集合计算分数。这是它内部的一个重要优化:先用代价低的过滤砍数据,再用代价高的打分算相关性。因此,把过滤条件写进filter不只是“语义更清晰”,还会直接改变执行顺序。

但要注意边界:如果must里的子句本身无法高效执行,比如通配符前缀过长、正则范围太大,即使外面套了filter,候选集也可能很大,打分阶段依然会慢。换句话说,过滤器能缩小范围,但不能拯救一个本身就很重的查询子句。

4. filter context:不打分,为什么还能更快

4.1 filter 的产物是位图

过滤条件执行后,每个分片会得到一个位图,表示“哪些文档满足这个条件”。如果多个过滤条件并存,就把多个位图做交集、并集或差集。位图运算非常快,而且是按文档编号进行的,不涉及词频计算。

更重要的是,这个位图可以放进query cache(节点级查询缓存,准确叫法是 node query cache,它缓存的是过滤器位图,不是完整响应)。下次有请求命中相同的过滤条件时,直接从缓存取位图,不再重算倒排索引。

4.2 一个 filter 缓存命中的例子

场景:商家后台首页要展示“当前商家 + 已完成 + 最近 30 天”的订单数,每 5 秒刷新一次。DSL 写成:

{"query":{"bool":{"filter":[{"term":{"shop_id":"S10086"}},{"term":{"status":"finished"}},{"range":{"created_at":{"gte":"now-30d/d","lte":"now/d"}}}]}},"size":0,"track_total_hits":true}

如果shop_id和status的过滤条件被缓存,后续请求可以直接复用位图。注意range里的now-30d/d会随时间变化,缓存键包含具体的时间范围,所以每过一天就会生成一个新的缓存项。这就是为什么“时间范围过滤”往往缓存命中率低:不是它不能被缓存,而是它的键一直在变。

4.3 query cache 的边界

node query cache 默认大小是堆内存的 10%,使用 LRU 淘汰。它缓存的是过滤子句的位图,不缓存match这种打分查询,也不缓存聚合结果。以下情况通常不适合依赖它:

  • 过滤条件包含高基数且快速变化的字段,比如订单 ID、时间戳精确到毫秒;
  • 过滤条件的组合太多,缓存项很快被挤掉;
  • 单个位图过大,超过单个缓存项上限(默认 10000 个文档对应的位图),会自动跳过缓存。
场景是否适合 filter 缓存原因
商家 ID + 固定状态码适合键稳定,重复率高
精确到毫秒的时间范围不适合键几乎不重复,缓存很快失效
高基数用户 ID 单点查询视情况重复访问才有收益,否则浪费缓存
热门分类 + 上架状态适合低基数、高频复用

这里最容易误解的是:filter 不等于“一定命中缓存”。filter 只是让位图有资格进缓存,是否命中取决于键的重复率、缓存容量和位图大小。把filter当成“免费的”,在低重复率场景下依然会消耗 CPU。

5. 一次请求完整走一遍:从 DSL 到响应

现在让一次请求按时间顺序完整走一遍。假设客户端发送一条带bool的查询,命中 3 个分片,分片分别位于节点 A、B、C。

客户端 | 1. 发送 Query DSL v 协调节点 | 2. 解析 DSL,确定目标索引与分片 | 3. 将查询广播到 3 个分片 v 分片 A / B / C | 4. 每个分片本地执行: | a. 先执行 filter 子句 -> 查 query cache | b. 未命中则扫倒排索引生成位图,并写入缓存 | c. 对过滤后的候选文档执行 query 子句打分 | d. 取本分片 top N,返回 (docId, score, sortValues) v 协调节点 | 5. 合并 3 个分片的 top N,全局排序 | 6. 回查 _source(如果请求了) v 客户端

这个流程里有几个关键点。第一,过滤发生在打分之前,前提是过滤条件能先执行。第二,每个分片只返回 top N,不是全部命中,这就是深度分页问题的根源。第三,协调节点合并后还要回查_source,如果返回字段很多,回查也是一笔开销。第四,query cache 是节点级的,不是集群级的,所以同一个过滤条件在不同节点上可能各自缓存一份。

理解了这条链路,再看慢查询日志里的耗时,就能判断瓶颈更可能在哪一段:是 filter 位图没命中、打分太重、合并排序太慢,还是回查字段太多。

6. 执行计划怎么看:_explain 与 profile 的读法

6.1 _explain:回答“为什么这个文档是这个分数”

_explain用来解释单个文档的得分构成。它适合调试相关性排序,不适合定位整体慢查询。请求形态如下:

GET/products/_explain/1001{"query":{"bool":{"must":[{"match":{"title":"无线 蓝牙 耳机"}}],"filter":[{"term":{"status":"on_sale"}}]}}}

返回中会看到value、description、details,details里逐层展示每个词项的 BM25 贡献。你能看到“哪个词项贡献了多少分”,从而判断是不是某个高频词把分数拉高了。

但要注意:_explain只解释打分,不会告诉你过滤位图是否命中缓存,也不会给你整体耗时分布。它是“微观得分显微镜”,不是“宏观性能剖面图”。

6.2 profile:回答“这条查询在哪个子句上花了多少时间”

profile才是定位慢查询执行计划的主力。它会返回每个分片、每个子句的time_in_nanos、build_scorer、next_doc、advance等指标。请求形态如下:

GET/products/_search{"profile":true,"query":{"bool":{"filter":[{"term":{"shop_id":"S10086"}},{"term":{"status":"finished"}}],"must":[{"match":{"title":"无线 蓝牙 耳机"}}]}},"size":10}

返回中你会看到query数组下每个分片的breakdown。重点看三个数字:build_scorer表示构建打分器的时间,next_doc表示遍历候选文档的时间,advance表示跳转的时间。如果next_doc特别大,说明候选集太大,通常是过滤没生效或过滤条件本身太弱;如果build_scorer大,说明打分逻辑太重,比如模糊查询、短语查询、脚本查询。

profile 有代价,会放大查询开销,不要在生产高峰长期开启。它更适合在预发环境复现问题,或者在低峰期对单条慢查询采样。

工具回答的问题是否展示缓存性能开销适用场景
_explain单个文档为什么是这个分数否低,单文档调相关性排序
profile每个子句耗时分布部分(可看缓存相关指标)高定位慢子句
慢查询日志哪些查询超过阈值否低线上发现慢查询
节点统计 API缓存命中、淘汰、内存占用是低观察缓存健康度

7. 慢查询日志:怎样从“慢”走到“为什么慢”

慢查询日志(slow log)记录超过阈值的查询和抓取阶段。它分查询阶段和 fetch 阶段,配置在索引级别,可以动态调整。一个典型配置如下:

PUT/products/_settings{"index.search.slowlog.threshold.query.warn":"1s","index.search.slowlog.threshold.query.info":"500ms","index.search.slowlog.threshold.fetch.warn":"500ms","index.search.slowlog.level":"info"}

查询阶段的慢日志会记录整条 DSL、耗时、分片信息。抓取阶段的慢日志记录回查_source的耗时。两者要分开看:如果查询阶段慢,问题在过滤和打分;如果 fetch 阶段慢,问题在返回字段太多或磁盘 IO。

慢查询日志本身不是根因分析工具,它只告诉你“哪条查询慢”。要定位根因,需要把慢日志里的 DSL 拿到预发环境,开启profile复现,再结合节点统计 API 看 query cache 的命中与淘汰:

GET /_nodes/stats/indices/query_cache

重点看hit_count、miss_count、evictions、memory_size_in_bytes。如果miss_count和evictions都高,说明缓存不够或键太散;如果hit_count高但查询依然慢,说明瓶颈不在过滤,而在打分或合并排序。

8. 完整示例一:验证 filter 与 must 的执行差异

目标

在本地单节点 Elasticsearch 上,用同一份数据对比must与filter的执行耗时,验证“过滤优先、打分更贵”这条规则。

前置环境

  • 本地启动单节点 Elasticsearch 8.x,关闭安全认证或使用默认用户;
  • 使用curl或 Kibana Dev Tools。

步骤一:建索引并写入数据

PUT/demo_ctx{"settings":{"number_of_shards":1,"number_of_replicas":0},"mappings":{"properties":{"title":{"type":"text"},"status":{"type":"keyword"},"shop_id":{"type":"keyword"}}}}

然后用_bulk写入 5000 条文档,status在on_sale和off_sale之间交替,shop_id在S1到S10之间循环,title填一些包含“无线 蓝牙 耳机”的文本。

步骤二:分别执行 must 与 filter 查询

POST/demo_ctx/_search{"query":{"bool":{"must":[{"match":{"title":"无线 蓝牙 耳机"}},{"term":{"status":"on_sale"}}]}},"size":10}
POST/demo_ctx/_search{"query":{"bool":{"filter":[{"term":{"status":"on_sale"}},{"term":{"shop_id":"S1"}}],"must":[{"match":{"title":"无线 蓝牙 耳机"}}]}},"size":10}

预期结果与观察

在took字段上,多数情况下第二条更快,因为status和shop_id先缩小候选集,match只在更小的集合上打分。如果数据量太小,差异可能不明显,这属于正常现象。

边界与容易改错的地方

  • 如果status只有两个值,过滤收益有限,真正收益来自候选集缩小;
  • 如果title的match本身就是主要过滤条件,把它从must改成filter会丢失相关性排序,只适合“不关心排序”的场景;
  • 测试时要固定分片数,否则分片数变化会影响耗时对比。

9. 完整示例二:观察 query cache 命中与淘汰

目标

通过重复执行同一组过滤条件,观察 node query cache 的hit_count与miss_count变化,理解“什么条件会被缓存”。

前置环境

复用示例一的索引,保证status和shop_id是keyword类型。

步骤一:清零观察基线

GET /_nodes/stats/indices/query_cache

记录当前的hit_count和miss_count。

步骤二:连续执行 20 次相同的过滤查询

foriin$(seq120);docurl-s-XPOST"http://localhost:9200/demo_ctx/_search"\-H"Content-Type: application/json"\-d'{"size":0,"query":{"bool":{"filter":[{"term":{"status":"on_sale"}},{"term":{"shop_id":"S1"}}]}}}'>/dev/nulldone

步骤三:再次查看缓存统计

GET /_nodes/stats/indices/query_cache

预期结果与观察

hit_count应该明显增长,miss_count增长有限。这说明重复的过滤条件确实复用了位图。如果你把shop_id换成每次都不同的随机值,miss_count会持续增长,hit_count几乎不动。

边界与容易改错的地方

  • 单分片、数据量小的时候,位图很小,缓存收益不容易观察;
  • size: 0不返回文档,但仍会执行查询阶段,适合观察缓存;
  • 缓存是节点级的,多节点集群要看所有节点的聚合值。

10. 完整示例三:用 profile 定位一条慢查询

目标

构造一条既有过滤又有打分的慢查询,用profile找出耗时集中在哪个子句,并给出改写方向。

前置环境

在示例一索引基础上,写入 5 万条文档,title包含一些长文本,并加入一个wildcard或match_phrase子句模拟高开销打分。

步骤一:执行带 profile 的查询

POST/demo_ctx/_search{"profile":true,"query":{"bool":{"filter":[{"term":{"status":"on_sale"}}],"must":[{"match_phrase":{"title":"无线 蓝牙 耳机"}}]}},"size":5}

步骤二:读取 profile 结果

关注返回里每个分片的breakdown。重点比较:

  • build_scorer:如果很大,说明match_phrase构建代价高;
  • next_doc:如果很大,说明候选文档太多;
  • advance:如果很大,说明跳转频繁,通常是多条件交集。

步骤三:改写并对比

如果发现build_scorer是主要瓶颈,可以尝试:把match_phrase换成match加filter组合,或在业务上确认是否真的需要短语匹配。如果next_doc是主要瓶颈,优先补充更有效的filter条件。

预期结果与观察

改写后再次执行profile,对比time_in_nanos总和。通常能从“打分器构建”转移到“过滤位图交集”,整体耗时下降。

边界与容易改错的地方

  • profile本身会放大耗时,不要拿它的绝对值和真实查询比较,只看相对占比;
  • match_phrase有位置信息要求,改写为match会改变语义,必须由业务确认;
  • 如果分片数多于 1,要综合所有分片的breakdown,不要只看第一个。

11. 常见误区:那些“看起来等价”的写法

第一个误区是把过滤条件写进must。结果正确,但会触发不必要的打分,并且无法利用 query cache。

第二个误区是认为 filter 一定命中缓存。缓存命中取决于键重复率、缓存容量和位图大小,不是语法决定的。

第三个误区是用match做精确值过滤。对keyword字段用match可能仍能工作,但对text字段会分词,语义完全变化。精确筛选应该用term或terms。

第四个误区是只看慢查询日志的took值。took是整个请求耗时,不区分查询阶段和 fetch 阶段,也不告诉你哪个子句慢。

第五个误区是把profile长期开在生产。它会显著增加开销,适合复现和采样,不适合常态开启。

误区实际影响正确做法
过滤写进must多算分数、缓存不可用不关心排序时用filter
认为 filter 必命中缓存低重复率场景白白消耗 CPU看缓存统计再判断
text字段用term分词后匹配失败明确字段类型再选子句
只看took无法定位子句配合 profile 与慢日志
生产常开 profile查询放大、节点压力大低峰采样或预发复现

12. 生产实践建议:怎样把执行计划纳入日常

第一,在索引设计阶段就区分打分字段和过滤字段。title、description这类全文检索字段用于match;status、shop_id、category_id、时间范围用于filter。Mapping 里keyword和text的划分要和查询写法一致。

第二,把慢查询日志阈值设成业务可接受的上限,并定期把慢日志里的 DSL 归档。不要只看条数,要看慢查询的分布:是集中在某个索引、某个分片,还是某类条件组合。

第三,观察 query cache 的三组指标:命中、未命中、淘汰。如果淘汰率持续高,要么是缓存太小,要么是过滤条件太散。不要盲目调大缓存,先看键的重复率。

第四,对深度分页单独设计。from + size越大,协调节点合并成本越高。超过一万条以后,应该改用search_after或滚动查询,而不是继续加大size。

第五,Spring Boot 集成时把查询构造集中管理。不要让每个业务方法各自拼 JSON,否则must和filter的边界会很快失控。可以用统一的 QueryBuilder 工厂方法,把“过滤条件”和“打分条件”明确分开。

// 简化代码:把过滤条件与打分条件分开构造,便于审查执行计划BoolQueryBuilderbool=QueryBuilders.boolQuery();bool.filter(QueryBuilders.termQuery("status","on_sale"));bool.filter(QueryBuilders.termQuery("shop_id",shopId));bool.must(QueryBuilders.matchQuery("title",keyword));SearchSourceBuildersource=newSearchSourceBuilder().query(bool).size(10).trackTotalHits(true);

这段代码不能独立运行,只是展示构造思路:filter放过滤条件,must放打分条件。它的价值在于让代码审查时一眼能看出哪些条件会进缓存、哪些会触发打分。

13. 排障清单:线上慢查询按这个顺序查

  1. 先从慢查询日志确认慢的是查询阶段还是 fetch 阶段;
  2. 如果 fetch 阶段慢,检查返回字段是否过多、是否请求了_source全部字段;
  3. 如果查询阶段慢,把 DSL 拿到预发环境开启profile;
  4. 看build_scorer、next_doc、advance哪个占比最大;
  5. 检查过滤条件是否写进了must,能否迁移到filter;
  6. 检查过滤字段类型是否与查询子句匹配,text误用term会导致全扫;
  7. 查看 query cache 的命中、未命中、淘汰指标;
  8. 检查分片数量与数据分布,是否存在热点分片;
  9. 检查排序与聚合是否作用在未优化的字段上;
  10. 最后才考虑扩容,因为扩容未必能解决打分和合并瓶颈。

14. 面试/复盘问题:检验你是否真的理解执行计划

  1. must和filter在结果上等价,为什么性能差异可能很大?
  2. query cache 缓存的是什么?它为什么不缓存match查询的分数?
  3. 为什么range过滤条件用now-30d/d时缓存命中率可能不高?
  4. profile里的next_doc很大,通常说明什么?
  5. 慢查询日志里的took为什么不等于所有子句耗时之和?
  6. 深度分页为什么不能靠加size解决?
  7. 如果一条查询在单分片很快、多分片很慢,你会优先查什么?
  8. 把text字段用term查询会发生什么?为什么?

15. 总结:把执行计划收回一张决策图

回到开头那次慢查询。真正要做的不是立刻加节点,而是先判断:这条 DSL 里哪些条件应该进filter,哪些过滤条件的键重复率足够高,哪些打分条件可以简化,慢查询日志里的耗时落在查询阶段还是 fetch 阶段。

把全文收成一张决策图:

拿到一条慢查询 | +-- 慢在 fetch 阶段? --> 减少返回字段 / 避免全量 _source | +-- 慢在查询阶段? | +-- filter 没生效? --> 把可过滤条件从 must 迁到 filter | +-- filter 生效但缓存不命中? --> 看键重复率与 evictions | +-- 打分太重? --> 简化 match/match_phrase,或改为 filter | +-- 候选集太大? --> 补过滤条件、检查字段类型 | +-- 合并排序太慢? --> 减少分片、用 search_after 替代深分页

最后一句话总结:Query DSL 的性能不取决于你写了多少条件,而取决于条件被分派到哪条路径、能否被缓存、以及候选集是否被尽早缩小。先理解执行计划,再谈调优,顺序不能反。

参考资料

  • Elasticsearch 官方文档:Query DSL、Bool Query、Query and filter context
  • Elasticsearch 官方文档:Search profile API
  • Elasticsearch 官方文档:Slow log
  • Elasticsearch 官方文档:Node query cache settings 与 indices stats
  • Elasticsearch 官方文档:Search after 与分页最佳实践
  • Elasticsearch 官方文档:Mapping 与 text/keyword 字段类型
  • 《Elasticsearch: The Definitive Guide》相关章节(过滤与查询上下文)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询