ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Elasticsearch查询与聚合实战:从match/term到bool组合与Java实现

Elasticsearch查询与聚合实战:从match/term到bool组合与Java实现 1. 为什么Day8非得啃DSL查询和聚合做微服务开发Elasticsearch基本是绕不过去的一环。前面Day7我们把ES装好、把商品数据导进去了很多人到这一步就觉得万事大吉——索引建好了数据进库了后面不就是调用接口的事吗真不是。等业务方把需求甩过来你就会发现麻烦才刚刚开始。我遇到过一个典型的场景运营要做一个商品搜索页要求支持关键词搜索、品牌过滤、价格区间筛选同时每个品牌下面要显示对应的商品数量还要按品牌统计平均售价。这些需求听着不复杂但落到ES上就是两件事DSL查询和聚合分析。查询负责把符合条件的商品捞出来聚合负责把捞出来的数据按维度算一遍。这一篇我们就围绕这个场景把DSL查询和聚合从原理到实战整个过一遍。你会学到match和term到底什么区别、bool查询怎么编排多个条件、terms聚合和avg聚合怎么组合、以及Java代码里怎么把DSL串起来跑通。不管是后端开发要接ES做搜索还是数据分析要搞统计报表这篇都能直接给你能抄的作业。2. 动手前的准备工作索引设计与测试数据2.1 商品索引的mapping到底该怎么建在写任何查询之前先得保证索引结构是合理的。很多人在这一步图省事直接让ES自动映射后面查询的时候就开始踩坑了。最常见的坑就是明明想精确匹配品牌结果查出来一堆不相关的东西——因为字符串字段被默认映射成了text类型自动做了分词。我习惯的做法是手动指定mapping尤其是参与过滤、排序、聚合的字段一定要是keyword类型。下面是这个商品索引我用的定义PUT /products { mappings: { properties: { title: { type: text, analyzer: ik_max_word }, brand: { type: keyword }, category: { type: keyword }, price: { type: double }, stock: { type: integer }, sales: { type: integer }, onSale: { type: boolean }, createdAt: { type: date, format: yyyy-MM-dd HH:mm:ss } } } }这里有个关键点title用text类型并且指定了IK分词器因为它要做全文检索brand、category用keyword因为它们要做精确过滤和分组聚合price、sales这种数值字段后面要算平均值、求和用double和integer即可。关于IK分词器多说一句。如果不指定分词器ES默认的standard分词器对中文基本是逐字切或者按空格切搜智能手机这种词根本匹配不到智能和手机组合的文档。插件装完之后在mapping里显式指定analyzer为ik_max_word或者ik_smart就行。ik_max_word是最大粒度分词能分出尽可能多的词——适合搜索场景ik_smart是最细粒度切分词的个数少但更精准——适合某些标签场景。搜索业务我基本都用ik_max_word。2.2 批量导入测试数据Mapping建好之后要灌测试数据。一条一条用PUT提交太慢了推荐用_bulk批量接口。我准备了几条覆盖不同品牌、不同价格区间、不同分类的测试数据方便后面演示查询和聚合效果。POST /products/_bulk {index: {_id: 1}} {title: 小米13 Pro 智能手机 12GB256GB, brand: 小米, category: 手机, price: 4999, stock: 120, sales: 3200, onSale: true, createdAt: 2024-01-15 10:30:00} {index: {_id: 2}} {title: 华为Mate 60 Pro 手机 12GB512GB, brand: 华为, category: 手机, price: 6999, stock: 80, sales: 5100, onSale: true, createdAt: 2024-01-20 14:20:00} {index: {_id: 3}} {title: Apple iPhone 15 Pro 全网通手机, brand: 苹果, category: 手机, price: 7999, stock: 150, sales: 8900, onSale: true, createdAt: 2024-02-01 09:00:00} {index: {_id: 4}} {title: 联想拯救者Y9000P游戏笔记本 i9 32G 1T, brand: 联想, category: 笔记本, price: 10999, stock: 60, sales: 2100, onSale: true, createdAt: 2024-02-10 16:45:00} {index: {_id: 5}} {title: 华为MateBook X Pro 轻薄办公本, brand: 华为, category: 笔记本, price: 5999, stock: 95, sales: 1800, onSale: true, createdAt: 2024-02-15 11:10:00} {index: {_id: 6}} {title: Apple MacBook Air 13英寸 M3芯片, brand: 苹果, category: 笔记本, price: 8999, stock: 40, sales: 3500, onSale: false, createdAt: 2024-03-01 08:30:00} {index: {_id: 7}} {title: 小米电视S Pro 65英寸 MiniLED 4K液晶, brand: 小米, category: 电视, price: 4599, stock: 200, sales: 1200, onSale: true, createdAt: 2024-03-05 13:40:00} {index: {_id: 8}} {title: 海信电视E7N 75英寸 4K超清智能, brand: 海信, category: 电视, price: 4599, stock: 110, sales: 980, onSale: true, createdAt: 2024-03-08 19:25:00} {index: {_id: 9}} {title: 美的变频空调1.5匹 新风空调 KFR-35GW, brand: 美的, category: 家电, price: 2799, stock: 300, sales: 4500, onSale: true, createdAt: 2024-03-10 10:05:00} {index: {_id: 10}} {title: 格力云佳空调1.5匹 新一级能效, brand: 格力, category: 家电, price: 2899, stock: 260, sales: 3800, onSale: false, createdAt: 2024-03-12 15:15:00}灌完数据后可以用POST /products/_count确认一下有10条文档。实际开发中测试数据量越大越好但演示学习的话10条足够覆盖各种查询和聚合的组合效果了。3. DSL查询从简单match到复合bool查询3.1 先搞清楚两个容易混淆的查询match和termDSLDomain Specific Language是ES自己的一套JSON查询语法。所有查询最终都会被ES翻译成Lucene的查询语句去执行。刚开始学DSL最容易搞混的就是match和term。match是全文检索查询会先对搜索词做分词再用分词结果去匹配倒排索引。比如搜智能手机它会把智能手机拆成智能手机等词然后去匹配那些包含这些词的文档。term是精确查询不会分词直接把整个搜索词拿去匹配索引里的精确值。所以term几乎总是跟keyword类型的字段搭配使用。举两个对照例子GET /products/_search { query: { match: { title: 智能手机 } } }GET /products/_search { query: { term: { brand: 小米 } } }第一个会把所有title中包含智能或手机相关分词的商品都找出来比如小米13 Pro 智能手机也可能匹配到小米电视S Pro——只要它的分词里有相关词。第二个只会返回brand字段精确等于小米的文档也就是小米手机和电视两条。3.2 多条件组合就靠bool查询真实的搜索场景几乎不会只有一个条件。比如运营要求搜索手机品牌限定华为和苹果价格区间在5000到8000之间还必须是在售状态。这种多条件组合用bool查询来实现。bool查询有四种子句理解这四种子句就能编排任意逻辑子句类型作用类似关系型数据库must必须满足参与相关性评分ANDfilter必须满足但不参与评分可缓存AND无评分开销should满足其一即可至少满足数为最小值ORmust_not必须不满足NOT我把上面的业务需求翻译成DSLGET /products/_search { query: { bool: { must: [ { match: { title: 手机 } } ], filter: [ { terms: { brand: [华为, 苹果] } }, { range: { price: { gte: 5000, lte: 8000 } } }, { term: { onSale: true } } ] } } }这里有个细节值得展开说filter和must都能做过滤但filter不计算相关度分数性能开销更低而且ES会缓存filter子句的执行结果。所以凡是纯粹的过滤条件——品牌归属、价格区间、上下架状态——一律丢进filter里。只有像关键词搜索这种需要按相关度排序的才放must里用match。这是一个非常实用的性能习惯。实操中我还经常遇到多值匹配的需求比如品牌不止限定华为苹果可能有七八个。这时候用term就要写七八个太啰嗦。改用terms一个字段传数组就行terms: { brand: [华为, 苹果, 小米] }。3.3 排序、分页和返回字段控制查询条件写好了接下来要控制结果展示。运营同学可能要求搜索结果按销量降序分页显示只要部分字段。GET /products/_search { query: { bool: { must: [ { match: { title: 手机 } } ], filter: [ { term: { onSale: true } } ] } }, sort: [ { sales: { order: desc } } ], from: 0, size: 5, _source: [title, brand, price, sales] }from和size就是分页的偏移量和页面大小跟MySQL的limit有点像。_source控制返回字段避免把stock、createdAt这些用不上的字段全捞回来。这个习惯在数据量大、字段多的索引里能明显降低网络传输和序列化开销。我见过不少刚接触ES的同学分页直接照搬MySQL的思路from拉得巨大。ES默认的max_result_window是10000超过这个范围查询会直接报错。这是因为深分页在分布式环境下代价极高——ES需要把每个分片上的结果先各自排序再汇总到协调节点合并排序from越大丢弃的数据越多。后面我会单独讲怎么应对深分页。4. 聚合分析从分组统计到多维度下钻4.1 聚合的三种类型bucket、metric、pipeline如果说查询是从文档里筛选数据聚合则是从文档里计算数据。ES的聚合分三类我习惯用一个生活化的类比来理解你有一堆商品卡片。bucket聚合相当于把卡片按某个维度分桶。比如按品牌分桶每个品牌一个桶把属于它的商品卡片放进去。metric聚合相当于对某个桶里的卡片做统计计算。比如算每个桶里卡片的平均价格、最高价格、总库存。pipeline聚合相当于基于其他聚合的结果再做聚合。比如先按品牌分桶算出平均价再从这些平均价里求一个总的平均值。实际开发中前两种组合使用最多pipeline在复杂的嵌套报表中才会用到。下面我们重点把bucket和metric的组合吃透。4.2 一次典型的组合聚合查询运营要的报表很明确统计每个品牌的商品数量、平均价格、总销量。这就是典型的terms聚合按品牌分桶加metric聚合算avg、sum。GET /products/_search { size: 0, aggs: { group_by_brand: { terms: { field: brand, size: 10 }, aggs: { avg_price: { avg: { field: price } }, total_sales: { sum: { field: sales } } } } } }size置0表示不返回文档内容只返回聚合结果。group_by_brand是聚合的自定义名称随便起但要见名知义。terms里的size是分桶数量上限不是分页大小——这个参数经常被人误读理解为每个桶里装多少商品其实是最多返回多少个桶。4.3 嵌套聚合与全局过滤的配合实际业务里报表很少是全量数据的统计。比如运营想看在售商品中每个品牌的平均价格这就需要query和aggs配合使用。query先过滤出在售商品aggs再对过滤结果做分桶统计。GET /products/_search { size: 0, query: { term: { onSale: true } }, aggs: { group_by_brand: { terms: { field: brand }, aggs: { avg_price: { avg: { field: price } } } } } }还有一个容易踩坑的场景我想知道华为品牌下手机和笔记本的平均价格分别多少。这里需要先限定品牌还要按category再次分桶。光靠之前的写法不够因为terms聚合默认只能看到全局数据。需要加一个filter聚合先把华为的文档过滤出来再在这个结果上分桶。GET /products/_search { size: 0, aggs: { huawei_avg_price: { filter: { term: { brand: 华为 } }, aggs: { group_by_category: { terms: { field: category }, aggs: { avg_price: { avg: { field: price } } } } } } } }返回结果里会先进入huawei_avg_price这个filter桶里面再按category分桶。华为的手机、笔记本各自的平均价格就都能拿到了。这种先过滤再下钻的写法在日常统计需求里非常常见。5. 查询加聚合组合实战完整的商品搜索统计场景5.1 需求拆解与DSL整体设计前面把查询和聚合分开讲了但真实需求永远是两者揉在一起的。我拿一个完整的业务场景来串一遍搜索页需求如下搜索关键词手机品牌限定华为、苹果、小米价格区间4000~9000只要在售商品结果列表按销量倒序分页第一页返回5条同时统计满足条件的商品在每个品牌下的数量、平均价格这个需求翻译成DSL是query部分做搜索过滤aggs部分做分组统计两个部分共享同一套过滤条件。一次性请求就能拿到全部结果不需要查两次再拼数据。GET /products/_search { query: { bool: { must: [ { match: { title: 手机 } } ], filter: [ { terms: { brand: [华为, 苹果, 小米] } }, { range: { price: { gte: 4000, lte: 9000 } } }, { term: { onSale: true } } ] } }, sort: [ { sales: { order: desc } } ], from: 0, size: 5, aggs: { brand_stats: { terms: { field: brand }, aggs: { avg_price: { avg: { field: price } }, total_sales: { sum: { field: sales } } } } } }我把这个请求在Kibana的Dev Tools里跑了一下。查询命中3条文档——小米13 Pro、华为Mate 60 Pro、苹果iPhone 15 Pro前两部价格都在筛选范围内苹果的7999也在范围内。注意小米电视是4599但title里有电视分词可能被match中手机吗实际上手机这个词不会分错它只会匹配确实包含手机或相关分词的文档。电视那条不会被捞出来因为match手机匹配的是包含手机分词的结果。聚合部分按品牌分桶后得到三个桶小米、华为、苹果各自的avg_price和total_sales也都算出来了。前端拿到这份返回体既可以把hits部分渲染成商品列表又可以把aggregations部分渲染成品牌统计侧边栏一次请求全搞定。5.2 Java代码里怎么把DSL串起来Kibana里验证完DSL接下来就是Java开发的重头戏。我项目里用的是Spring Cloud微服务架构ES这块通过spring-boot-starter-data-elasticsearch集成。构造查询用ElasticsearchClient的Java API核心逻辑跟DSL几乎一一对应。下面是一段核心代码实现上面那个组合查询Service public class ProductSearchService { Autowired private ElasticsearchClient client; public SearchResponseMap searchProducts(String keyword, ListString brands, double minPrice, double maxPrice) throws IOException { BoolQuery.Builder boolQuery new BoolQuery.Builder(); // 关键词搜索进must参与评分 if (StringUtils.hasText(keyword)) { boolQuery.must(MatchQuery.of(m - m .field(title) .query(keyword) )._toQuery()); } // 品牌过滤进filter不参与评分 if (!brands.isEmpty()) { boolQuery.filter(TermsQuery.of(t - t .field(brand) .terms(TermsQueryField.of(f - f.value(brands.stream() .map(FieldValue::of) .toList()))) )._toQuery()); } // 价格区间过滤 boolQuery.filter(RangeQuery.of(r - r .field(price) .gte(JsonData.of(minPrice)) .lte(JsonData.of(maxPrice)) )._toQuery()); // 在售状态过滤 boolQuery.filter(TermQuery.of(t - t .field(onSale) .value(true) )._toQuery()); // 组装查询、排序、聚合 SearchRequest request new SearchRequest.Builder() .index(products) .query(boolQuery.build()._toQuery()) .sort(SortOptions.of(s - s.field(f - f.field(sales).order(SortOrder.Desc)))) .from(0) .size(5) .aggregations(brand_stats, Aggregation.of(a - a .terms(TermsAggregation.of(t - t.field(brand))) .aggregations(avg_price, agg - agg.avg(avg - avg.field(price))) .aggregations(total_sales, agg - agg.sum(sum - sum.field(sales))) )) .build(); return client.search(request, Map.class); } }这段代码里有两个容易出错的点。第一terms字段要传FieldValue列表不能直接传字符串列表需要通过FieldValue.of逐个包装。第二聚合的嵌套写法外层的terms和内部的两个metric聚合要在同一个Aggregation.BuilderContainers里级联声明。我刚开始用这个客户端时总把terms查询和terms聚合搞混命名一个叫TermsQuery一个叫TermsAggregation看官方文档时容易绕晕。其实就是查询和聚合两条线写多了自然就顺了。另外如果项目还在用老的RestHighLevelClient核心逻辑也是一致的只是API类名不一样DSL部分是共通的。6. 踩坑实录与性能优化笔记6.1 深分页问题的三种解法之前提到from拉大会报错。我项目里有过真实案例运营要求商品列表能翻到第500页每页20条。我直接写了from9980, size20结果ES返回错误——max_result_window exceeded。这就是深分页的典型坑。解决深分页通常有三种思路search_after基于上一页最后一条结果的排序值来翻页。适合上一页/下一页这种场景不依赖from性能稳定。但没法直接跳到任意页。scroll一次性生成快照适合导出全量数据。注意scroll上下文会占用内存用完关闭。改用其他存储超过万级的深度翻页本身就不适合用ES做可以考虑把结果转存或设计更精准的过滤条件。我在项目里给前端加的是search_after方案。第一次请求不带search_after返回结果最后一条的sort值比如销量记录下来下一页把那个值塞进search_after里。实现也不难GET /products/_search { query: { match_all: {} }, sort: [ { sales: desc }, { _id: asc } ], size: 20, search_after: [8900, 3] }注意一点search_after必须配合一个唯一值做第二排序我用的_id。理论上不一定非得_id但必须保证排序字段组合唯一否则翻页可能出现数据重复或遗漏。6.2 text与keyword的误用聚合结果跟想的不一样聚合结果不准十有八九是字段类型用错了。我遇到过这么个问题按分类统计商品数量结果出来一堆奇怪的分桶——什么手机智能苹果华为这些词全都能单独成桶。我一看索引映射category字段被自动映射成了text类型分过词了聚合按分词结果分桶当然跟预想完全不一样。解决方案就是一个参与terms聚合、排序、精确条件过滤的字段必须用keyword类型。如果索引已经建好了修改映射需要重建索引或者加keyword子字段。ES支持字段多类型可以在text字段下挂一个keyword子字段{ mappings: { properties: { category: { type: text, fields: { keyword: { type: keyword, ignore_above: 256 } } } } } }这样聚合和精确查询可以用category.keyword全文检索用category。这种双字段设计在我实际项目中非常常见既能支持模糊搜索又能精确聚合。6.3 聚合精度与性能的平衡terms聚合在数据量大时默认只取分片前几个桶合并时有可能会丢桶。这个特性很多人不知道。ES为了性能在协调节点合并各分片结果时如果桶数量超过了size无法保证全局精确。要拿到全局精确的统计可以设置shard_size为一个足够大的值或者用sum_other_doc_count来观察有多少文档没进桶。我一般情况下会手动把shard_size调成size的1.5~2倍在内存和精度之间取个平衡。6.4 filter缓存带来的意外惊喜filter子句会被ES自动缓存这在大多数时候是好事但有个坑第一次查询时缓存未命中响应时间可能几百毫秒第二次相同条件查询时走了缓存直接降到十几毫秒。我调试的时候差点以为ES出bug了——同一条件两次查询性能差距接近一个数量级。理解了这个机制后官方建议把高频的、相对固定的过滤条件尽量设计进filter里充分利用缓存。但缓存也不是越大越好每GB堆内存默认的缓存上限是10%数据量特别大时需要注意观察JVM内存占用。6.5 结合实际业务的一段最终建议如果你之前没用过ES的查询和聚合我建议你按我的这个顺序去练先把mapping建清楚确定哪些字段要keyword哪些要text然后从Kibana的Dev Tools开始不要直接跳到Java代码先在那儿把DSL调到满意为止确认DSL正确了再翻译成Java调用。这样能把排查问题的范围限制在某一层不会两边一起出问题。我在多个微服务项目里都沿用这个流程。每到一个新团队我都会按这套思路把ES查询和聚合的规范立起来。另外一个很实用的建议是多关注Kibana的监控页面查询响应时间、聚合内存占用、分片健康状况这些东西在数据量上来之后会成为微服务性能的暗雷早点看到早点优化。
返回列表