ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Elasticsearch基础查询语法详解:从倒排索引到Bool组合查询

Elasticsearch基础查询语法详解:从倒排索引到Bool组合查询

如果你接手过一个用 Elasticsearch 做日志检索或者商品搜索的系统,八成会遇到同样的场景:能启动节点、也能通过 curl 敲出_search请求,但返回结果总跟预期对不上——想搜的词条结果跑出来一大片无关记录,明明存在的字段却命中不了,分页翻到第 100 页直接报错。这不是你操作有误,而是 Elasticsearch 基础查询语法背后有一套自己的逻辑,跟 MySQL 这种关系型数据库完全是两套思维。我把自己从零接触 ES 到现在维护多个索引的经验整理了一份,专门讲 query DSL 里最常用、最容易被误解的那批查询写法,适合刚入门的开发者、后端工程师和运维,照着写就能落地,不用去啃几百页官方文档。

1. 写查询前,先理解ES为什么这么设计

1.1 倒排索引:全文检索为什么这么快

先聊一个绕不开的概念:倒排索引。ES 的查询快,不是因为它用了什么黑科技,而是数据结构选得好。传统关系型数据库存储数据时,一般按行组织,查询某个字段时依赖 B+ 树索引去定位,适合等值查询和范围查询,但要做全文模糊搜索就比较吃力。ES 不同,它把每个文档拆成一个个词项,再建立“词项到文档”的映射表,也就是倒排索引。

拿一个商品标题举例:“某品牌无线降噪耳机”。经过分词后,可能会拆成“某品牌”“无线”“降噪”“耳机”几个词项。倒排索引里记录的是:无线出现在文档 1、文档 50;降噪出现在文档 1、文档 2。查询时只要在词项表里找到无线,马上就知道哪些文档包含这个词,不需要全表扫描。这个过程很像查字典的部首目录:先找到偏旁所在页码,再去对应区域找字,而不是从第一页翻到最后。

理解这一点,你就能明白为什么基础查询里match和term行为完全不同。ES 默认会对文本字段做分词,所以真正存储在倒排索引里的,是分词后的词项,而不是原始字符串。这也为后面很多“查不到”的案例埋下了伏笔。

1.2 分词器决定你能查到什么

分词器(Analyzer)是 ES 里最容易被忽略、却最影响查询结果的东西。一个完整的分词器由三部分组成:字符过滤器、分词器、Token 过滤器。字符过滤器负责清洗原始文本,比如去掉 HTML 标签;分词器负责把句子切成词项;Token 过滤器负责处理词项,比如转小写、去除停用词、加同义词。

ES 默认的standard分析器对英文按空格和标点切分,对中文则按单个汉字切分。这意味着“无线耳机”在text字段里存进去后,倒排索引里可能是“无”“线”“耳”“机”四个单独的词项。如果你用term查询去搜“无线”,ES 会拿着“无线”这个完整词项去倒排索引里找,发现没有这个词项,结果就匹配不上;但用match查询却能匹配,因为match会先把查询词“无线”也切成“无”和“线”,再去分别匹配。这两个查询在文本字段上的表现差异,是新手最容易栽的坑。

另外要记住,ES 在索引文档时和查询时会分别做一次分词,两边必须保证逻辑一致,否则就会出现“文档明明有这个词,却搜不出来”的诡异情况。如果你用了自定义分词器,建议先在测试环境验证索引分词结果和查询分词结果是否对称,不要直接上线。

1.3 查询上下文与过滤上下文

在 ES 里,同样一个条件,放在不同上下文里,执行效率完全不同。查询上下文(Query Context)会计算相关度评分,也就是_score字段,影响排序;过滤上下文(Filter Context)只判断文档是否满足条件,不计算评分,而且结果可以被缓存。

用生活例子比喻:你在筛选商品时,价格 100 到 500 元这个条件,属于“硬性过滤”,无论谁搜,结果都一样,没有必要给它算分。而“无线耳机”这种关键词,不同人搜索可能有不同相关度需求,需要算分。这两种条件在 ES 里就应该分开处理。

落实到写查询上,能放进filter的条件就不要放进must,这是基础查询里一个很重要的性能意识,后面讲bool查询时会详细展开。

2. 高频基础查询语法逐个拆解

2.1 match 和 multi_match:全文检索的主力

match是最常用的全文检索查询。给你一个最基础的写法:

{ "query": { "match": { "title": "无线耳机" } } }

执行过程分两步:先把无线耳机按title字段的分析器分词,再用分词后的词项去倒排索引里匹配,最后按相关度打分排序。默认情况下,分词得到的多个词项之间是 OR 关系,也就是说文档只要包含其中任意一个词,就会被匹配。

这里有个很容易踩的坑:默认 OR 逻辑太宽松。比如搜索“无线 降噪”,系统会把所有包含“无线”或“降噪”的文档都返回,其中很多根本不是用户想要的。解决办法有两种,一是显式指定 operator:

{ "query": { "match": { "title": { "query": "无线 降噪", "operator": "and" } } } }

二是用minimum_should_match控制最少匹配词数:

{ "query": { "match": { "title": { "query": "无线 降噪", "minimum_should_match": 2 } } } }

两者的区别在于:operator: and是全有或全无,minimum_should_match更灵活。比如 4 个词里至少匹配 2 个,这在长句搜索里更实用。我的建议是,业务上要求“多个关键词都得出现”就用and;如果只是想提高相关度,用minimum_should_match更稳妥。

multi_match则是同时在多个字段里做match查询,比如同时搜标题和描述:

{ "query": { "multi_match": { "query": "无线耳机", "fields": ["title", "description"] } } }

它适合搜索框全局检索的场景,不需要为每个字段单独写一个match,然后用bool拼起来。

2.2 term 和 terms:精确匹配绕不开的分词陷阱

term查询用于精确匹配某个词项,它是不会分词处理的。这是它与match最大的区别。

{ "query": { "term": { "brand": "某品牌" } } }

上面这段查询能正常工作,前提是brand字段是keyword类型。keyword类型在索引时不分词,整个字符串作为一个词项存进倒排索引,term查询就能精确命中。

但如果你对text字段执行term查询,很容易得到空结果。比如商品标题是“某品牌无线降噪耳机”,索引时被拆成多个词项,你用term搜“无线耳机”,ES 会拿“无线耳机”这个整体去倒排索引里找,找不到,结果为空。换成搜“耳机”,才可能命中,因为“耳机”是分词后的一个独立词项。这是非常基础但非常多人犯过的错误。

terms是term的复数版本,逻辑类似 SQL 里的IN:

{ "query": { "terms": { "status": ["created", "paid", "finished"] } } }

它表示status等于这三个值中任意一个即可,适合状态码、分类 ID 这类多值精确匹配。补充一点,terms查询里的数组元素如果是数字,字段类型也要是数字,类型不匹配时不会报错,但结果往往为空,排查时别忽略。

2.3 range 范围查询:数值与时间的边界控制

业务里经常要筛价格区间、库存数量、时间范围,range就是干这个的。它支持gt、gte、lt、lte四个操作符,分别表示大于、大于等于、小于、小于等于。

数值型字段:

{ "query": { "range": { "price": { "gte": 99, "lte": 499 } } } }

日期字段也用它,但需要注意格式问题:

{ "query": { "range": { "create_time": { "gte": "2024-01-01", "lte": "2024-12-31", "format": "yyyy-MM-dd", "time_zone": "+08:00" } } } }

这里format告诉 ES 传入的日期格式是yyyy-MM-dd,time_zone指定时区偏移。不加time_zone时,ES 默认按 UTC 处理,搜索结果可能跟你本地时间差 8 小时。很多人上线后发现时间范围不对,大部分都是时区问题。另外一个实用技巧是,range支持日期数学表达式,比如查最近 7 天的数据可以写:

{ "query": { "range": { "create_time": { "gte": "now-7d/d" } } } }

now-7d/d表示从 7 天前的零点开始算,这个写法在做日志检索时非常方便。

2.4 exists 和 ids:两个不起眼但很实用的小工具

exists查询用来筛选某个字段存在的文档,比如“有库存数量的商品”:

{ "query": { "exists": { "field": "stock" } } }

这里有个细节要留意:空字符串""和空数组[]在某些版本中会被视为字段存在。如果你需要排除“字段为空”的文档,仅用exists是不够的,要配合其他查询组合处理。

ids查询按文档_id批量查询,非常简单:

{ "query": { "ids": { "values": ["1", "2", "3"] } } }

它适合前台根据 ID 列表回源数据,比循环单个查询效率高得多。

3. bool 组合查询:把单个条件串成真实业务

3.1 must、must_not、should、filter 分别是什么

单个查询很难满足真实业务,组合查询才是主菜。bool查询有四种子句:

子句语义是否参与评分
must必须满足,逻辑上相当于 AND是
must_not必须不满足,逻辑上相当于 NOT否
should至少满足一个,相当于 OR是
filter必须满足,但不参与评分否

must和filter的区别值得再强调一遍:两者都要求文档必须匹配条件,但must会计算相关度分数,filter不计算。如果只是做硬性筛选,选filter更好,性能更高,还能走缓存。

should的默认行为要看上下文。当bool查询里没有must和filter时,至少需要满足一个should条件;当存在must或filter时,should变成加分项,全部不满足也能返回结果。这一点很容易让人困惑,我建议在复杂查询里显式设置一个minimum_should_match,把行为固定下来。

3.2 一个完整的 bool 查询长什么样

举个实际场景:搜索“无线耳机”,要求品牌在“某品牌”或“某二品牌”中,价格 99 到 499 元之间,排除状态为“下架”的商品。完整的 DSL 长这样:

{ "query": { "bool": { "must": [ { "match": { "title": "无线耳机" } } ], "filter": [ { "terms": { "brand": ["某品牌", "某二品牌"] } }, { "range": { "price": { "gte": 99, "lte": 499 } } } ], "must_not": [ { "term": { "status": "下架" } } ] } } }

在这个例子里,品牌、价格、状态都放在filter或must_not里,因为它们是客观筛选条件,不需要参考相关性。核心关键词“无线耳机”放在must里参与评分,保证更相关的结果排前面。这个分层思路,能让查询语义清晰,性能也好。

3.3 filter 为什么快:评分与缓存

filter 快的根本原因有两点。第一,不计算评分省去了打分阶段大量 CPU 消耗。第二,ES 会对filter子句做节点级查询缓存,同样的过滤条件如果反复出现,直接命中缓存,省去倒排索引的查找过程。

举个例子,一个商城首页每天有成千上万次同样的类目和价格筛选请求,如果这些条件都放在filter里,第二次请求起就能吃到缓存红利,响应时间会明显降低。我实测过把一个查询里的状态过滤从must挪到filter后,线上接口的 P99 延迟能下降 30% 左右。在基础查询阶段就养成“能过滤不评分”的习惯,对后面复杂系统很有帮助。

4. 通配符、模糊和正则:处理记不准的搜索词

4.1 wildcard:简单的通配匹配

wildcard查询支持*和?两种通配符。*匹配零个或多个字符,?匹配单个字符。比如:

{ "query": { "wildcard": { "order_no": "A2024*" } } }

这能匹配所有以A2024开头的订单号。它在keyword字段上用得最多。

但wildcard有个明显的问题:如果模式以*开头,比如*2024,ES 需要遍历大量词项才能找到匹配项,性能会非常差。这就像没有页码的字典,你得从头翻到尾。我的建议是,能用prefix查询解决的场景就别用wildcard;必须用wildcard时,尽量把固定前缀写出来,减小扫描范围。如果数据量很大,可以考虑专门设计正则化字段,但那是进阶玩法,基础阶段先避开性能雷区。

4.2 fuzzy:拼写错了也能搜到

fuzzy查询通过计算编辑距离,能容忍拼写错误。比如用户把elasticsearch输成了elasticsearh,普通查询匹配不到,fuzzy可以:

{ "query": { "fuzzy": { "product_name": { "value": "elasticsearh", "fuzziness": "AUTO" } } } }

fuzziness参数有两个常见配置方式:一个是数字,表示允许的最大编辑距离,比如1表示最多改动一个字符;另一个是AUTO,会根据词项长度自动决定容错范围,通常短词更严格、长词更宽松。

fuzzy适合搜索建议、纠错场景,但别把它用在每个查询里。它要做大量候选词项比对,对长文本和超大索引来说,性能开销很大。我现在只在用户输入可能包含拼写错误、且索引不太大的场景里用它。

4.3 regexp:规则明确的字段用正则

regexp查询按正则表达式匹配整个词项,适合订单编号、证件号这类格式固定的字段。注意,它匹配的是整个词项,而不是在文本中找子串。

{ "query": { "regexp": { "order_no": "S[0-9]{6}" } } }

这会匹配类似S123456的订单号。使用的时候有两点需要提醒:一是 ES 的正则语法是 Lucene 正则,不是 Java 或 Python 完整正则语法,像\d这类简写不一定支持,建议用完整字符类写法;二是正则查询性能消耗大,能避免就避免,尤其不要配合.*开头使用。

5. 排序、分页与结果控制

5.1 sort 排序:text 字段不能排?先解决类型问题

默认情况下,ES 按相关度_score降序排列。想按业务字段排序,就加sort:

{ "query": { "match": { "title": "无线耳机" } }, "sort": [ { "price": { "order": "asc" } }, { "create_time": { "order": "desc" } } ] }

这里有个很典型的坑:直接对text类型字段排序会报错,错误信息里会提示这类字段的排序优化做不了,正确的是对它的keyword子字段排,或者确保排序字段是keyword、numeric或date类型。

还有一个细节:加了sort之后,如果你还希望相关度高的结果排在前面,可以把_score当作第一排序条件,再加字段排序。多个排序条件都相同时,结果顺序不确定,对分页场景可能造成翻页数据重复,这个留在下一节讲。

5.2 三种分页方式怎么选

ES 的三种分页方式各有适用场景,选错了就会遇到“报错”或“性能崩掉”的问题。

第一种是from + size,最直观,但默认窗口只有 10000 条,超过就报错:

{ "query": { "match_all": {} }, "from": 0, "size": 10 }

为什么会有这个限制?因为协调节点需要从每个分片取from + size条数据,再统一排序,才能得到最终结果。翻页越深,需要处理的数据越多,内存开销越大。所以业务上只适合前几百页的浏览。

第二种是search_after,适合深分页。它利用上一页最后一条记录的排序值,作为下一页查询的起点:

{ "size": 10, "sort": [ { "price": { "order": "asc" } }, { "_id": "asc" } ], "search_after": [99.0, "doc_123"] }

注意两个细节:第一,每页查询都要带上同样的sort;第二,排序字段值必须唯一或尽量唯一,否则翻页时可能漏数据。价格可能相等,所以补一个_id作为 tie breaker,这个经验很实用。

第三种是scroll,专为一次性导出大批量数据设计:

POST /test_index/_search?scroll=1m { "query": { "match_all": {} }, "size": 100 }

拿到返回里的_scroll_id后,后续用 scroll 请求继续拉取下一页。它相当于在 ES 里生成一个数据快照,数据量很大时效率高。但用完一定要清理掉 scroll 上下文,否则资源一直占着。

5.3 _source 过滤和高亮片段

返回结果默认会带上完整的_source字段。如果业务只需要部分字段,可以这么过滤:

{ "query": { "match": { "title": "无线耳机" } }, "_source": ["title", "price", "brand"] }

这个操作能减少网络传输和内存开销,在高频接口里值得养成习惯。

搜索系统还经常要显示命中关键词的高亮片段,ES 提供了highlight功能:

{ "query": { "match": { "title": "无线耳机" } }, "highlight": { "pre_tags": ["<em>"], "post_tags": ["</em>"], "fields": { "title": {} } } }

返回结果里会多出highlight字段,包含带高亮标签的片段,前端直接渲染就行。这个功能让我少写了不少前后端联调的代码,学会它性价比很高。

6. 聚合查询基础:从一个分组需求说起

6.1 terms 聚合:按字段分组计数

聚合功能让 ES 不只是搜索引擎,还成了轻量级分析引擎。常见的需求是“每个分类下有多少商品”,用terms聚合:

{ "aggs": { "category_count": { "terms": { "field": "category.keyword", "size": 10 } } } }

注意字段用了category.keyword,因为category本身可能是text,直接在text字段上做terms聚合会报错。一个稳妥的方案是建索引时给文本字段加一个keyword子字段,聚合时用它。

size参数控制返回多少个分组桶,默认是 10。如果想更精确地统计,有两个参数值得理解:shard_size控制每个分片返回给协调节点的候选桶数,默认是size * 1.5 + 10;min_doc_count控制桶最少包含多少文档数,设置为 1 可以过滤掉空桶。如果数据量很大但想知道更准确的分组数,可以考虑调大shard_size,但这会增加内存开销,要权衡。

6.2 嵌套聚合与日期直方图

聚合还能嵌套。比如“按品牌分组,再看每个品牌下的商品平均价格”:

{ "aggs": { "brand_group": { "terms": { "field": "brand.keyword", "size": 10 }, "aggs": { "avg_price": { "avg": { "field": "price" } } } } } }

返回结果里每个品牌桶下面会有avg_price,这正是运营看板常见的“品牌均价排行”。聚合类型还有很多,比如sum、max、min、cardinality,逻辑都类似,需要时在文档里一查就能上手。

时间维度的统计用date_histogram:

{ "aggs": { "orders_by_month": { "date_histogram": { "field": "create_time", "calendar_interval": "month" } } } }

calendar_interval支持month、quarter、year等日历区间;如果要用固定时长,比如统计每 5 分钟的数据,用fixed_interval: "5m"。这里我踩过坑:calendar_interval和fixed_interval不能互相替代,固定时长统计如果误用了日历区间,日期边界会不整齐。

6.3 聚合与查询条件的关系

聚合默认是在查询结果集上执行的。如果请求里同时有query和aggs,那聚合只针对query命中的文档。比如你筛选了价格 0 到 100 元的商品,再按品牌聚合,得到的就是这个价格区间内的品牌分布。

这种组合特别适合后台筛选器的“实时统计”,比如用户在筛选面板勾选条件时,实时看到结果数量。只要把筛选条件放在query里,聚合会自动跟随变化,不需要写额外代码。这个能力在业务系统里能省掉不少统计接口。

7. 常见问题与排查技巧实录

7.1 查得到但匹配不到:四个排查方向

实际运维和开发里,最多的疑问就是“为什么文档明明有这个词,却搜不到”。我整理了一个自查顺序:

现象可能原因解决办法
term 查 text 字段结果为空分词后词项不匹配完整字符串改用 match 查询,或用 keyword 子字段
日期范围查询结果明显不对时区设置缺失导致偏移加上 time_zone 参数;确认存储格式
数字字段用字符串过滤没结果查询类型与字段类型不匹配检查映射 type,修正查询参数
查询命中一堆无关文档match 默认 OR 逻辑太宽松显式设置 operator 或 minimum_should_match

排查时最有效的工具是_analyzeAPI,直接看分词结果:

POST /test_index/_analyze { "field": "title", "text": "无线降噪耳机" }

返回结果里会列出每个词项,你一看就知道为什么term查不到、match能查到。再看_explainAPI,它能展示单条文档的匹配详情,字段数据一目了然。我现在的习惯是:出问题先_analyze看分词,再_explain看匹配路径,很少靠猜。

7.2 聚合报错与 text 字段的 fielddata 问题

在对text字段做聚合或排序时,经常会遇到这样的报错:Fielddata is disabled on text fields by default。原因是text类型默认支持全文检索,分词后存倒排索引,如果要按它做聚合,必须把分词后的词项加载到堆内存里,也就是fielddata,这非常吃内存,所以 ES 默认关掉了这个功能。

面对这种情况,最规范的做法不是在映射里开启fielddata,而是给字段增加keyword子字段。设计一个字段时,同时提供text类型做搜索和keyword子字段做聚合、排序,这个模式应该成为默认选择。

7.3 映射设计建议

配合基础查询,我建议新建索引时先想清楚字段有哪些查询需求,再定映射。一个常见的设计如下:

{ "mappings": { "properties": { "title": { "type": "text", "fields": { "keyword": { "type": "keyword" } } }, "price": { "type": "float" }, "create_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" } } } }

这样title.keyword可以做精确匹配、排序和聚合,title本身可以全文检索。日期字段同时兼容标准时间字符串和毫秒时间戳。基础查询里学到的那些语法,在这个映射下基本都能顺利跑通。提前设计映射,比后期遇到问题再重建索引省事得多。

7.4 基础查询语法速查表

收藏一张速查表,写查询时先看一遍,比翻文档快:

需求查询类型示例要点
全文搜索关键词match注意 operator 与 minimum_should_match
精确匹配单个值term建议在 keyword 字段上使用
精确匹配多个值terms类似 SQL 的 IN
数值/日期范围range日期注意 format 和 time_zone
组合多条件boolmust/filter/must_not/should 按需组合
开头匹配prefix性能比 wildcard 好
模糊容错fuzzyfuzziness 设置为 AUTO
通配匹配wildcard避免开头使用 *
字段存在性判断exists结合空字符串行为使用

这张表里每一条语法,拆开看都不复杂,但组合起来能覆盖绝大多数业务场景。我见过不少团队把复杂业务查询硬生生写在一大段bool里,其实先想清楚条件属于哪种语义,再套上面的基础语法,查询会清晰很多。

我在实际维护 ES 系统时,最大的体会是:基础查询不难,难的是养成“先建模、后查询”的习惯。每个查询背后,都要问自己一句——这个字段是什么类型?分词结果是什么?应该用match还是term?要不要参与评分?四个问题想明白,查询基本不会出大问题。另外一个很实用的建议是,新写一条查询时,先在测试环境用_analyze验证分词、用_validate检查语法,确认无误了,再把它落到业务代码里。基础语法用熟练之后,再看聚合、再看查询模板,会觉得往前的每一步都有了抓手。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进