Elasticsearch查询语法详解:从match到聚合,一篇搞定基础查询

发布时间:2026/9/15 22:30:15
Elasticsearch查询语法详解:从match到聚合,一篇搞定基础查询 做 Elasticsearch 开发或者运维最难的不是把服务跑起来也不是装个 Kibana 看数据而是面对业务方提的帮我查一下最近一小时订单量超过五百的客户有哪些这类需求时脑子里能立刻翻译出一段像样的查询语句。ES 的查询语法学习曲线有点陡网上资料大多停留在 curl 一个 match 查询的层面真正到了 bool 组合、聚合分析、深分页优化的时候很多人就开始靠猜了。这篇内容我根据自己的使用经验把 ES 基础查询语法从头到尾捋了一遍。不是翻文档式的罗列 API而是从这个东西到底解决什么问题出发配合一套可以本地跑通的样例数据把 match、term、bool、aggregation 这些核心语法串起来讲。适合刚接触 ES 的开发者也适合那些会用 CRUD 但没系统梳理过查询语法的同学查漏补缺。1. 在写查询之前先搞清楚 ES 搜的是什么很多人直接跳进怎么写查询这一步结果遇到一个非常基础的问题明明数据里有一条记录叫iPhone 15 Pro我用term查15却什么都查不到。这不是 ES 坏了而是你还没理解 ES 的存储结构和查询上下文之间的关联。1.1 text 字段与 keyword 字段的区别ES 底层依赖倒排索引Inverted Index分词是它的核心操作。一个text类型的字段在写入时会被分析器Analyzer拆成多个词项例如I love Elasticsearch会被默认的 standard analyzer 拆成[i, love, elasticsearch]。而keyword类型的字段则相反它把整个字符串当作一个不可拆分的词项存进索引所以适合存状态值、标签、订单号这类需要精确匹配的内容。这意味着什么意味着同一份数据用match还是term查询背后的查找逻辑完全不同match查询会先把你的查询词做同样的分词处理再去倒排索引里匹配。比如查love elasticsearchES 会把查询词拆成[love, elasticsearch]然后匹配包含这两个词项的文档。term查询不做任何分词直接把查询词作为一个完整的词项去倒排索引里找。所以如果想用term精确匹配一个 text 字段基本都会失败因为 text 字段在索引里存的已经不是完整字符串了。这是新手最容易踩的坑之一。正确做法是需要全文检索的字段用text类型需要精确匹配、排序、聚合的字段用keyword类型。如果一张表里两种需求都有ES 也支持给同一个字段同时建两种类型fields多字段特性后面章节我会再展开。1.2 Query Context 与 Filter Context 的区别ES 的查询还分两种上下文Context这个直接关系到查询性能Query Context回答这个文档有多匹配会计算相关性评分_score用于排序。Filter Context回答这个文档是否匹配条件只做是/否判断不计算相关度。由于结果可以缓存通常比 Query Context 更快。用一个简单类比Query Context 像搜索引擎的关键词相关度排序Filter Context 像数据库的WHERE过滤条件。带着这个认知去理解bool查询里的must和filter区别后面就顺理成章了。2. 本地环境准备所有演示从这里开始讲语法不能只贴理论。我在本地准备了一套可复现的环境和数据集后面的查询都会跑在这套数据上。你也顺手把它配好看到示例可以直接复制运行边看边练。2.1 一条命令启动 ES 与 Kibana因为 ES 最新版本对本地启动比较挑剔特别是 Windows 下经常遇到内存配置、目录权限、端口占用之类的问题我建议直接用 Docker 把 ES 和 Kibana 一起拉起来。这也是目前最省心、最接近生产环境的方式。docker network create es-network docker run -d \ --name es-single \ --network es-network \ -p 9200:9200 \ -e discovery.typesingle-node \ -e xpack.security.enabledfalse \ -m 1g \ docker.elastic.co/elasticsearch/elasticsearch:8.14.3 docker run -d \ --name kibana \ --network es-network \ -p 5601:5601 \ -e ELASTICSEARCH_HOSTShttp://es-single:9200 \ docker.elastic.co/kibana/kibana:8.14.3启动后访问http://localhost:5601进入 Kibana 的 Dev Tools就能直接写 DSL 查询了。如果不想用 DockerWindows 用户也可以去官网下载 ZIP 包bin\elasticsearch.bat启动配置环境变量ES_JAVA_OPTS-Xms1g -Xmx1g防止内存不足但不要在生产环境这么干。强调一点本文所有示例基于 ES 7.x/8.x 的查询 DSL。不同大版本之间基础查询语法基本一致但如果有版本特性差异我会单独说明。2.2 准备一份便于演示的测试数据为了把查询讲透我建一个电商订单索引包含商品名称、用户信息、订单金额、状态、创建时间等字段。这类数据非常贴近实际业务演示全文检索、精确匹配、范围查询和聚合分析都很自然。先创建索引并显式指定字段映射。这里故意把几个核心字段的类型固定下来避免 dynamic mapping 自动推断出不符合预期的类型。PUT /shop_order { mappings: { properties: { order_id: { type: keyword }, user: { properties: { name: { type: keyword }, level: { type: keyword } } }, product_names: { type: text }, total_amount: { type: double }, status: { type: keyword }, created_at: { type: date } } } }然后写入几条订单数据POST /shop_order/_bulk {index: {}} {order_id: 20240101001, user: {name: 张三, level: gold}, product_names: Apple iPhone 15 Pro 手机, total_amount: 8999, status: paid, created_at: 2024-01-01T10:30:00Z} {index: {}} {order_id: 20240101002, user: {name: 李四, level: silver}, product_names: Elasticsearch 实战指南 书籍, total_amount: 129, status: unpaid, created_at: 2024-01-01T11:00:00Z} {index: {}} {order_id: 20240101003, user: {name: 王五, level: gold}, product_names: MacBook Pro 14 英寸 笔记本电脑, total_amount: 14999, status: paid, created_at: 2024-01-02T09:15:00Z} {index: {}} {order_id: 20240101004, user: {name: 赵六, level: silver}, product_names: 无线蓝牙耳机 Pro, total_amount: 399, status: refunded, created_at: 2024-01-02T14:45:00Z} {index: {}} {order_id: 20240101005, user: {name: 孙七, level: vip}, product_names: Apple iPhone 15 Pro Max 手机 和 Apple Watch, total_amount: 10999, status: paid, created_at: 2024-01-03T08:20:00Z} {index: {}} {order_id: 20240101006, user: {name: 周八, level: normal}, product_names: Java 编程思想 书籍, total_amount: 108, status: cancelled, created_at: 2024-01-03T20:05:00Z}数据量虽然少但已经能覆盖字符串全文检索、精确匹配、数值范围、时间聚合等绝大部分场景。3. 全文检索的三板斧match、match_phrase 与 multi_match全文检索是 ES 区别于普通关系型数据库的核心能力。很多人知道match是模糊查询但模糊在哪里、如何控制匹配度其实值得花点时间弄明白。3.1 match 查询默认的 OR 逻辑比你想得要宽写一个最简单的全文检索查商品名称里包含iPhone 手机的订单。GET /shop_order/_search { query: { match: { product_names: iPhone 手机 } } }ES 会先把查询语句iPhone 手机分词为[iphone, 手机]然后默认按OR逻辑去匹配包含其中任意一个词的文档。所以实际返回的可能是只包含iPhone的文档也可能是只包含手机的文档排名靠前的才是两个词都命中的。这个默认行为对很多业务场景来说太宽了。如果你希望所有分词都必须命中即默认 AND 逻辑可以在查询里显式指定{ query: { match: { product_names: { query: iPhone 手机, operator: and } } } }实际项目中搜索同时包含多个关键词的需求很常见但operator: and又有些过于严格比如标题里写了iPhone 15 Pro 手机壳用户搜iPhone 15 手机时希望能匹配到。这时候可以引入minimum_should_match参数比如设置为2表示分词后至少有两个词命中才算匹配。这个参数在搜索引擎里极其常用建议多写几个例子体会一下。3.2 match_phrase 查询一定要连起来的那种匹配如果需求是查完整包含这个短语的内容比如搜索iPhone 15 Pro要求文档里这些词连续出现且顺序一致那就轮到match_phrase出场了。{ query: { match_phrase: { product_names: iPhone 15 Pro } } }上面的查询能匹配到20240101001和20240101005两条订单因为它们的product_names分词后都有iphone、15、pro且顺序一致。而如果你查iPhone Pro默认就是匹配不到了因为它要求分词按顺序连续出现。但连续并不一定代表中间不能有任何间隔。match_phrase有个slop参数用来控制词项之间允许移动的最大距离。比如{ query: { match_phrase: { product_names: { query: iPhone Pro, slop: 1 } } } }slop为 1 表示Pro可以向前或向后移动一个位置。这样即使在原文里是iPhone 15 Pro也能被检索出来。这个参数非常适合处理用户少输入了一个词、或者中英文顺序习惯不同的场景。3.3 multi_match多字段搜索的正确打开方式业务中经常需要在一个输入框里搜索多个字段比如既搜商品名product_names又搜用户昵称user.name。multi_match就是为了解决这个问题的。{ query: { multi_match: { query: 张三, fields: [product_names, user.name] } } }除了最基本的用法multi_match还支持给不同字段设置不同的权重。比如我认为商品名称的匹配比用户昵称的匹配更重要可以写成{ query: { multi_match: { query: iPhone 15, fields: [product_names^3, user.name] } } }^3表示product_names字段的相关性得分乘以 3。这个技巧用在实际搜索排序上非常关键同样的词出现在标题和出现在正文里重要性完全不同。4. 精确匹配与结构化过滤term / terms / range / exists全文检索解决的是模糊问题但很多业务条件其实是精确的比如订单状态等于paid、用户等级是gold、金额大于 1000。这类条件在 ES 里应该用结构化查询而不是 match。4.1 term 查询必须理解 keyword 类型term是 ES 最基础的精确匹配查询它不会对查询词做分词。{ query: { term: { status: paid } } }一次性查多个精确值比如查询所有paid和refunded状态的订单用terms{ query: { terms: { status: [paid, refunded] } } }这里有个必须牢记的细节被查询的字段必须是keyword类型或者numeric、date、boolean等非 text 类型。如果把term用在text字段上结果通常出乎意料。另外keyword字段的精确匹配是大小写敏感的也就是说Paid和paid是两个完全不同的词项。很多时候你在 MySQL 里查没有这个问题但到了 ES 里大小写和分词就是绕不开的两个坑。如果确实需要对 text 字段做精确匹配建议在 mapping 里用多字段特性{ mappings: { properties: { product_names: { type: text, fields: { keyword: { type: keyword, ignore_above: 256 } } } } } }这样product_names可以用于全文检索product_names.keyword可以用于精确匹配和聚合排序。一个小字段配置解决两类问题。4.2 range 查询数值和时间的范围过滤查金额在某个区间内的订单或者查某个时间点之后的订单统一用range。它支持的运算符包括gt大于、gte大于等于、lt小于、lte小于等于。查金额在 100 到 500 之间的订单{ query: { range: { total_amount: { gte: 100, lte: 500 } } } }查 2024 年 1 月 2 日之后创建的订单{ query: { range: { created_at: { gte: 2024-01-02T00:00:00Z } } } }日期相关的 range 查询也支持相对时间比如查最近 7 天的订单{ query: { range: { created_at: { gte: now-7d/d } } } }其中/d表示将时间对齐到天。这类语法在日志分析场景出现得非常频繁值得记下来。4.3 exists 查询与通配符查询exists查询用来过滤某个字段是否存在。注意 ES 的存在有特殊定义字段值不是null也不是空数组。比如查询所有有退款原因记录的订单{ query: { exists: { field: refund_reason } } }另一类常见需求是做模糊匹配比如搜索所有订单号以20240101开头的订单。可以用prefix或wildcard{ query: { prefix: { order_id: 20240101 } } }{ query: { wildcard: { order_id: 20240101* } } }wildcard支持*任意字符和?单个字符确实方便。但这里我必须泼一盆冷水wildcard和prefix这类查询在数据量大的时候性能非常差因为它们在倒排索引里做的是遍历词项而不是快速查找。生产环境能用keyword精确匹配解决的尽量避免用通配符尤其不要用前缀通配符*xxx那意味着几乎扫描整个索引。5. 组合查询bool 的四个子句到底怎么用真实业务查询很少只有一个过滤条件。订单状态、金额区间、商品关键词、用户等级往往同时出现。把这些条件组合起来就是bool查询的天下。5.1 must、should、must_not 与 filter 的语义bool查询里有四个子句理解它们各自的语义很重要must文档必须满足这些条件并且会参与相关性评分。filter文档必须满足这些条件但不会影响相关度评分。ES 会自动缓存 filter 子句的结果性能比 must 好。should文档可以满足也可以不满足但满足的话可以提升评分。在 bool 查询中如果不存在must或filter子句至少一个 should 条件需要满足。must_not文档必须不满足这些条件是一个纯过滤条件不影响相关度。用一句话总结must是必须匹配而且要打分filter是必须匹配但不打分should是加分项must_not是排除项。5.2 一个贴近业务的组合查询实例假设产品经理提了一个搜索需求搜索商品名包含iPhone或苹果订单金额大于 5000状态必须是paid但排除user.level为normal的用户。这个需求用 bool 写出来是{ query: { bool: { must: [ { match: { product_names: iPhone 苹果 } } ], filter: [ { range: { total_amount: { gt: 5000 } } }, { term: { status: paid } } ], must_not: [ { term: { user.level: normal } } ] } } }在这个例子里must中的 match 查询负责全文匹配同时参与打分保证结果按相关度排序。filter中的 range 和 term 负责过滤结果被缓存性能好又不会干扰排序。must_not排除低等级会员。我在实际项目中强烈建议能把条件放到filter里的尽量不要放must。比如商品分类、状态、是否删除这类属性应该全部放 filter。只有真正的关键词相关性匹配才放 must。这样可以最大程度利用 ES 的查询缓存尤其是并发量上来之后效果能差出一倍以上。5.3 嵌套 bool别把结构写得太深当业务条件比较多时bool 里是可以继续嵌套 bool 的。比如商品名包含 iPhone 或者 MacBook且金额在特定区间内这个或者在 bool 里需要用 should 表示{ query: { bool: { must: [ { bool: { should: [ { match: { product_names: iPhone } }, { match: { product_names: MacBook } } ], minimum_should_match: 1 } }, { range: { total_amount: { gte: 1000 } } } ], filter: [ { term: { status: paid } } ] } } }嵌套本身不复杂但有一个原则需要守住不要为了查询把 bool 套到三四层以上。一旦超过三层一方面阅读代码会非常痛苦另一方面可维护性直线下降。更好的做法是先把过滤条件拆成独立的 filter 子句再把复杂的 OR 关系提取成单独的 bool最后在代码层组装。6. 聚合查询写完条件再把数据汇总查询如果只能返回原始列表价值始终有限。业务里常见的问题是不同状态的订单各有多少每个月的销售总额是多少平均客单价多少。这些汇总统计在 ES 里统称为聚合Aggregation它是 ES 快速分析大数据量的利器。6.1 先分桶再统计terms 聚合最简单的聚合是根据字段分组统计类似 SQL 里的GROUP BY。以订单状态为例统计各状态订单数量{ size: 0, aggs: { group_by_status: { terms: { field: status } } } }注意size: 0这表示我们只要聚合结果不关心命中的原始文档。响应里的buckets会列出paid、unpaid、refunded、cancelled各自的文档数量。每个桶里还可以再嵌套其他聚合比如统计不同状态下订单的平均金额{ size: 0, aggs: { group_by_status: { terms: { field: status }, aggs: { avg_amount: { avg: { field: total_amount } } } } } }这就像 SQL 里的SELECT status, AVG(total_amount) FROM shop_order GROUP BY status。除了avgES 还提供了sum、min、max、stats一次性返回多个统计值等 metric 聚合用法完全一致。6.2 日期直方图聚合按时间统计趋势日志分析和电商报表里最常见的时间维度聚合就是用date_histogram。它可以把日期字段按天、月、小时等粒度分成一个个桶。比如按天统计订单金额总和{ size: 0, aggs: { amount_per_day: { date_histogram: { field: created_at, calendar_interval: day }, aggs: { total_amount: { sum: { field: total_amount } } } } } }返回结果里每个桶代表一天桶内doc_count是订单数量total_amount的 value 是当天销售额总和。这类查询在构建监控大屏、运营报表时非常常见。需要注意calendar_interval和fixed_interval的区别calendar_interval支持day、month、quarter等按日历对齐的间隔fixed_interval则适合固定的时间跨度比如24h、30s两者不能混用。比如跨天时间窗用calendar_interval更符合直觉而监控系统按每 5 分钟采集数据用fixed_interval更合适。6.3 查询条件与聚合的嵌套配合聚合可以和查询条件一起使用查询先筛出符合条件的文档然后对这些文档再做聚合。比如统计已支付订单中每天的平均客单价{ size: 0, query: { bool: { filter: [ { term: { status: paid } } ] } }, aggs: { avg_amount_per_day: { date_histogram: { field: created_at, calendar_interval: day }, aggs: { avg_amount: { avg: { field: total_amount } } } } } }这个模式的本质是query部分负责缩小数据范围aggs部分在范围之上做分析。生产环境几乎所有聚合查询都要配合时间过滤条件否则全量聚合对性能和资源消耗都不小。7. 结果处理里的几个细节排序、分页与字段裁剪查询条件写得没问题结果集处理没做好同样会踩坑。我见过太多同学在 ES 里做深分页查询把from设到几万甚至几十万然后发现请求耗时直接飙升。这一节把结果处理的几个关键点讲清楚。7.1 排序别忘了一切排序都要基于可排序的字段ES 默认按照相关度_score排序。但用了 filter 子句之后由于不计算分数且全文检索不一定触发_score的值很可能是 1.0 或 0.0这时候结果排序就随机了。业务上有明确排序规则时应该显式指定sort。比如按订单金额降序排列{ query: { match_all: {} }, sort: [ { total_amount: { order: desc } } ] }注意用于排序的字段必须是keyword、numeric或date类型text字段不能直接排序。如果 mapping 里没有设置就会报错。通常的解决方案还是利用多字段特性用字段名.keyword来排序。7.2 深分页问题与 search_afterES 的常规分页方式是from size比如查询第 100 页、每页 10 条就是from: 990, size: 10。这种写法在小数据量时够用但深分页时性能急剧下降因为它需要在每个分片内把前from size条数据全部取出来再整体排序合并。读到几万条以后这个过程对 CPU 和内存的消耗是灾难级的。生产环境我不建议用深分页翻数据。如果只是给用户看前几页用from size没问题如果是后台批量导出数据或者无限滚动加载请使用search_after。search_after的核心思路通过上一页最后一条记录的排序值作为下一页的起点而不是从全局跳跃查找。{ size: 2, query: { match_all: {} }, sort: [ { total_amount: desc }, { order_id: asc } ] }第一次查询返回最后一条记录中sort数组的值比如[14999.0, 20240101003]。第二次查询带着这个值继续{ size: 2, query: { match_all: {} }, sort: [ { total_amount: desc }, { order_id: asc } ], search_after: [14999.0, 20240101003] }使用search_after时排序字段的值必须唯一或者加一个order_id这样的唯一字段作 tiebreaker否则在排序值相同的记录之间可能漏数据或重复数据。7.3 _source 字段裁剪查询条件已经命中了一堆文档但前端只需要其中两三个字段怎么办ES 允许在查询时用_source筛选返回字段{ query: { match_all: {} }, _source: [order_id, status, total_amount] }这不仅能减少网络传输的数据量还能降低部分序列化开销。那种一句话查出来再在内存里圈字段的代码习惯在 ES 里是不太划算的。8. 基础查询之后建议你接着做这几件事到这里ES 基础查询语法已经覆盖了 80% 的日常需求。但有几个进阶主题操作上很值得马上安排上。8.1 用 Kibana 的 Search Profiler 定位慢查询如果查询响应时间超过几百毫秒先别去调 JVM 参数更别急着加服务器。用 Kibana Dev Tools 里的 Search Profiler把查询 DSL 粘进去执行一下就知道哪个查询子句耗时最多。常见的问题通常是wildcard 扫了太多词项、sort 在 text 字段上触发了 fielddata、或者 filter 条件没生效导致全表扫描。这些用 Profile 结果一看就非常清楚。8.2 了解一下 ES|QL 这种更直观的查询方式从 ES 8.11 开始官方力推 ES|QL 查询语言语法上更接近传统 SQL学习成本比 JSON DSL 低不少。比如刚才的日销售额统计用 ES|QL 写是这样POST /_query { query: FROM shop_order | WHERE status paid | STATS total_amount SUM(total_amount) BY BUCKET(created_at, 1 day) }ES|QL 在过滤、join、聚合等场景下确实比嵌套多层 JSON 更直观。但要注意不是所有老版本都支持而且生态工具对它的支持也还在完善中。我的建议是新项目可以尝鲜生产环境如果存量代码都是 JSON DSL不要着急大规模迁移。8.3 Java 项目里的几个惯用法如果是 Java 技术栈最常用的就是ElasticsearchClient或旧版的RestHighLevelClient。实际写代码时有一个细节值得注意先构造 BoolQuery 再逐步追加条件不要写一堆 if 嵌套来动态拼条件。举个例子商品搜索可能有很多可选过滤项用 builder 模式追加 filter 条件比手动拼接 JSON 字符串要优雅得多也不容易出错。另外ES 在写查询时有个习惯我可以分享所有查询先拿到 Kibana 里用原始数据验证一遍确认返回结果符合预期再翻译成 Java 代码。不要直接在代码里写一个复杂查询然后靠猜调试那样定位问题的时间大概率是前者的好几倍。最后再说一个小技巧。ES 查询里的filter子句好比一个免打分快速通道能放filter就别放must。我自己写过很多查询回头优化时发现大部分性能问题都不是集群不给力而是查询结构把没必要打分的条件放到must里导致相关性计算白烧 CPU。把状态、分类、时间这类刚性条件全部移入filter后很多慢查询就不治而愈了。