Elasticsearch查询语法核心:match、term、bool与聚合实战

发布时间:2026/9/8 11:07:14
Elasticsearch查询语法核心:match、term、bool与聚合实战 1. 一条ES查询语句的背后先搞懂查询语法在解决什么问题做了这么多年搜索和日志分析相关的开发我几乎每天都要跟Elasticsearch打交道。很多刚接触ES的同学会跑来问我为什么我明明是按文档抄的查询结果就是不对为什么match查出来的结果跟我想的不一样为什么term查不到数据这些问题背后其实都是同一个根源没有真正理解ES查询语法的分类逻辑和底层原理。ES的查询语法不是一套简单的等于某个值的拼凑而是一套从全文检索到结构化过滤再到聚合分析的完整体系。如果你把它当成SQL来写一定会踩坑。Elasticsearch简称ES最核心的查询入口是_search接口。所有查询无论多复杂本质上都是向这个接口提交一个JSON结构的query对象。这个对象里的每一个关键字比如match、term、bool、range、wildcard都有它自己的适用场景和底层实现逻辑。搞懂这些你写出来的查询才会既准确又高效。这篇文章我就从实际使用角度把这套基础查询语法掰开揉碎讲清楚。不堆概念直接上请求、上返回结果、上踩坑记录。文章覆盖的内容从最基础的_search请求结构到match与term的底层区别再到bool组合查询、聚合分析、排序分页、模糊匹配基本把你日常开发里90%会碰到的查询场景都过了一遍。花二十分钟看完你至少能少走几个月弯路。2. 从一个最简单的_search请求说起请求结构决定了查询的边界先看一个最基础的查询长什么样。假设我有个电商系统的订单索引名叫orders里面存了订单号、用户ID、商品名称、订单金额、订单状态、创建时间这些字段。我一条请求查商品名称包含手机的订单写法是这样的GET /orders/_search { query: { match: { product_name: 手机 } } }你会发现整个查询用JSON包成了一个query对象里面再套具体的查询子句。这就是ES查询的基础骨架query上下文决定哪些文档能匹配上。除了query这个请求里还能放很多其他顶层参数比如控制返回条数的size、控制跳过的from、控制排序的sort、控制返回字段的_source、以及做聚合的aggs。我见过不少新手在query里写排序和分页或者把聚合写在query外面导致语法报错这是因为没搞清这些参数的分工。一个完整的_search请求顶层结构应该是这样的GET /orders/_search { query: { ... }, from: 0, size: 20, sort: [ ... ], _source: [ ... ], aggs: { ... } }这里的逻辑很简单query负责筛选文档from和size负责取哪几页sort负责排序_source负责控制返回哪些字段aggs负责分组统计。每个参数的职责边界很清晰互不干扰但可以在一次请求里协同工作。实际项目中我通常会用Kibana的Dev Tools来调试这些请求因为它有语法高亮和格式化提示还能看到查询耗费的时间。不过无论用什么工具请求的JSON结构都是一样的。另外提一个很实用的习惯正式写复杂查询之前先只写query部分并加一个size: 0确认返回的hits.total数量符合预期再逐步加上sort、aggs这些参数。这样定位问题会快很多而不是一上来就写一个几百行的JSON出错了都不知道错在哪。2.1 查询结果的结构别只看hits_score里藏了很多信息拿到查询结果之后很多初学者只知道看hits.hits数组里的文档却忽略了另外两个关键信息hits.total和_score。hits.total是符合查询条件的文档总数它本身也是一个对象里面有两个字段value和relation。正常情况relation的值是eq表示总数精确匹配但如果数据量特别大且使用了track_total_hits: falserelation会变成gte表示总数不小于value。我在做大数据量导出功能的时候专门研究过这个如果不关心总数把track_total_hits设为false能让查询快不少因为ES不需要精确统计全部匹配文档了。再来看_score这是ES独有的相关度评分。ES会给每个匹配的文档算一个分数分数越高排在越前面。这个分数不是拍脑袋算的而是基于一套名为BM25的算法算出来的综合考虑了词频词在文档中出现越多次分数越高和逆文档频率词在整个索引中出现得越少这个词越有区分度权重越高。比如你搜手机一个商品标题里出现了三次手机的文档得分一般会高于只出现一次的文档。理解了_score你就理解了为什么全文检索的返回顺序看起来不讲武德。它不是按创建时间排的也不是按价格排的默认就是按相关度排。如果你只想看最新订单就手动指定sort否则ES会一直用_score排序。这一点在做业务查询时特别容易踩坑——好多人查订单列表发现顺序乱了其实就是没建排序条件。2.2 顶层参数的优先级sort、_source、aggs和query怎么配合用一个实际场景把这些顶层参数串起来产品经理要一个已支付订单中商品名称包含手机的订单列表按订单金额降序每一页20条只要订单号和金额两个字段同时还要统计一下这些订单的均价。这个需求一次性就能写完请求长这样GET /orders/_search { query: { bool: { must: [ { match: { product_name: 手机 } } ], filter: [ { term: { status: paid } } ] } }, from: 0, size: 20, sort: [ { amount: { order: desc } } ], _source: [order_id, amount], aggs: { avg_amount: { avg: { field: amount } } } }注意这里我已经用到了bool查询来组合两个条件其中status精确匹配放在了filter里。关于bool和filter的细节下一节专门讲。这里你只需要记住这些顶层参数是平级的ES会先执行query筛出文档再走sort排序再按from/size切页最后根据_source裁剪返回字段同时aggs在筛出的文档集合上做聚合。整个流程是流水线式的任何一个环节的参数缺失结果都可能偏离你的预期。3.match和term的本质区别全文检索与精确匹配背后是分词器在起作用如果说ES查询语法里只能记住两件事我一定选match和term的区别。这是最多的坑也是最基础的知识点。3.1match是分词后匹配term是完整值匹配match查询走的是全文检索逻辑。什么叫全文检索就是ES会先把你要查的输入文本做分词再用分词结果去倒排索引里找。比如你输入match: {product_name: 华为手机}ES会把华为手机这串查询文本交给分词器。以默认的standard分词器为例它会按空格和标点切词华为手机会被切成华为和手机两个词如果是中文场景standard按单个汉字切这里先以通用分词器为例。然后ES拿着这两个词去倒排索引里查找只要文档的product_name字段里包含华为或手机任何一个词就能匹配上并且根据匹配到的词频等算出_score。而term查询走的是精确匹配逻辑它不会分词。term: {status: paid}就是用完整的字符串paid去倒排索引里找一个一模一样的词条。如果字段的mapping类型是keyword这种方式非常高效类似数据库里的等值查询。听起来很简单对不对但问题就出在分词上。ES默认会对字符串类型的字段同时建text和keyword两种类型text类型经过分词器处理用于全文检索keyword类型保留完整字符串用于精确匹配、排序和聚合。如果你在keyword字段上用match查询或者在text字段上用term查询结果往往会出乎意料。3.2 实际案例为什么term查不到text字段的数据有个真实案例特别典型。一个商品索引里有个字段product_namemapping类型是text里面存了Apple iPhone 15 Pro Max。我让同事去查这个商品他写了term: {product_name: Apple iPhone}结果返回0条。他跑来问我说索引坏了。问题不在索引在于term不会分词。term拿着完整的字符串Apple iPhone去倒排索引里匹配词条可倒排索引里存的是分词后的结果比如apple、iphone、15、pro、max这些独立词条根本不存在Apple iPhone这样一个完整词条自然查不到。反过来也一样。如果字段是keyword类型存的是完整字符串Apple iPhone 15 Pro Max你写match: {product_name: Apple iPhone}ES会把Apple iPhone分词成apple和iphone两个词去匹配看起来能查到因为分词后两个词都命中了但这种能查到很容易误导你它实际执行的是包含查询而不是精确匹配。所以最重要的原则是做搜索框的模糊搜索、全文检索用match做状态、分类、ID、精确名称的筛选用term。并且要先通过_mapping接口确认字段是text还是keyword。业务开发里90%的查询结果不对问题追根溯源都是这个。3.3match_phrase要求所有词按顺序出现match还有一个常用变体叫match_phrase它解决的是词序和连续性问题。举个例子你搜match: {product_name: 手机 充电器}一个文档里只有充电器没有手机也能匹配上因为match是或的逻辑。但如果你用match_phraseES要求分词后的所有词必须按顺序且彼此相邻地出现在文档里。match_phrase有一个非常实用的参数叫slop它表示词与词之间允许隔多少个位置。比如文档是华为手机原装充电器你搜match_phrase: {product_name: {query: 手机充电器, slop: 2}}因为手机和充电器中间隔了一个原装slop设为2就能容忍这个间隔查询就能命中。这个参数在做内容搜索、标题匹配时非常常用比如搜索苹果 数据线时希望苹果原装数据线这种写法能被命中slop就派上用场了。4.bool查询把多个条件组合起来别忽略must、should、filter的语义差别真实业务里很少只查一个条件。订单表通常是状态 时间 关键字一起查商品表通常是分类 品牌 价格区间 搜索词一起查。ES提供了bool查询来组合各种子查询这是日常使用最频繁的查询结构没有之一。4.1 三个核心子句的语义must是必须满足should是尽量满足filter是必须满足且不参与评分bool查询里有几个常用子句每个的语义必须记清楚must文档必须满足这里的条件同时参与相关度评分。比如商品名称必须包含手机。filter文档必须满足这里的条件但不参与相关度评分。比如订单状态必须是paid。should文档可以满足也可以不满足但如果满足相关度分数会更高。should单独使用时至少有一个条件被满足文档才会返回但如果在must或filter存在的情况下should变成了加分项不强制满足。must_not文档必须不满足这里的条件。这个好理解就是排除。这四种子句可以随意嵌套组合形成任意复杂的查询逻辑。下面这个例子几乎涵盖了电商后台订单管理的常用筛选场景GET /orders/_search { query: { bool: { must: [ { match: { product_name: 手机 } } ], filter: [ { term: { status: paid } }, { range: { created_at: { gte: 2024-01-01, lte: 2024-12-31 } } } ], must_not: [ { term: { is_refunded: true } } ] } } }这个查询的含义是返回2024年内已支付、未退款、且商品名称包含手机的订单。其中已支付时间范围未退款都是精确过滤条件放在filter和must_not里而手机是搜索词放在must里参与评分。4.2 为什么filter比must快缓存机制不是玄学实际开发中我特别建议把筛选类条件放进filter而不是must。原因有两个第一语义上更准确。must的评分会干扰排序。比如你搜索手机某条订单只匹配了一次手机但它的status恰好是paid。如果你把status放进mustES会在算分时把这个条件的匹配也纳入_score会导致卖了一百单的老客户订单排到某些只匹配了两个词的新订单前面排序逻辑就乱了。放进filter就完全没这个问题它的分数只由must里的搜索条件决定。第二性能上有缓存优势。ES对filter条件有专门的缓存机制同一个filter条件在短时间内重复查询时可以直接复用之前的结果集省去重新扫描倒排索引的消耗。把高频筛选条件比如status: paid、created_at区间放进filter在对高并发查询的优化效果上非常明显。我记得有一个日志查询系统最开始把所有条件都写在must里高峰期查询耗时平均在800ms左右。后来把时间范围、日志级别这些过滤条件全部挪到filter查询耗时降到了200ms以内。这就是filter缓存带来的实际收益不是玄学。4.3should的两种行为模式很多人第一反应是错的should是bool查询里最容易出认知偏差的子句。我特意把这个单独拎出来讲。should的行为取决于bool查询里还有没有其他强制性子句场景一bool里只有should。这时它的语义等同于OR即至少满足一个条件才会返回文档。场景二bool里既有must又有should。这时should退化为加分项不满足should的文档也会返回只是分数比满足的低。举一个业务例子帮助理解。电商搜索里常见的需求是搜索手机希望标题匹配的排在前面同时品牌是华为的也排在前面。这个需求的逻辑应该写成GET /products/_search { query: { bool: { must: [ { match: { product_name: 手机 } } ], should: [ { term: { brand: 华为 } } ] } } }这样写所有标题包含手机的商品都会返回但品牌是华为的商品会在排序上获得额外加分、排到更前面。这种加权效果是should最常见的用法。4.4 用minimum_should_match控制should的最低命中数再往后走一步。当你在一个bool查询里写了多个should条件希望至少命中其中两个文档才返回就要用到minimum_should_match参数。比如说搜索酒店用户希望靠近地铁站或含早餐或免费取消至少满足两个可以这样写GET /hotels/_search { query: { bool: { should: [ { term: { near_subway: true } }, { term: { breakfast_included: true } }, { term: { free_cancel: true } } ], minimum_should_match: 2 } } }这个参数在搜复合标签的场景中很常见。比如文章系统里给文章打标签用户筛选带有Java并发性能优化三个标签的文章要求至少命中两个minimum_should_match: 2直接搞定。它的值可以是数字、百分比甚至是一个复杂的表达式。日常开发用数字就够百分比适合动态控制灵活度的场景。5. 聚合查询当size: 0成为习惯aggs能帮你做报表分析ES最让我惊艳的能力不是搜索而是聚合分析。刚用ES做日志统计的时候我一度以为要先把数据查出来再在代码里慢慢算后来发现一条aggs请求就把报表搞定了性能还高得离谱。5.1 先跑通一个最简单的terms分组统计先看一个最基础的聚合需求统计订单索引里每种状态的订单数量。这个需求在SQL里是SELECT status, COUNT(*) FROM orders GROUP BY status在ES里长这样GET /orders/_search { size: 0, aggs: { group_by_status: { terms: { field: status, size: 10 } } } }返回结果里会多出一个aggregations节点里面有一个名为group_by_status的聚合结果每个桶包含key比如paid和doc_count比如1024。这里有两个细节要注意第一size: 0意味着不返回具体文档只返回聚合结果。如果你只是做统计报表这个参数能省掉大量网络传输开销。很多人在写聚合时忘记加size: 0白白把成千上万条文档从ES拉到应用层再被程序丢掉非常浪费。第二terms聚合的field必须是keyword类型或者开启了fielddata的text类型。ES默认不允许在text字段上直接做聚合因为text字段经过分词后每个词都是一个独立的词条直接聚合出来的是一堆零碎词频几乎没有业务意义。如果你非要在text字段上聚合mapping里需要显式开启fielddata: true但我不建议这样做它会吃掉大量堆内存。正确做法是在mapping里给这个字段额外建一个keyword子字段比如product_name.keyword然后在聚合时用product_name.keyword。5.2avg、sum、max、min指标聚合的基础用法terms属于桶聚合Bucketing分组后每一组是一个桶。除了桶聚合还有指标聚合Metric用来计算值。继续用订单表举例统计已支付订单的平均金额、总金额、最大单笔金额一条请求写完GET /orders/_search { query: { bool: { filter: [ { term: { status: paid } } ] } }, size: 0, aggs: { avg_amount: { avg: { field: amount } }, sum_amount: { sum: { field: amount } }, max_amount: { max: { field: amount } }, min_amount: { min: { field: amount } } } }一次性在aggs里声明多个聚合它们会并行执行互不干扰。这里注意amount字段必须是数值类型如果是字符串就没办法做这种数值计算了。这也是为什么建索引之前一定要设计好mapping字段类型一旦定错后面查询和聚合全都会出问题。聚合还有个大杀器叫子聚合就是在桶聚合的结果里再套一层聚合。比如统计每个商品分类下的平均价格和最高价格GET /products/_search { size: 0, aggs: { by_category: { terms: { field: category.keyword }, aggs: { avg_price: { avg: { field: price } }, max_price: { max: { field: price } } } } } }这样返回的每个分类桶里都会带上avg_price和max_price两个指标。这种先分组再对每组做统计的嵌套结构是ES聚合最强大的地方。它可以把SQL里需要多次GROUP BY才能完成的复杂报表压缩成一条请求。我在做商品运营报表时经常用这类查询从几千万的商品数据里直接拉出各分类的销售统计。5.3 聚合中的filter和range让分组统计更精细有时候你要在聚合之前先过滤一部分数据。举个例子统计2024年每个月份的订单数。这里用到的不是前面介绍的range查询而是聚合里的date_range桶聚合或者更常用、更灵活的filter聚合。先看一个filter聚合的典型用法统计已支付订单的金额和未支付订单的金额。GET /orders/_search { size: 0, aggs: { paid_orders: { filter: { term: { status: paid } }, aggs: { total_amount: { sum: { field: amount } } } }, unpaid_orders: { filter: { term: { status: unpaid } }, aggs: { total_amount: { sum: { field: amount } } } } } }这个查询返回两个桶一个只包含已支付订单并在其中计算总金额另一个只包含未支付订单并计算总金额。filter聚合的作用就是在聚合子层级上做条件过滤不影响整体的query结果集。这种写法在做各种对比报表时非常方便你想按什么维度切分统计就写几个filter聚合桶。另外date_histogram是时间序列统计的神器按天、按月、按年自动分桶。我拿它做系统监控指标如每分钟的请求数、平均响应时间相当顺手。一段典型用法GET /logs/_search { size: 0, aggs: { requests_per_minute: { date_histogram: { field: timestamp, fixed_interval: 1m } } } }它会按分钟把日志切成一堆桶每个桶的doc_count就是这一分钟的请求量。如果再加一个avg子聚合去算每分钟的平均响应时间一个简易的监控仪表盘数据就齐了。6. 查询结果的处理排序、分页、字段裁剪这些细节决定体验很多ES初学者能写出match查询但一到排序怎么不对分页怎么越翻越慢返回字段太多了怎么办这些问题就卡壳。这一节把这些查询之外的细节讲透因为它们是决定一个查询能否真正落地到业务系统的关键。6.1 排序的坑text字段默认不能排序为什么先说一个最常见的报错对product_name排序ES直接抛异常。原因很简单text字段经过分词后存进倒排索引的是一个个独立的词条而不是完整的原始字符串。你可以想象一个字段被拆成了苹果、手机、手机壳这些词按它们排出来的顺序毫无意义。因此ES默认不允许在text字段上排序。解决办法有两个方向对keyword字段排序。如果mapping里已经给product_name建了keyword子字段用product_name.keyword排序即可。如果确实需要按某种自定义规则排序可以单独建一个排名字段维护。排序的JSON写法是这样GET /orders/_search { query: { match_all: {} }, sort: [ { amount: { order: desc } }, { created_at: { order: asc } } ] }多个排序条件按先后顺序生效先按金额降序金额相同的再按创建时间升序。要注意的是一旦指定了sort返回结果里的_score就不再有排序意义因为它默认就退出了排序流程。如果你希望既要相关度排序又要某个字段作为次级排序可以把_score显式写在sort数组里比如{ _score: { order: desc } }。6.2 分页的两种方案from/size适合浅分页search_after适合深分页ES的分页非常简单GET /orders/_search { query: { match_all: {} }, from: 0, size: 20 }from表示跳过多少条size表示返回多少条。第一页是from: 0第二页是from: 20第三页from: 40以此类推。这跟MySQL的LIMIT 20, 20是同一个逻辑。但这里有个巨大的性能陷阱深分页。ES分布式架构决定了它的分页不是单纯跳过多少条那么简单。当请求翻到第100页from: 1980, size: 20时ES实际上需要从每个分片里取出前2000条数据汇聚到协调节点后排序再截取第1980到2000条。这意味着页面越深内存和CPU开销越大。我曾经在数据量过亿的索引上翻到第50页单次查询耗时从30ms飙升到了4秒多导致线上接口直接超时。结论是from/size只适合深度在1万以内的分页。如果用户需要翻得很深或者要支持无限滚动比如App下拉加载正确方案是search_after。它的思路是用上一页最后一条文档的排序字段值作为下一页的起点。实现方式是拿到上一页最后一条数据的排序值在下一页请求里把这个值传给search_afterGET /orders/_search { query: { match_all: {} }, sort: [ { order_id: desc } ], size: 20, search_after: [1092837465] }这里order_id是唯一且有序的字段。下一页请求时search_after填的是上一页最后一条的order_id值。ES会直接从那条数据之后开始取不会再从头扫描。它的代价是不能跳页只能一页一页往下翻。但绝大多数加载更多场景恰恰不需要跳页所以search_after是生产环境里的主流方案。6.3_source字段裁剪控制返回字段减少网络开销当查询结果只需要几个字段时用_source裁剪返回内容能显著减少网络传输和内存占用。GET /orders/_search { query: { match_all: {} }, _source: [order_id, amount, status] }这条查询只返回订单号、金额、状态三个字段其他字段一概不传。这在API网关、移动端接口等带宽敏感的场景下很有用。还可以用通配符比如account_*返回所有以account_开头的字段。不过要注意_source只影响返回结果不影响查询匹配逻辑。就算你不返回某个字段它依然可以被查询和排序使用。7. 模糊与通配查询wildcard、regexp、query_string这些高级查询用的时候要想清楚总有人问我ES能不能像数据库里的LIKE %手机%一样模糊查答案是可以但代价要想清楚。ES里有wildcard、regexp、fuzzy、query_string等一堆看起来很方便的查询用不好就是性能杀手。7.1wildcard通配符查询能做模糊匹配但别在超大数据集上裸用wildcard查询支持*匹配任意字符序列和?匹配单个字符比如GET /products/_search { query: { wildcard: { product_name.keyword: { value: 苹果* } } } }这个查询会匹配所有product_name以苹果开头的文档类似SQL里的LIKE 苹果%。听起来很方便对吧但它有个严重问题如果value以*或?开头比如*手机*ES无法利用倒排索引快速定位只能全表扫描。想象一下你在一个几千万条的索引里做这种查询每一个文档都要被遍历一遍性能灾难几乎是注定的。我在日志系统里排查过一次线上故障某条业务查询就是写了value: *error*直接把ES节点的CPU打满了。如果必须做包含式的模糊匹配业界通用的做法是引入ngram分词器把手机拆成手、手机、机这样的n-gram词条存入索引然后用match查询去命中。这样倒排索引还能派上用场性能比wildcard高几个数量级。但ngram会增加索引体积属于典型用空间换时间的方案需要跟产品确认是否值得。7.2regexp正则查询灵活但更危险regexp允许使用正则表达式匹配字段值比wildcard更强大。比如查所有型号包含iPhone 1且后缀是一个数字的商品GET /products/_search { query: { regexp: { product_name.keyword: iPhone 1[0-9] } } }但regexp同样面临正则性能问题尤其当正则表达式包含复杂的回溯匹配时CPU消耗非常夸张。我的原则是生产环境尽量不用。如果需要正则优先在查询前先在应用层把候选集缩小再对缩小后的数据做正则过滤。其实很多业务场景里的正则需求用prefixrange组合就能覆盖大半。7.3query_string一个字符串写出复杂查询但别直接暴露给用户query_string是一个非常特殊的查询它允许用类似Lucene查询语法的字符串来表达复杂逻辑。比如GET /products/_search { query: { query_string: { query: product_name:手机 AND price:[1000 TO 5000] } } }这条查询等价于商品名称包含手机且价格在1000到5000之间。query_string非常强大支持AND、OR、NOT、通配符、正则、范围等多种语法但它也是安全风险最高的查询。如果你把用户输入的搜索词直接拼进query_string用户只要输入一个*或者精心构造的特殊字符就可能把你整个索引的内容拉出来甚至导致查询超时。我在接第三方系统时见过他们把前端搜索框的输入直接透传到query_string结果一个空字符串查询就把集群打挂了。所以我的建议是query_string可以在Kibana调试时用但产品化接口里尽量用显式的boolmatchterm组合不要直接把用户输入喂给query_string。如果你非要用至少加一层字符白名单过滤去掉*、?、/等特殊字符并让输入超时自动中断。7.4prefix前缀查询适合输入提示和自动补全的轻量场景prefix用来查某个字段以指定前缀开头的文档比如搜索框自动补全用户输入苹下拉框展示苹果苹果手机苹果数据线GET /products/_search { query: { prefix: { product_name.keyword: 苹 } } }prefix比wildcard效率高一些因为它能利用倒排索引中的词项顺序快速定位到前缀位置但也别指望它能媲美match。真正大规模的自动补全场景我一般会用completionsuggest这种专门设计的功能或者直接走ngrammatch方案。prefix最多只能算小规模数据时的轻量方案。8. 一段完整的实战代码把前面的语法串起来做商品筛选前面每块语法都是拆开讲的最后我来演示一个真实业务场景把match、bool、sort、aggs、_source全部串起来。假设要做一个电商后台的商品筛选页面筛选条件有搜索关键词手机、品牌是华为或小米、价格在1000到5000元、库存大于0按价格升序排列每页20条同时统计结果总数和价格区间分布。GET /products/_search { query: { bool: { must: [ { match: { product_name: 手机 } } ], filter: [ { terms: { brand.keyword: [华为, 小米] } }, { range: { price: { gte: 1000, lte: 5000 } } }, { range: { stock: { gt: 0 } } } ] } }, from: 0, size: 20, sort: [ { price: { order: asc } } ], _source: [product_id, product_name, brand, price, stock], aggs: { price_ranges: { range: { field: price, ranges: [ { to: 2000 }, { from: 2000, to: 3500 }, { from: 3500 } ] } } } }这个请求的意图很清楚must里的match负责手机的相关度筛选filter里的terms、range负责品牌、价格、库存的精确条件过滤sort按价格排序_source裁剪返回字段aggs算了三个价格区间的商品数量方便前端展示分布直方图。你可能会想为什么不用should来实现华为或小米因为should是尽量满足的语义放这里会导致品牌不是华为也不是小米的文档也有可能返回。正确写法是用terms查询它专门用于字段值属于某个集合的场景语义精确性能也好。terms是term的多值版只要是精确匹配、多个值取其中一个就优先用它。我还想强调一点写这种组合查询时建议先在Dev Tools里把query部分单独跑一遍确认返回的total数正确再逐步加上sort、aggs。如果最终的结果和预期不一致回查时先确认字段类型、再确认分词行为基本能把问题范围缩小到很小的区域。9. 版本差异和踩坑记录ES 5.x、7.x到8.x的API变化以及几个我至今记得的教训ES版本演进很快不同版本之间查询语法有差异。我最早用的是ES 5.x后来升级到7.x再到现在项目基本都在8.x。这里分享几个真实的版本变化和踩坑记录希望能帮你少走弯路。9.1 从_type消失到7.x的兼容性变化ES 5.x时代索引下还能分_type查询时要带上type。到6.x开始_type被弱化同一索引默认只能有一个type7.x直接移除了_type。如果你在网上翻到老教程写了GET /orders/orders/_search这种带type的路径在新版本里直接会报错。正确写法是GET /orders/_search。我在一次接第三方数据同步项目时对方的代码还是5.x风格到处是type路径升级后全部要改费了很大劲。现在做新项目直接上8.x查询路径一律不带type。9.2size: 0与track_total_hits的控制7.x开始ES出于性能考虑默认在size: 0的聚合查询中只精确返回不超过10000条的总数。如果你查询的数据量超过1万hits.total.value返回的可能是10000但relation字段会变成gte表示真实总数不低于10000。这在做分页和报表统计时要特别注意。如果业务上必须获得精确总数在请求里加上track_total_hits: true这样ES会完整统计匹配总数但代价是查询变慢。如果你的场景根本不关心总数比如无限滚动加载可以显式设置track_total_hits: falseES就不算总数了性能还能再快一点。我做日志查询时用户根本不看共多少条这时候关掉总数统计查询响应速度会有明显提升。9.3keyword字段的normalizer精确匹配前的整理再分享一个很多人不知道的小细节。keyword字段默认区分大小写存进去是Apple用term: {brand.keyword: apple}就查不到。如果你的业务希望查询时忽略大小写可以在mapping里给keyword字段配置normalizer{ mappings: { properties: { brand: { type: keyword, normalizer: lowercase_normalizer } } }, settings: { analysis: { normalizer: { lowercase_normalizer: { type: custom, filter: [lowercase] } } } } }配置了normalizer后ES在索引时和查询时都会先把值转成小写再比较term: {brand: apple}就能匹配到Apple了。这个细节在做用户输入容错、品牌名归一化时特别有用。9.4 三个让我记忆深刻的查询性能事故第一起事故是生产环境一个商品搜索接口用户输入一个没有加引号的搜索词走了match查询结果返回了十几万条数据接口超时。后来发现是搜索词太短只有机一个字分词后匹配到了大量文档。match查询天然是包含即匹配对于通用词、短词要额外考虑结果集大小和评分排序。第二起事故是日志系统里有人在filter里写了一个正则匹配整个日志文本字段ES节点CPU直接100%。排查了很久才发现是正则回溯导致的计算爆炸。凡是涉及正则的查询都要做性能测试并且严格控制输入长度。第三起事故是一个数据同步任务明明索引里更新了数据查询结果却还是旧的。排查到最后发现是ES的近实时性问题ES默认refresh_interval是1秒写入的数据要等下一次refresh才能被搜索到。如果你的业务要写入后立即能查到可以调小refresh_interval但代价是写入性能下降。这个平衡要根据业务场景自己拿捏。10. 从基础语法到工程化方案给刚入门ES的开发者几条我亲身验证过的路线前面语法讲了不少最后想从工程实践角度给刚入门ES查询的开发几条我踩过坑之后验证过的建议。第一建mapping之前先想清楚每一个字段的类型。这是ES项目里最重要、也最容易忽略的一步。你决定product_name用text还是keyword直接决定了后面能不能排序、能不能聚合、模糊查询怎么走。如果mapping建错了最好的办法是在数据量还小的时候重建索引拖到几千万条数据再改就非常被动了。第二写任何查询之前先跑一个_mapping看看字段类型。这只需要几秒钟但能避免你写出一堆看起来对但实际查不到的查询。我发现很多ES问题不是语法不会写而是对字段类型和分词行为不了解。term查text字段查不到这个问题我至少帮同事排查过几十次。第三生产环境避免使用query_string、wildcard、regexp这类高自由度查询。不是它们不能用而是用它们之前必须做充分的性能评估和输入约束。搜索框的输入直接透传到这类查询里是导致ES集群被打挂的常见原因。能用bool加match/term表达的需求就不要去碰看起来很优雅的字符串语法。第四学会用Kibana的Dev Tools和_search的profileAPI。profile能告诉你一次查询的每一个子句花了多少毫秒数、命中了多少文档这对性能定位帮助巨大。我以前排查慢查询靠经验猜半天后来学会了看profile输出问题原因一眼就能看出来。这个工具的价值比很多付费的ES监控插件都高。第五把常见的查询封装成模板或者工具方法。比如我平时会封装一个BoolQueryBuilder的构建器把must、filter、should、sort、分页这些参数通过Java或Python的API传进去返回一个完整的请求体。这样业务同学只需要关心业务参数不用每次手写JSON也不容易出错。ES官方客户端Java、Python、Go都提供了对应的Builder模式建议优先使用而不是自己手工拼JSON字符串。ES的查询语法体系很庞大但这篇文章覆盖的内容已经足够支撑你完成日常95%的业务查询需求。剩下那5%等你真的遇到了再带着具体问题去查官方文档效果会比一开始就啃文档好得多。实际操作中多跑几次Dev Tools、多看一眼返回里的_score和took字段你对ES查询的理解会越来越扎实。