
写这一篇之前我已经折腾了三年多的Elasticsearch从最初只会无脑用match_all拉数据到后来被团队里大佬按着头学Query DSL再到自己负责的日志检索系统上线后暴露出各种查询性能问题——可以说ES查询语法这块是每个不搞搜索引擎原理、只打算把数据好好查明白的开发者最容易踩坑也最值得认真啃一遍的知识点。这篇文章要讲的就是ES入门系列里的第五篇查询语法。也就是说你已经装好了ES、建好了索引、导入了数据现在要解决一个特别现实的问题怎么把想要的数据精确地捞出来并且捞得又快又准。内容会覆盖最常用的查询写法、bool组合查询的逻辑、排序分页、聚合统计以及我看过无数同事“翻车”的坑点排查。适合刚接触ES但已经写过一点简单的match查询的人也适合那些已经写了大半年查询、却始终没搞清楚为什么term查不到、filter和query性能差在哪里的朋友。我用的示例数据是一个模拟的博客文章索引blogs字段包括标题title、正文content、标签tags、发布时间pub_time、浏览量views。整个测试环境是ES 7.x版本。虽然版本不同有些细节会变但核心思想完全通用。1. 查询语法的大盘看懂Query DSL再去碰各种花哨写法1.1 为什么推荐Query DSL而不是String语法很多新手入门ES时最先接触到的查询方式其实是URL里的q参数比如GET /blogs/_search?qtitle:elasticsearch这种写法叫Query String语法看起来很方便但它有两个我很不喜欢的毛病。第一它把你的查询条件变成了一长串拼接出来的字符串一旦条件复杂一点比如“标题里有elasticsearch并且发布时间在最近七天但不要tags里包含广告的文章”这个字符串写起来就会变成一场噩梦。转义、引号、空格、特殊符号任何一个地方出错整条查询直接语法报错或者静默失效。第二它不利于代码维护。你写完一条复杂的q参数查询三个月后回头再看还得一个字一个字去解析但如果你用的是Query DSL也就是把查询条件写成结构化的JSON再放到请求体里这段JSON本身就是自解释的同事接手也能一眼看懂。Query DSL的核心思想很简单一个查询请求体query字段里装的是ES定义的领域特定语言。你不需要学一个全新的编程语言只需要掌握几个查询关键字然后用JSON把它们组合起来就行。我个人的经验是除非是日常临时调试一条简单查询否则生产环境一律写查询体也就是用GET /blogs/_search配合JSON请求体不要用q参数。1.2 从一次查询看ES的返回结构不管是哪种查询ES返回的顶层结构都是一样的。我随便跑一条最简单的查询GET /blogs/_search { query: { match_all: {} } }返回结果包含这些关键部分hits.total命中总数。hits.hits命中的文档列表默认返回前10条。hits.hits._score相关性得分。took查询耗时单位毫秒。timed_out是否超时。_shards分片信息通常犯错排查时要用。很多刚接触ES的人会花大量时间研究返回结果里那些字段的展示方式但我觉得最核心的是要理解_score这个东西。_score是ES给每条结果打的“相关度分”数值越高代表这条文档和你的查询条件越匹配。这就引出了一个非常重要的概念ES默认并不是严格返回“条件符合”的数据而是返回“跟你的查询语句长得最像”的数据然后按相似度倒序排列。比如你执行一个match查询ES会把你的查询词拆词然后逐词去倒排索引里找再用算法计算相似度。哪怕文档里只包含了查询词的一部分它也会被算进去只是得分低一些。如果你只想纯粹过滤不想要任何评分参与那就得用后面要讲的filter上下文。这个差异是理解ES查询行为的关键。2. 最常用的四个查询要弄清楚它们各自查的是什么2.1 term查询精确匹配重点是keyword和text的差别先看term查询。它的作用是精确匹配某个字段的完整值不进行分词。比如我想查标签tags中恰好有个“elasticsearch”标签的文章GET /blogs/_search { query: { term: { tags: elasticsearch } } }这个查询看起来没什么问题但如果我改成查title字段GET /blogs/_search { query: { term: { title: Elasticsearch } } }十有八九会查不到结果。原因在于title字段在默认映射环境下通常被ES映射为text类型而text类型在写入索引时会经过分词器处理。你写入的标题Elasticsearch入门教程会被拆成类似elasticsearch、入门、教程这样的小写词条。因此索引里根本不存在完整的Elasticsearch这个词项而是分词后的词条。term查询是去倒排索引里找完整不分词的词项它不会对你搜索的关键词再做分词处理。所以用term查text字段就很容易出现“我知道数据明明存在却搜不到”的情况。解决办法是在这个text字段后面加一个keyword子字段。ES在默认动态映射时其实已经自动给多数字符串字段加了一个.keyword字段。你可以这样查GET /blogs/_search { query: { term: { title.keyword: Elasticsearch入门教程 } } }keyword类型的字段在写入时不会被分词保存的就是完整字符串因此term就能精确匹配到。实际操作里我见过太多人在这里卡很久。一个重要的心得先看映射再写查询。用GET /blogs/_mapping看一下字段类型就不会犯这种错。2.2 match查询全文检索的默认选择分词逻辑直接影响结果如果说term是外科手术刀那match就是大网捞鱼。它会对查询关键词先分词再去倒排索引里搜索最后按照相关度排序。还是用title字段的例子GET /blogs/_search { query: { match: { title: elasticsearch入门 } } }ES会先把elasticsearch入门分词成elasticsearch和入门两个词或者更细分然后返回包含其中任意一个词的文档并根据匹配程度计算得分。这就意味着match返回的结果集往往比term大得多但也会有“看起来不相关”的文档混进来。这里有一个大家容易忽略的operator参数。默认match查询只要任意一个词匹配就算命中但实际上ES的默认逻辑是operator: or。如果我希望所有分词后的词都必须出现可以这样设置GET /blogs/_search { query: { match: { title: { query: elasticsearch入门, operator: and } } } }这个写法看着简单但它背后的意义是match查询不是死的“完全匹配”或者“模糊匹配”而是可以精细控制匹配程度的。再深入一层match查询还支持minimum_should_match参数允许你设定最少匹配词数。比如一段长句被分成了五个词你可以指定至少匹配三个词才算命中。这在搜索召回阶段特别有用既不会太严格导致漏数据又能过滤掉明显不相关的文档。2.3 match_phrase查询短语匹配的注意点和常见坑又一个常用查询是match_phrase它的定位是短语匹配。比如文档里包含“elasticsearch实战”这个连续短语就可以这么查GET /blogs/_search { query: { match_phrase: { title: elasticsearch实战 } } }它的核心约束是查询词分词之后的各个词必须在文档中按同样的顺序相邻出现中间不能插入其他词。这听起来很美好但你很快会遇到一个经典问题文档写的是Elasticsearch 7.X 实战指南中间有空格和数字你的match_phrase最终可能成功也可能失败取决于分词结果。我用一个真实踩坑场景来说明。某次我在某个项目里写了一个搜索框用户输入“民法典婚姻编”我用的match_phrase查法规正文。结果怎么都查不到中华人民共和国民法典 第五编 婚姻家庭的内容原因是原文被分词后词序和用户输入并不完全连续“编”和“婚姻家庭”中间插入了第五等词。改法是用slop参数。slop允许词与词之间最多隔着多少个其他词移位。比如设置slop: 2就允许一定的跨度GET /blogs/_search { query: { match_phrase: { title: { query: elasticsearch实战, slop: 1 } } } }slop越大匹配越宽松。但记住slop不是无限用的设置太大就失去了短语匹配的意义查出来的东西会非常杂。2.4 multi_match查询一次查多个字段的小技巧实际搜索场景里很少只查一个字段。比如用户搜索“elasticsearch基础”我既希望匹配标题也希望匹配正文甚至匹配标签。用multi_match就是最顺手的方案GET /blogs/_search { query: { multi_match: { query: elasticsearch基础, fields: [title^3, content, tags] } } }注意title^3这个写法意思是标题字段的权重是正文的3倍。权重越高的字段匹配到相同词时贡献的相关度得分越高。这在搜索排序里非常实用。我个人的建议是小项目可以省事用multi_match一把梭但一旦业务复杂起来最好还是用bool查询把各个字段的匹配条件拆开控制因为multi_match的可调参数远没有那么灵活。不过作为入门理解multi_match绝对是必须掌握的基础款。2.5 其他小但很实用的查询range、exists再补两个高频使用的查询。range查询用于范围过滤比如发布时间GET /blogs/_search { query: { range: { pub_time: { gte: 2024-01-01, lte: 2024-12-31 } } } }gte表示大于等于lte表示小于等于还有gt和lt对应严格大于/小于。range不仅用于日期也适用于数字。这里要特别注意如果字段是date类型你传入的日期字符串是会被正确解析的如果字段是keyword类型range走的是字典序范围比较结果可能和你直觉不一样。所以日期字段务必映射为date类型别偷懒用字符串存。exists查询用来过滤某个字段是否存在。很多业务里文档可能有些字段为空而我们只想搜索有该字段的文档GET /blogs/_search { query: { exists: { field: summary } } }这个查询写起来简单但它背后涉及的是ES里“字段不存在”和“字段值为null”的细节区别。ES默认不会索引null值所以exists查不到null字段这一点要心里有数。3. bool查询组合需要的不是“或”“与”而是上下文匹配3.1 must、should、filter、must_not四种角色的分工当你要写真正生产可用的查询时几乎离不开bool查询。它相当于一个集装箱把你所有的条件装进去并按逻辑组织起来。bool内部有四个常见部分must必须匹配且参与相关度得分计算。filter必须匹配但不参与评分。结果得分统一是0性能更好。should可选匹配如果满足则加分。通常用于实现“至少匹配一个”的宽松召回或者影响排序权重。must_not必须不匹配也在filter上下文中执行不参与评分。一个完整的例子GET /blogs/_search { query: { bool: { must: [ { match: { title: elasticsearch } } ], filter: [ { term: { tags: 实战 } }, { range: { views: { gte: 1000 } } } ], must_not: [ { term: { tags: 广告 } } ], should: [ { match: { content: 聚合 } } ] } } }这个查询表达的意思是标题必须包含elasticsearch标签必须是“实战”且阅读量在1000以上不能带“广告”标签如果正文里还有“聚合”可以额外加分。注意should在没有must和filter时默认行为是至少满足一个但这里因为有must条件should就变成了影响排序的加分项这个细节非常容易踩坑。我刚接触bool时经常疑惑must和filter不都是“必须满足”吗差别到底在哪核心只有一点得分计算。must里的查询会计算相关度得分会遍历大量文档去做打分计算filter里的条件只做真假判断不计算得分。因此如果你的业务条件只是必须满足比如状态字段等于“上线”、时间范围在某个区间强烈建议放进filter既语义清晰又能省掉一大段算分开销。3.2 filter上下文和query上下文性能差异到底有多大要理解filter和query的性能差异得先知道ES在查询时有两个上下文query context和filter context。query context里ES不仅要判断文档是否匹配还要计算每条匹配文档的_score然后用这个分数排序。计算_score的过程涉及词频、文档频率、字段长度等一系列统计开销不小。而filter context里ES只关心“匹配”或“不匹配”结果会被缓存起来复用。所以对于大量重复的过滤条件比如“状态启用”“创建时间在一个月内”用filter的查询性能会明显优于query。我在一次日志检索系统优化里做过实测原来把所有条件都堆在must里一个包含三四个条件的查询单次took大概在80ms左右把等值条件和范围条件挪到filter之后同样的数据量took下降到了30ms左右。这个差距不是玄学而是ES缓存机制在起作用。更重要的是不要把“必须匹配”的习惯性思维带到所有场景里。如果你要做的是“筛选”优先filter如果你要做的是“搜索”比如搜索词和文本的相关度匹配才用query。记住这句话你写的查询会快不少也更符合语义。3.3 嵌套bool查询的正确打开方式bool内部可以继续嵌套bool。比如要表达“A标签且阅读量高或者B标签且发布时间新”这样的复杂条件就需要嵌套。GET /blogs/_search { query: { bool: { should: [ { bool: { must: [ { term: { tags: A } }, { range: { views: { gte: 10000 } } } ] } }, { bool: { must: [ { term: { tags: B } }, { range: { pub_time: { gte: 2024-06-01 } } } ] } } ] } } }这个查询里最外层是should两个子bool只要满足其一即可。写嵌套时要注意括号和JSON逗号的层级我见过太多同事在多层嵌套时漏掉一个花括号然后被ES返回的parse_exception搞疯。建议写复杂查询时先在编辑器里用格式化工具整理JSON再复制到Kibana或curl里调试。另外嵌套bool还会带来一个多字段上下文协同的问题。比如你用了must过滤一个字段又用should匹配另一个字段两者的得分是叠加的。如果想强制某个should匹配才生效那得配合minimum_should_match参数来控制数量我后面在常见问题部分还会讲到。4. 结果排序、分页与聚合查询不只是“把数据捞出来”4.1 sort排序的写法和默认评分排序默认情况下ES返回结果是按_score降序排列的。如果查询条件里全是filter所有文档的_score都是0返回顺序就不稳定看起来像“随机”。这时候需要显式地使用sort。单字段排序最简单GET /blogs/_search { query: { match_all: {} }, sort: [ { views: { order: desc } } ] }多字段排序只需要在数组里加一个元素sort: [ { pub_time: desc }, { views: desc } ]排序字段如果是text类型ES默认不允许直接排序因为text经过分词后无法按原始字符串排序。你需要用.keyword字段title.keyword。这也是为什么很多索引在设置映射时会单独定义一个不需要分词的排序字段目的就是给排序用。还有一个小知识点对date字段排序时如果文档里该字段是缺失的ES默认会把它排在最后。可以用missing参数控制sort: [ { pub_time: { order: desc, missing: _last } } ]4.2 from/size分页与深分页问题分页几乎是查询绕不开的需求。ES的传统分页方式是from和size比如查第二页每页10条GET /blogs/_search { query: { match_all: {} }, from: 10, size: 10 }这个写法和处理传统数据库的LIMIT 10 OFFSET 10思路一样但ES的分布式架构决定了它必须“先拉全部再截取最后那一段”。比如你的索引有5个分片查询from: 1000, size: 10ES实际上要从每个分片取回1010条结果在协调节点上排序后再丢到1000条之后取10条。这个操作在小数据量时没问题但一旦冲到几万、几十万页协调节点的内存和CPU会被拖垮。这就是著名的“深分页问题”。解决方案有三种主流手段用search_after配合排序实现“游标式”下一页。用scrollAPI做批量导出。用PITPoint In Time结合search_after适合在一致性快照上翻页。我实际的经验是如果只是前端页面的分页控制在几十页以内直接用from/size没问题一旦你要导出全量数据或者做后几百万条的深度遍历必须换成search_after。search_after的写法是先正常查询并排序记录最后一条结果的排序值然后下一次查询带上GET /blogs/_search { query: { match_all: {} }, sort: [ { views: desc }, { _id: asc } ], size: 10, search_after: [ 9876, doc_id_123 ] }注意search_after要求排序值唯一且稳定所以通常加一个_id字段作为第二排序条件否则数据一旦有相同排序值翻页会丢数据或重复。4.3 聚合查询从统计角度理解分组统计查询不光是捞数据很多时候是拿来做统计的。ES的聚合分为两大类metric聚合做数值计算bucket聚合做分组。最经典的例子是统计每个标签下的文章数GET /blogs/_search { size: 0, aggs: { group_by_tag: { terms: { field: tags.keyword, size: 10 } } } }注意这里用了tags.keyword原因和term查询一样标签字段如果是text聚合会基于分词后的词条而不是完整标签。size: 0的意思是不返回文档列表只返回聚合结果能显著减少返回体大小。如果还想进一步统计每个标签下的平均浏览量GET /blogs/_search { size: 0, aggs: { group_by_tag: { terms: { field: tags.keyword }, aggs: { avg_views: { avg: { field: views } } } } } }聚合里嵌套聚合子聚合在前一个分组的结果之上继续计算这是ES聚合的基本单位结构。时间范围聚合在日志场景里极其常用比如按天统计文章发布量GET /blogs/_search { size: 0, aggs: { daily_count: { date_histogram: { field: pub_time, calendar_interval: day } } } }这里calendar_interval是ES中指定的日历间隔支持minute、hour、day、week、month等。和fixed_interval的区别是calendar_interval会考虑时区和自然边界比如周日周起始之类。日志分析里按天、按小时的统计基本都用它。写聚合时还要记住一个关键点聚合是在查询结果之上做的。如果query里写了过滤条件聚合结果就是过滤后的数据。想统计全量数据要么不写query要么用post_filter区分“返回给用户的过滤”和“聚合的过滤范围”这个细节在实际报表场景很折磨人。5. 常见问题与排查技巧实录5.1 为什么term查询查不到数据这是新手群里出现频率最高的问题。前面提到过term查不到多半是字段类型问题。排查步骤我可以总结成三条用GET /blogs/_mapping看目标字段类型是text还是keyword。如果是text直接用term查基本都会失败换成match或者用.keyword。如果字段类型没问题再检查查询关键词大小写。text字段索引时会被转为小写keyword字段则保留原始大小写。term查询是不做大小写转换的所以查keyword字段时关键词大小写必须完全一致。举一个我实际遇到的例子。某业务系统里用户的设备唯一标识是device_id映射成了text。我一开始用term去筛这个字段返回0条。后来看映射才发现ES给这个字段同时创建了device_id.keyword子字段改用term查keyword子字段后立刻正常。5.2 为什么match_phrase明明有数据却匹配不到这个问题通常和分词有关。ES默认标准分词器会把中文按单个汉字切分。如果你的短语包含中文而文档里的表达方式和查询词不完全一致就会匹配失败。建议在处理中文场景时先做两件事调好分词器。至少在索引的text字段上配置一个合适的中文分词插件而不是默认的standard。查询时先用_analyze接口确认分词结果再看match_phrase为什么失败。比如执行POST /blogs/_analyze { field: title, text: Elasticsearch实战 }看到分词结果后你就能判断是哪些词没有按预期组合。match_phrase本质上就是要求分词后的词在文档里按顺序出现分词器不同匹配结果完全不同。5.3 查询变慢怎么快速定位问题查询慢的排查思路我习惯从这几个方向入手看took和timed_out判断是偶发还是持续。打开profileAPI看查询每个阶段耗时。确认查询条件是不是都进了query上下文。如果条件比较简单但耗时高往往是因为没写filter。检查有没有深分页。from拉得太深查询会明显变慢。看聚合部分terms聚合如果size设置过大或者桶数量巨大也会拖慢整个查询。我的一次典型优化案例是某个日志查询接口用户选择时间范围后我一开始把时间范围放在match_all旁边的must里单次查询平均120ms。改成filter之后降到40ms。这个改动几乎没有成本收益却非常明显。5.4 结果不符合预期用explain看评分细节当你明明觉得某条文档应该排在前面ES却把它排到后面时不要用猜的用explain接口看具体得分来源。示例GET /blogs/_search { query: { match: { title: elasticsearch实战 } }, explain: true }返回结果里会展示每一条得分的计算过程包括词频、逆文档频率、字段长度归一化等。虽然第一次看这个JSON觉得很复杂但排查多了就会明白ES的评分不是“我觉得应该高就高”它有一堆统计逻辑在里面。我还遇到过一种常见误区filter条件过多时某条文档的得分其实只取决于must里的匹配情况和filter无关。但业务方会觉得“明明条件都满足了怎么得分还是低”。这时候用explain讲清楚逻辑往往比改代码更有说服力。5.5 一个快速自查清单最后我把平时排查查询问题的思路整理成一个清单每次写新查询前过一遍目标字段的映射类型是什么text还是keyword我用的是匹配查询还是过滤查询是否需要参与评分有没有把不需要评分的条件放进filter排序字段是否可排序有没有用.keyword分页深度超过几千了吗要不要改用search_after聚合字段是不是分词字段要不要指定.keyword短语匹配失败时有没有检查分词结果这些问题大多数在动手查之前想清楚就能省掉大把排查时间。我个人在写ES查询这么多年里最受益的一个习惯是永远先确认字段类型和分词方式再写查询体。很多人一上来就复制一段网上找的查询模板字段名一改就发出去结果出了问题还要对着返回JSON猜半天。ES的查询语法其实并不复杂复杂的是它背后那套基于倒排索引、分片分布、评分模型的设计逻辑。把term和match的区别、query和filter的上下文这些底层概念吃透你会发现各种查询写法都只是组合游戏。后面如果你要继续往深走可以看看如何自定义分词器、如何优化聚合性能、如何用function_score定制评分规则。但前提是基础的查询语法已经在你脑子里形成了一套清晰的地图。这篇内容就是那张地图的主干。