☰
ES Query DSL从入门到实战:查询骨架、叶子查询与bool组合
2026/10/13 13:01:59 网站建设 项目流程

做ES入门系列这段时间,后台被问到最多的一个问题就是:索引建好了、数据灌进去了,接下来怎么把数据查出来?说实话,前几篇讲索引和映射还算平静,一到查询语法这一篇,很多同学就卡住了。不是看不懂文档,而是文档里一片一片的JSON示例,随手抄一个能跑,换一个需求就不会改。这篇我准备换一个讲法,不按官网文档的顺序来,而是按一个真实项目里从简单到复杂的排查思路,把这套Query DSL的骨架拆开给你看,目标只有一个:让你看完之后,拿到一个搜索需求,心里能立刻知道该往哪个语法上靠。

1. 先理清查询请求的整体骨架

1.1 一份查询请求里装了什么

先看一个最常见的查询请求(直接在可视化控制台的开发工具里执行):

GET /shop_order/_search { "query": { "match": { "buyer_name": "林某某" } }, "size": 10, "from": 0, "sort": [ { "create_time": "desc" } ], "_source": ["order_id", "buyer_name", "amount"], "aggs": { "total_amount": { "sum": { "field": "amount" } } } }

解析一下,一个查询请求可以拆成几个部分。

  • query:真正描述"筛什么"的查询条件,是整个请求的灵魂。
  • from / size:分页参数,from表示跳过多少条,size表示取多少条。
  • sort:排序规则,按哪个字段、升序还是降序。
  • _source:控制返回哪些字段,就像SQL里的select指定列。
  • aggs:聚合统计,类似于SQL里的group by和聚合函数。

很多初学者有个误区,以为ES查询就是query一个字段,结果遇到"只要某几个字段""按时间倒序""统计一下总额"这些需求时无从下手。其实ES的查询是分层的:先定义查询条件,再定义返回形状,再定义统计口径,三部分互相独立,可以组合使用。这也是我建议你把上面这个骨架先记下来的原因——后续所有的查询语法,都只是往query、sort、aggs这些格子里填东西而已。

1.2 为什么ES用的是JSON形式的Query DSL

用过关系型数据库的同学,一开始一定很不习惯:明明有个SQL标准语法,为什么ES非要搞一套JSON查询?

原因要从ES的底层说起。ES底层是Lucene,它的查询是一层Query对象,有很强的类型层次和组合能力。在分布式环境下,查询请求要在协调节点和数据节点之间传递,需要一种跨语言、跨平台的序列化格式。JSON天然适合干这个:人类可读、解析成本低、结构灵活。于是ES把Lucene的各种Query包装成了对应的JSON DSL结构,这套东西就叫Query DSL。

你可以把Query DSL理解成给ES看的JSON版"需求单"。SQL适合表达"表、行、列、join"这类思维,Query DSL更适合表达"倒排索引、分值、过滤、聚合"这类搜索引擎思维。所以ES查询里经常见到"匹配度""评分""缓存"这些概念,SQL里反而不讲。等你适应了这套思维方式,会发现ES查询语法比SQL更贴近信息检索的场景。

顺带提一句,ES也提供了SQL接口和Query DSL翻译能力,但生产环境里,最稳、最可控的仍然还是Query DSL。我见过不少团队为了"让业务同学写SQL"引入ES SQL,结果复杂相关性评分需求一多,最后还是回到DSL。所以入门阶段别绕弯,直接啃Query DSL是最划算的。

1.3 查询结果长什么样

第一次执行查询时,建议你先忽略返回里的hits以外的东西。返回结构大概是这样:

{ "took": 12, "hits": { "total": { "value": 10000, "relation": "gte" }, "max_score": 2.3, "hits": [ { "_index": "shop_order", "_id": "123", "_score": 2.3, "_source": { "order_id": "123", "buyer_name": "林某某" } } ] } }

took是毫秒耗时,hits.hits是命中的文档列表,_score是相关度评分,默认按这个值降序排。注意total在7.0之后变成了带有relation的字段,当total超过10000时value显示10000、relation为gte,代表"至少10000条"。这是ES出于性能考虑做的一个近似计数。如果你需要精确计数,可以设置track_total_hits为true,但会有性能开销,常规场景不建议用。

这个返回结构知道就够了,不要为它花太多时间。真正要花时间的是怎么把query写好。

2. 叶子查询逐个击破:match、term、range这些怎么用

叶子查询这个词听起来吓人,其实就是"不能再拆分的查询条件"。ES所有查询最后都会被拆成一堆叶子查询,再组合起来。入门阶段,把match、term、range、exists这几个用熟,日常需求就能覆盖大半。

2.1 match:全文检索的主力

match大概是ES里最常用的查询,它处理的是"文本字段怎么匹配"的问题。比如:

{ "query": { "match": { "title": "大屏手机" } } }

这条查询背后发生了什么?简单说,ES会先用title字段定义的分析器把"大屏手机"切词,比如切成"大屏""手机",然后去倒排索引里匹配包含这两个词的文档,并按匹配情况算一个相关度分。所以match天然支持分词搜索,搜"大屏手机"能命中文档里同时有"大屏"和"手机"的记录,也可能命中只有"大屏"或只有"手机"的记录,只是前者分更高。

这就带来一个常见疑问:为什么match有时查出来的结果"看起来不完整"?因为ES默认的match操作符是or,只要任意一个词命中也算。如果你要求所有词都出现,可以显式指定:

{ "query": { "match": { "title": { "query": "大屏手机", "operator": "and" } } } }

还有一种更严格的场景,比如用户搜"洗发水",你不想把它切分成"洗"和"发水",而希望整段作为一个短语去匹配,那就用match_phrase。短语查询要求词项顺序一致且相邻,适合做标题或名片这类精确语境。

我之前在一个商品搜索项目里被"match一搜就出来一大堆无关结果"这个问题困扰很久,后来才意识到,问题不在match,而在没有想清楚业务到底需要"分词命中"还是"整词命中"。分词命中适合搜索框输入长句的场景,整词/短语命中适合属性、标签、名称校验的场景。很多调优不是调参数,而是先调语义。

2.2 term:精确匹配和它的坑

term是按"整个词条"去倒排索引里查找的查询,它不分析查询词。典型用法是查精确值:

{ "query": { "term": { "order_status": "PAID" } } }

问题来了:如果order_status是text类型,term大概率查不到PAID。因为text字段在索引时会经过分析器处理,比如统一转小写、分词,倒排索引里存的可能是"paid",而不是"PAID"。而你term "PAID"时,ES不会帮你转小写,所以找不到。

怎么避免?两个办法。第一,字段类型设计成keyword,keyword字段不做分词,存什么就是什么,适合做状态、枚举、编号、标签这类精确值。第二,如果动态映射已经给文本字段生成了keyword子字段,那就用.keyword子字段做term查询。例如:

{ "query": { "term": { "order_status.keyword": "PAID" } } }

我给所有入门同学的建议永远是:先看映射,再写查询。执行GET /shop_order/_mapping,确认目标字段是keyword还是text,10秒就能避免一小时的排查。类似地,terms查询就是SQL里的in,一次可以查多个值:

{ "query": { "terms": { "order_status.keyword": ["PAID", "REFUNDED"] } } }

提示:写任何查询之前,先看目标字段的映射,确认它是text还是keyword。这个习惯能帮你避开大量"查不到数据"的坑。

2.3 range和exists:过滤条件的常用补充

范围查询是数值和日期场景的刚需,语法很直观:

{ "query": { "range": { "amount": { "gte": 100, "lte": 500 } } } }

操作符一共四个:gt、gte、lt、lte。日期字段也用它,比如:

{ "query": { "range": { "create_time": { "gte": "2024-01-01" } } } }

日期范围有两个坑值得单独说。第一,ES默认使用UTC,如果你的业务是北京时间,查询"今天"的数据却搜不到,多半是时区问题。第二,日期字符串可以被ES自动解析,但建议统一用带时区的格式,比如"2024-01-01T00:00:00+08:00",减少歧义。这两个坑我踩过不止一次,尤其是在做按天报表统计的时候。

exists查询用来判断某个字段是否存在:

{ "query": { "exists": { "field": "remark" } } }

很多同学奇怪,为什么要"判断字段是否存在"?ES的文档是schema less的,不同文档可以有不同字段。比如订单表里有些订单有remark备注,有些没有,你想把有备注的订单筛出来,exists就是标准解法。它和SQL里的where remark is not null很像。

3. bool查询:把条件组合起来,顺便理解评分

单条查询语法再好用,也解决不了真实业务里那种"既要又要还要"的检索需求。真实需求长这样:搜索标题包含"手机"、价格在1000到3000之间、排除已下架的、有"新品"标签的优先展示。这种组合逻辑,就是bool查询的主场。

3.1 bool的四个子句各管什么

bool查询相当于ES里的条件容器,里面可以放四种子句:

  • must:必须满足,且参与评分。
  • filter:必须满足,但不参与评分。
  • must_not:必须不满足,且不参与评分。
  • should:可以满足,满足则加分。

举个例子:

{ "query": { "bool": { "must": [ { "match": { "title": "手机" } } ], "filter": [ { "range": { "price": { "gte": 1000, "lte": 3000 } } }, { "term": { "status.keyword": "ON_SALE" } } ], "must_not": [ { "term": { "is_deleted": true } } ], "should": [ { "match": { "tags": "新品" } }, { "match": { "tags": "热卖" } } ] } } }

我用一个生活类比帮你记这四个词:你要找房子,must是"必须朝南",filter是"价格必须在我预算内",must_not是"不要顶层",should是"带电梯更好,有它加分,没它也认"。前三个决定房子能不能要,should决定候选房在排序时有没有优势。

这里最核心的设计原则是:不参与评分的条件,一律放filter。价格区间、状态、删除标记、时间范围,这些条件只影响"哪些文档能进来",不影响"谁排前面"。放了filter之后,ES还可以利用filter cache加速后续重复查询,性能上也有收益。我在实际项目里,通常把bool里的must只留跟"搜索关键词"相关的那几个match,其余能用filter的一律filter,效果立竿见影。

3.2 should的匹配逻辑和minimum_should_match

should有个容易让人困惑的点:它匹配一个和匹配两个结果不一样,而且它是不是强制条件,取决于bool里有没有must/filter。

如果bool里只有should,没有must或filter,那么文档至少要满足一个should条件才会被返回,相当于or查询。例如{"bool": {"should": [{"match": {"a": 1}}, {"match": {"b": 2}}]}},意思就是a=1或者b=2。

如果bool里有must,should就变成纯粹的打分加项,不强制。比如上面那个商品搜索例子,标题必须匹配"手机",tags里有"新品"或"热卖"就加分,没有tags匹配的文档也会返回,只是分低。

想控制至少要匹配几个should条件,用minimum_should_match。有一种常见需求:"标题命中手机,或者描述命中手机,至少其中一个字段命中",这时候可以写:

{ "query": { "bool": { "should": [ { "match": { "title": "手机" } }, { "match": { "description": "手机" } } ], "minimum_should_match": 1 } } }

我在早期做搜索的时候,最常犯的错就是盲目往bool里堆条件,导致评分很难解释,用户也说结果"玄学"。后来总结出一条经验:把bool查询想象成两个区域,区域一是"必选过滤器",区域二是"相关性加分器"。先过滤,后排序,各管各的,查询逻辑才干净。

3.3 相关性评分到底在算什么

既然讲到must和filter,就绕不开_score。ES 5.x之后默认的相关度算法是BM25,核心思想不复杂:一个词在某篇文档中出现越多,该文档得分越高;一个词在所有文档中出现越普遍,它对区分的价值就越低,权重也越低。这就像在文章里搜索"的",几乎每篇都有,这个词就没区分度;搜一个冷门词,命中它的文档理应排更前。

看着公式理解,不如做一个实验来得快。同一个查询,先只放must,再在filter里加条件,你会发现_score不变。这正好验证了filter不参与评分的特性。而把查询词从"手机"改成更冷门的"曲面屏手机"后,得分通常会变,因为词项频率和文档频率都变了。

如果想知道一条文档为什么得到某个分,可以用explain参数:

GET /shop_order/_search?explain=true { "query": { "match": { "title": "手机" } } }

返回里会有_score的详细拆解。实际业务中,满分详情看得比较少,但排障时会看。最典型的场景是:运营反馈"某个商品为什么排第一?",你用explain把得分拆开,往往能发现是某个罕见词在作祟,或者某个should条件给分太高。

4. 排序、分页与字段裁剪:让结果更可控

查询条件写对了,只算解决了"筛什么"。用户还关心"按什么顺序看""一次看多少""返回哪些字段"。这部分语法不难,但容易写出性能灾难。

4.1 排序规则与多级排序

默认情况下,ES按_score降序排列。一旦你显式指定sort,ES就不再按相关度排序了,除非你把_score也写进sort里。例如:

{ "query": { "match_all": {} }, "sort": [ { "create_time": { "order": "desc" } }, { "_score": { "order": "desc" } } ] }

这条排序的意思是:先按创建时间倒序排,同一时间内的文档再按相关度倒序排。多级排序在列表页非常常见,比如后台订单列表,先按状态分组、再按时间倒序的诉求,本质就是两级sort。

排序还有几个容易踩的坑。第一,text字段默认不能排序,因为分词后的词项不是稳定的完整值;想按标题排序得用keyword子字段。第二,排序字段如果是多值字段比如数组,ES会取最值的某个值来排,行为有时反直觉。第三,给排序字段加.unmapped_type是个小技巧,可以在字段在部分索引不存在时避免报错。这些坑在数据量大、索引分了很多的时候会放大,设计映射阶段就应该想好哪些字段需要排序。

4.2 分页的三种方式:from/size、search_after、scroll

最直观的分页就是from和size:

  • from:跳过多少条。
  • size:取多少条。

前端列表页第一页翻到第二页,页面返回下一页的from,这是最常用的方式。但ES对from/size深分页有限制,index.max_result_window默认是10000,from+size超过这个值会直接报错。为什么?因为ES不知道你要跳过的那一万条在哪台分片上,它得先把每个分片上前一万多条全部取回来,再在协调节点上排序、截取,代价成倍增长。翻得越深,浪费越大。

如果你的业务只需要"下一页"这种无状态翻页,不要硬改max_result_window,应该用search_after。它的思路是:把上一页最后一条文档的排序值记下来,下一页从这条之后开始取。

GET /shop_order/_search { "size": 10, "sort": [ { "order_id": "asc" } ], "search_after": [100023] }

注意,search_after要求sort值是全局唯一且稳定的。一般组合使用唯一字段,比如订单号或文档_id,否则遇到相同排序值时会出现重复或跳条。ES 7.x之后还可以直接在sort里写{"_id": "asc"}作为排序字段,配合search_after做稳定翻页。

如果你要的不是翻页,而是把符合条件的数据全量导出去做离线分析,那就是scroll的活。scroll会在第一次查询时创建一个快照上下文,后续用scroll_id不断拉取,直到全部数据取完。它适合数据导出、reindex场景,不适合给用户做实时搜索,因为上下文会占用资源,而且快照期间数据更新也看不到。

我把三者的适用场景简单总结一下:

方式适用场景注意点
from/size小额翻页、前几页列表默认上限10000,深翻页性能差
search_after无限滚动、实时搜索下一页sort值需要稳定唯一
scroll全量遍历、导出、reindex快照上下文,不适合实时搜索

注意:ES默认限制from+size最大不超过10000,不要盲目调大这个配置,应该换search_after。

4.3 _source裁剪和fields参数

_source字段存的是文档原始JSON。默认情况下,查询会把整个_source返回给你,数据量大时白白占用网络带宽。很多业务只需要其中几个字段,用_source过滤:

{ "_source": { "includes": ["order_id", "buyer_name", "amount"], "excludes": ["remark"] }, "query": { "match_all": {} } }

如果你只是想拿个别字段的值做展示,还可以用fields参数。它和_source的区别是,fields可以读取doc values里的列式存储,处理某些字段时比完整取_source更快。值得一提的是,即便字段在_source里被过滤掉了,只要它在映射里支持doc values,fields还是能取到值。

这个细节经常被忽略。我记得有次排查一个接口耗时问题,排查到最后发现每次查询返回了接近200KB的_source,而页面只用其中3个字段。把_source裁剪之后,接口耗时直接降了一半。返回字段多不多,平时看不出来,并发一上来就是实打实的瓶颈。

5. 聚合语法:从查询走向统计报表

查询语法掌握到能组合bool、能分页排序,日常搜索需求基本够用了。但ES还有一个杀手锏是搜索语法绕不开的,那就是聚合。面试时被问到"ES怎么做分组统计""怎么做趋势图",说的就是这层。

5.1 桶聚合:terms把数据分组

聚合分两大类:桶聚合和指标聚合。桶聚合负责分组,相当于SQL里的group by;指标聚合负责计算,相当于count、sum、avg这些函数。

最经典的桶聚合是terms,按字段值分组:

{ "size": 0, "aggs": { "group_by_status": { "terms": { "field": "order_status.keyword" } } } }

返回里会有一个aggregations:group_by_status.buckets,每个bucket带着key和doc_count。size设为0表示不返回hits,只拿聚合结果,适合报表接口。

terms聚合有个容易被忽略的默认行为:它默认只返回doc_count最多的10个桶,不是全部。想把所有分组都列出来,需要显式设置size,比如"size": 1000。但桶太多时,这个值也会影响内存和网络开销,要权衡。另一个常见问题是text字段不能直接做terms聚合,因为text分词后的词条不是完整概念。解决方式和term查询一样,用keyword子字段,或者在建映射时明确设计字段类型。

5.2 指标聚合与嵌套:平均值、求和、时间趋势

指标聚合单独用很简单:

{ "aggs": { "avg_amount": { "avg": { "field": "amount" } }, "total_amount": { "sum": { "field": "amount" } } } }

但真实报表需求通常要"分组后统计",也就是两个聚合嵌套。比如按商品品牌分组,统计每个品牌下订单的平均金额:

{ "aggs": { "group_by_brand": { "terms": { "field": "brand.keyword", "size": 20 }, "aggs": { "avg_amount": { "avg": { "field": "amount" } } } } } }

这类嵌套聚合的写法规律很简单:外层桶聚合定义分组,内层aggs定义该组内的统计。多少层嵌套都行,但层级太深会造成查询复杂度过高,我建议嵌套控制在3层以内,超出需求时考虑用ES pipeline聚合或者干脆预处理数据。

时间趋势分析是另一个高频场景。按天统计订单量,用date_histogram:

{ "aggs": { "orders_over_time": { "date_histogram": { "field": "create_time", "calendar_interval": "day" } } } }

date_histogram会按时间自动分桶,类似SQL里GROUP BY DATE(create_time)。calendar_interval可以写成minute、hour、day、week、month等,也可以用fixed_interval指定固定时间跨度,比如"fixed_interval": "90s"。这里有个容易踩的坑:时间字段的类型要是date,否则聚合结果会很奇怪。

5.3 查询和聚合协同使用

聚合和查询不是二选一,而是可以先过滤再统计。ES请求里query和aggs可以同时存在,query负责限定数据范围,aggs负责统计这个范围内的指标。比如统计"价格在1000到3000之间的在售商品,按品牌分布":

{ "query": { "bool": { "filter": [ { "range": { "price": { "gte": 1000, "lte": 3000 } } }, { "term": { "status.keyword": "ON_SALE" } } ] } }, "size": 0, "aggs": { "group_by_brand": { "terms": { "field": "brand.keyword" } } } }

这比先查出来再在代码里分组统计高效太多。数据量达到几百万条时,同样的统计逻辑放ES里往往毫秒级返回,放应用层则可能要几分钟还占内存。我见过不少项目压根不知道"ES还能这么做报表统计",白白写了大量代码去循环分组。

6. 查询语法排障实录:查不出数据时先查这几个地方

写了这么多查询语法,真正到了项目里,最经常被问的还是"为什么我这个查询搜不出数据"。老实说,ES查询报语法错误的情况反而少,大部分是"不报错但结果不对",这需要系统性的排查思路而不是瞎试。

6.1 查不出数据的几个高频原因

我按出现频率排了一下,基本集中在下面的表里:

现象大概率原因快速验证方式
term查文本搜不到字段是text,被分词导致词条不匹配看_mapping,改用match或keyword子字段
日期范围查不到当天数据时区不是UTC,日期格式不对用带+08:00的字符串重新传参
明明有数据但查询命中为0字段名写错或索引写错先match_all确认索引里有数据,再看字段名
分页第几百页开始报错from+size超过max_result_window改用search_after
聚合结果对不上terms默认只返回top10,或者text字段没有keyword子字段显式设置size,改用keyword做聚合

第一条和第二条是我的"踩坑之最"。某次排查一个订单列表的筛选功能,条件选了"已支付",列表一直是空的,最后发现status字段被动态映射成了text,term "PAID"怎么会匹配上被转成小写且分词的"paid"?改成keyword字段后立刻就好了。这类问题不能靠肉眼猜,先看映射。

6.2 用好三个调试工具

多花一分钟验证,有时候能省一小时排查。我常备三个工具。

第一个是GET /shop_order/_mapping,看字段真实类型,所有"为什么term查不到""为什么这个字段不能排序"的问题,在这一步就能水落石出。

第二个是GET /shop_order/_validate/query?explain=true,用来验证查询语法是否正确。它会返回这个查询在Lucene层的实际形态,顺便排除语法错误和字段名错误。

第三个是GET /shop_order/_search?explain=true,查看单条文档的评分明细,适合排查排序结果不符合预期的情况。如果explain不够用,还可以加profile参数,看看耗时集中在哪个环节,但日常排障不推荐一直开着profile,太费资源。

我个人的习惯是:任何一条查询写完后,先用size:0跑一下看hits.total和关键字段,确认结果数量符合预期,再去结合业务调排序和聚合。这个习惯帮我挡掉了大量线上问题。

7. 反复踩坑后,我对查询语法的几点补充体会

7.1 学习顺序:先骨架,再细节

不要一上来就死记API。把bool查询当成万能容器,把match和term当成两种最重要的叶子查询,把filter当成所有非评分条件的默认归宿,这套组合已经能解决八成需求。剩下那两成,遇到再查文档,效率远高于一开始把文档从头到尾背一遍。

我见过一些同学把match_phrase、wildcard、fuzzy这些生僻查询背得滚瓜烂熟,结果一写bool组合还是乱套。ES查询语法的本质是"分层组合",先理解查询条件、过滤条件、排序、分页、聚合这五层结构,再往里填具体语法,遇到任何需求都能稳得住。

7.2 写查询前的三个好习惯

第一,看映射。真的,这个习惯花的时间不到一分钟,但能在整个排查链条里省下最多时间。我现在接手新项目,第一件事就是导出全部索引的映射,先看清每个字段是keyword还是text,再谈怎么写查询。

第二,性能优先看filter和分页。高并发场景下,把能过滤掉的条件全部放进filter,让ES的filter cache帮扛流量;分页能用search_after就不用from/size;返回字段能用_source裁剪就裁剪。这些优化不花一分钱成本,收益却是立竿见影的。

第三,重视可视化控制台开发工具的自动补全和校验。它有语法高亮,能减少JSON括号配对的低级错误。写完先校验,再跑explain,最后再合入代码,这套流程走顺了,ES查询基本不会再成为项目的瓶颈。

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

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

立即咨询