Day8了,前面几天我们把服务拆分、Nacos注册发现、OpenFeign调用、Gateway网关、Seata分布式事务、Redis缓存这些环环都过了一遍,今天进入 Elasticsearch 的硬核环节。微服务里的搜索需求如果还停留在 MySQL 的 LIKE 查询,那基本是把自己逼上绝路:一旦数据量上了百万,多条件组合查询的耗时和数据库压力根本扛不住。所以这两天我集中把 Elasticsearch 的索引设计、DSL查询、聚合统计完整走了一遍。
这篇内容主要聚焦两件事:DSL 查询怎么写、聚合怎么用,以及它们怎么在 SpringCloud 微服务项目里真正落地。适合正在学微服务的 Java 开发,尤其是准备做商品搜索、文章检索、日志统计这类场景的同学。我会把查询和聚合的 DSL 语法、Java API 调用、实际踩坑一次性讲完,尽量让你看完就能在项目里动手开干。
1. 微服务架构下,搜索与聚合任务为什么必须交给 ES
1.1 数据库 LIKE 和 ES 倒排索引的根本差异
先聊一个核心问题:为什么微服务里要做搜索,大家最终都会落到 Elasticsearch,而不是继续用 MySQL?
我见过很多项目初期拿 MySQL 硬扛搜索,SQL 大概长这样:
SELECT * FROM product WHERE name LIKE '%手机%' AND category_id = 1001 AND price BETWEEN 1000 AND 5000 ORDER BY price ASC这个 SQL 看着也不复杂,但你要注意,%手机%这种写法是没法走索引的,它必须全表扫描。一旦表里是几百万行,再叠加多个过滤条件、分页、排序,数据库的连接池很快就满了。更麻烦的是,如果用户还要做全文检索、相关性排序、统计每个分类下有多少商品,这类需求用 SQL 写起来会非常痛苦,还会拖垮主库。
Elasticsearch 的核心是倒排索引。打个比方,一本书的目录是正排索引,告诉我们从哪一页能读到什么内容;而倒排索引就像书末尾的关键词索引,每个词后面写了它出现在哪些页码。你去搜“手机”这个关键词,ES 不需要一页一页翻书,而是直接找到倒排索引里“手机”对应的文档列表,立刻返回结果。这个机制决定了它在全文检索、多维过滤、聚合统计上的性能优势。
微服务场景里还有一个隐藏的痛点:数据库通常是按业务拆分的,用户查询搜索时可能要组合商品、品牌、库存、评价多个服务的数据。在数据库里做跨库 JOIN 基本是噩梦,而 ES 可以把这些数据冗余到一份索引里,查询时一次到位。
1.2 搜索服务如何融入到 SpringCloud 体系中
放到微服务架构里看,ES 一般不会让所有服务直接连。更合理的做法是单独拆出一个 search-service(搜索服务),统一封装索引操作和查询逻辑。外部请求先进网关,再由业务服务调用 search-service,或者前端直接走聚合接口。
那搜素服务里的数据从哪里来?商品数据原始存储在商品服务的 MySQL 里,ES 只是数据的消费方,两者之间必然要同步。我这次用的是消息队列异步同步:商品服务在更新数据后发一条 MQ 消息,搜索服务监听消息,再对 ES 文档做增量更新。为什么不直接双写数据库和 ES?因为双写很难保证一致性,一个写成功一个写失败,数据就乱了。通过 MQ 异步通知,至少可以借助消息重试机制保证最终一致性,同时不影响主业务链路的响应时间。
索引设计上,ES 里的文档要按查询字段冗余设计。比如商品索引里除了商品基本信息,还要把品牌名、分类路径、库存状态、销量这些查询需要用到的字段都放进去,别想着查询时再去关联别的服务拿数据。ES 适合做查询和统计,但它不是业务数据库,数据一致性最终还是要靠源头系统来保证。
2. 从零写一版商品搜索 DSL 查询
2.1 DSL 的基本结构与 bool 查询组合
DSL 是 Elasticsearch 自带的查询语言,说白了就是一段 JSON。刚开始接触会觉得它不如 SQL 直观,但用顺手以后你会发现,JSON 这种嵌套结构反而能非常自然地表达复杂查询逻辑。
一个完整的查询请求,顶层通常带这些参数:
query:查询条件,核心部分sort:排序规则from/size:分页_source:返回哪些字段,默认返回全部highlight:高亮aggs:聚合统计
一个最基础的全文检索查询长这样:
GET /product/_search { "query": { "match": { "name": "手机" } } }match会对搜索词做分词,再拿着分词结果去倒排索引里匹配,这是全文检索最常用的查询。但如果我现在想同时满足多个条件,就需要多用bool查询组合。比如“搜索商品名称中带手机的词,同时要求分类ID必须等于1001”,就不能只靠一个match了,得用这种方式:
GET /product/_search { "query": { "bool": { "must": [ { "match": { "name": "手机" } } ], "filter": [ { "term": { "categoryId": 1001 } } ] } } }这里must代表必须匹配,会参与相关性打分;filter代表过滤条件,必须满足但不参与打分。这个设计差异很值钱:过滤条件用filter而不是must,因为 ES 对 filter 的结果有缓存机制,性能远高于每次都计算相关性分数的 must。实际项目中,分类过滤、品牌过滤、价格区间这些条件都应该放在 filter 里。
2.2 关键词、精确过滤、范围筛选与分页排序的完整拼装
接下来我给一个完整的商品搜索 DSL。假设业务需求是:用户输入“手机”关键词搜索,同时选择分类和品牌,价格范围限定在 1000 到 5000,按价格升序,分页取第 3 页,每页 20 条,并返回高亮片段。
GET /product/_search { "query": { "bool": { "must": [ { "match": { "name": "手机" } } ], "filter": [ { "term": { "categoryId": 1001 } }, { "term": { "brandId": 502 } }, { "range": { "price": { "gte": 1000, "lte": 5000 } } } ] } }, "from": 40, "size": 20, "sort": [ { "price": { "order": "asc" } } ], "_source": ["id", "name", "price", "brandName", "categoryName"], "highlight": { "fields": { "name": {} } } }这段 DSL 里要特别注意term和match的区别。term是精确匹配,一般查 keyword 类型字段或数值字段,它不会对查询词做分词;match是全文检索,会对查询词做分词。很多新手在这里翻车:用term查一个 text 字段,比如商品描述,结果什么都查不到,就是因为 text 字段在索引时被分词了,而term拿完整的词去精确匹配不可能命中。
range用于范围查询,gte大于等于,lte小于等于,价格、库存、销量这类数值字段都用它。排序支持多个字段,卖得好的商品想优先展示,就加{ "sales": { "order": "desc" } }。分页的from等于(页码 - 1) * size,这个和 MySQL 的 limit 很像,但 ES 的深分页代价极高,后面排坑部分我再展开。
高亮是搜索产品最常见的功能。ES 默认会给匹配到的字段加<em>标签,前端拿到后用这个标签渲染红色或加粗即可。注意高亮字段必须是 text 类型,keyword 字段是不支持高亮的。
3. 聚合分析:用 DSL 把海量商品按维度统计出来
3.1 三种聚合类型到底分别解决什么问题
说到聚合,有些人第一反应是音乐软件里的“聚合音源”或者资讯平台的“聚合搜索”,那类聚合讲的是多数据源合并。ES 里的聚合(aggregations)完全是另外一回事,它更像 SQL 里的GROUP BY——对查询结果做分组统计,算出每组有多少条数据、平均值、最大值这些指标。
ES 聚合分三大类:
- 桶聚合(Bucket Aggregation):把文档分组,类似
GROUP BY。最常碰到的terms是按某个字段的不同值分桶,range是按数值区间分桶,date_histogram是按时间区间分桶。 - 指标聚合(Metric Aggregation):对数值字段做统计计算,比如
avg、min、max、sum、stats(一次返回多个统计值)、cardinality(去重计数)。 - 管道聚合(Pipeline Aggregation):基于其他聚合的结果再做一次计算,比如计算每个桶的平均值。这类用得少一些,但做复杂报表时非常有用。
日常搜索中最常见的是桶聚合加指标聚合嵌套,比如搜索出商品后,再按分类分桶,统计每个分类下的商品数和平均价格。这个能力是数据库很难实现的——SQL 写出来既复杂又慢,ES 用一段 JSON 轻松搞定。
3.2 分类-品牌-价格区间多层聚合实战
我现在假设一个电商搜索侧边栏场景。用户搜“手机”后,页面上需要一个筛选栏,展示所有相关分类及每个分类的商品数量、所有品牌及对应数量,还要把价格按“1000以下”“1000-3000”“3000-5000”“5000以上”分成几段展示商品数。
结合前面那个查询,我给它加上聚合部分:
GET /product/_search { "query": { "bool": { "must": [ { "match": { "name": "手机" } } ] } }, "size": 0, "aggs": { "categoryAgg": { "terms": { "field": "categoryId", "size": 20 }, "aggs": { "brandAgg": { "terms": { "field": "brandId", "size": 10 } } } }, "priceAgg": { "range": { "field": "price", "ranges": [ { "to": 1000 }, { "from": 1000, "to": 3000 }, { "from": 3000, "to": 5000 }, { "from": 5000 } ] } }, "priceStats": { "stats": { "field": "price" } } } }有几个关键点要理解。
size: 0表示不需要返回具体商品列表,只要聚合结果。如果用数据库思维理解,就是只做 GROUP BY,不 SELECT 明细。
categoryAgg是分桶聚合,按categoryId分组。返回结果里每个 bucket 会有doc_count表示该分类下有多少商品。我又在categoryAgg里面嵌套了一个brandAgg,也就是每个分类桶下再按品牌继续分组,这个就是父子聚合,解决“分类下的品牌分布”这道题。
priceAgg是 range 桶聚合,按四个价格区间分桶,电商价格筛选栏就是这么来的。priceStats是 stats 指标聚合,会一次性返回价格的最小值、最大值、平均值、总和、总数,对页面展示非常有价值。
聚合返回结构大概是:顶层aggregations字段下,每个聚合名称对应一个对象。terms 聚合里有buckets数组,每个 bucket 有key(分组值)、doc_count(数量)、还有嵌套的brandAgg。range 聚合的key是“1000.0-3000.0”这样的字符串,Java 解析时要注意别取错了字段。
有一点需要提醒:terms 聚合默认只返回 size=10 的桶。换句话说,如果分类有 50 个,你不写 size 的话,ES 只给你前 10 个分类的数据,很容易漏统计。但 size 也不是无脑调大,后面讲内存坑的时候会说清楚。
4. SpringCloud 项目中用 Java 代码落地 DSL
4.1 环境准备:Windows 上启动 ES 与项目配置的几处坑
在 Windows 上跑 Elasticsearch 其实不复杂,下载压缩包后进入 bin 目录,双击elasticsearch.bat就能启动。默认监听 9200 端口,浏览器访问http://localhost:9200,如果返回一个带cluster_name的 JSON 就说明启动成功了。
但环境准备有几个容易踩的坑。
首先是 JDK 版本。ES 7.x 自带内置 JDK,但如果你项目用的是 JDK 8,ES 8 以上版本可能会不兼容,开发环境我建议保持 ES 7.x 和 JDK 8/11 的组合,稳妥。
其次是 JVM 堆内存。默认jvm.options给的是 1G,如果电脑内存不大,启动会非常慢,甚至起不来。建议调到 2G 以内,别超过物理内存的一半。同时 ES 会做 bootstrap check,Windows 单机开发一般不会遇到 Linux 上那种max file descriptors的报错,但如果你在 Linux 上部署,必须要改。
第三是跨域问题。如果前端要直接通过浏览器访问 ES(开发环境图省事),必须在elasticsearch.yml里加入。
http.cors.enabled: true http.cors.allow-origin: "*"再来是项目端的客户端配置。我用的是 Spring Boot 2.x 加 RestHighLevelClient,这是 ES 7.x 时代最主流的 Java 客户端。需要提醒的是,客户端版本必须和服务端版本保持大版本一致,否则序列化兼容性上很容易出幺蛾子。
@Configuration public class ElasticsearchConfig { @Bean public RestHighLevelClient restHighLevelClient() { return new RestHighLevelClient( RestClient.builder( new HttpHost("localhost", 9200, "http") ) ); } }4.2 用 SearchRequest 构建搜索与聚合请求
在 Java 代码里写 DSL 并不需要手动拼 JSON 字符串,RestHighLevelClient 提供了一套完整的 Builder API。我一开始图方便字符串拼接过,后来改成了SearchSourceBuilder,因为类方法有语法提示,结构也更清晰,改错字段的几率低很多。
下面这段代码对应第 2 节的商品搜索需求:
public SearchResponse searchProducts(String keyword, Long categoryId, Long brandId, Double minPrice, Double maxPrice, int page, int size) throws IOException { BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); if (StringUtils.hasText(keyword)) { boolQuery.must(QueryBuilders.matchQuery("name", keyword)); } if (categoryId != null) { boolQuery.filter(QueryBuilders.termQuery("categoryId", categoryId)); } if (brandId != null) { boolQuery.filter(QueryBuilders.termQuery("brandId", brandId)); } if (minPrice != null || maxPrice != null) { boolQuery.filter(QueryBuilders.rangeQuery("price") .gte(minPrice == null ? 0 : minPrice) .lte(maxPrice == null ? Integer.MAX_VALUE : maxPrice)); } SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query(boolQuery); sourceBuilder.from((page - 1) * size); sourceBuilder.size(size); sourceBuilder.sort("price", SortOrder.ASC); sourceBuilder.fetchSource(new String[]{"id", "name", "price", "brandName", "categoryName"}, null); HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("name"); sourceBuilder.highlighter(highlightBuilder); // 聚合部分 TermsAggregationBuilder categoryAgg = AggregationBuilders.terms("categoryAgg") .field("categoryId") .size(20); categoryAgg.subAggregation(AggregationBuilders.terms("brandAgg").field("brandId").size(10)); sourceBuilder.aggregation(categoryAgg); sourceBuilder.aggregation(AggregationBuilders.range("priceAgg") .field("price") .addUnboundedTo(1000) .addRange(1000, 3000) .addRange(3000, 5000) .addUnboundedFrom(5000)); SearchRequest searchRequest = new SearchRequest("product"); searchRequest.source(sourceBuilder); return restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT); }这里有个容易忽略的问题:matchQuery用于 keyword 字段时,会对关键词做分词再匹配,结果往往不准确。如果你的需求就是“品牌名必须完全等于某个值”,对应的字段必须设计成 keyword 类型,Java 里就用termQuery。
还有一点,聚合字段如果是 text 类型直接做terms聚合会直接报错,因为 text 默认关闭了fielddata。这个我后面速查表里也会再说。
4.3 解析返回结果并封装成 VO
拿到SearchResponse之后,接下来的事就是把它转换成前端要的 DTO。这段代码的坑比查询构造多,尤其是聚合结果的解析。
public ProductSearchVO parseSearchResponse(SearchResponse response) { ProductSearchVO vo = new ProductSearchVO(); List<ProductDTO> productList = new ArrayList<>(); // 解析商品列表 SearchHits hits = response.getHits(); for (SearchHit hit : hits.getHits()) { ProductDTO dto = JSON.parseObject(hit.getSourceAsString(), ProductDTO.class); Map<String, HighlightField> highlightFields = hit.getHighlightFields(); HighlightField nameHighlight = highlightFields.get("name"); if (nameHighlight != null) { dto.setName(nameHighlight.getFragments()[0].toString()); } productList.add(dto); } vo.setProductList(productList); // 解析分类聚合 Aggregations aggregations = response.getAggregations(); Terms categoryAgg = aggregations.get("categoryAgg"); List<CategoryBucketVO> categoryBuckets = new ArrayList<>(); for (Terms.Bucket bucket : categoryAgg.getBuckets()) { CategoryBucketVO categoryVO = new CategoryBucketVO(); categoryVO.setCategoryId(bucket.getKeyAsNumber().longValue()); categoryVO.setCount(bucket.getDocCount()); // 解析嵌套的品牌聚合 Terms brandAgg = bucket.getAggregations().get("brandAgg"); List<BrandBucketVO> brandBuckets = new ArrayList<>(); for (Terms.Bucket brandBucket : brandAgg.getBuckets()) { brandBuckets.add(new BrandBucketVO( brandBucket.getKeyAsNumber().longValue(), brandBucket.getDocCount() )); } categoryVO.setBrands(brandBuckets); categoryBuckets.add(categoryVO); } vo.setCategoryBuckets(categoryBuckets); return vo; }细节提醒:bucket.getKey()返回的是 String,但如果字段是数值类型,应使用getKeyAsNumber()再转成对应的 Java 数值类型,否则你拿到的 key 可能是 "1001.0" 这种带小数点的字符串。高亮字段解析时,getFragments()返回的数组,如果字段有多个命中片段就是多个元素,通常取第一个即可。
如果要在微服务里提供给其他业务方调用,搜索服务还要把这个方法再包一层 Feign 接口,让商品服务、后台管理服务通过 OpenFeign 来调用,而不是暴露 ES 的 9200 端口给所有服务。这也是 SpringCloud 架构下比较干净的调用方式。
5. 复盘与排坑:DSL 查询、聚合的常见问题
5.1 四个最容易翻车的现场
第一个坑:term 查询查不到数据。我之前见过一个同学,索引里商品名称字段用的是默认 mapping,也就是 text 类型,然后他用term查“苹果手机”,结果返回 0 条。原因前面已经讲过:text 字段会被分词器拆成“苹果”“手机”等词条存储,你拿完整的“苹果手机”去精确匹配,当然匹配不到。解决方案有两个:查询用 match,或者把需要精确匹配的字段单独映射成 keyword。最正确的做法是在设计索引 mapping 时,就想清楚哪些字段要全文检索、哪些字段要精确匹配,而不是等出问题再改。
第二个坑:中文搜索不准。默认的 standard 分词器对中文就是按字切,搜索“羽绒服”时,它可能被切分为单个“羽”、“绒”、“服”,打着打着结果就乱了。生产环境一定要装 IK 分词器,用ik_max_word做索引分词、ik_smart做搜索分词。IK 还能扩展自定义词库,把品牌名、行业专有名词加进去。
第三个坑:聚合结果不准。terms 聚合默认返回 10 个桶,分片之间还有个shard_size参数。如果业务上有 30 个品牌,你只写默认值,聚合结果可能只有 10 个,而且数量有偏差。原因在于 ES 每个分片先返回自己的 Top N,协调节点再合并,若shard_size太小,全局排名靠前的桶可能会在分片级就被截掉。解决方法是按业务情况调大 terms 聚合的 size 和shard_size,但要注意高基数聚合会消耗大量内存,别设置成一个特别大的数。
第四个坑:深分页导致性能崩。from + size分页在页数深时会指数级变慢,因为协调节点要取出随机的第 N 页数据,就必须把前面 N 页都排序一遍。比如from: 10000, size: 20,ES 需要拿回所有匹配文档的前 10020 条再截取,系统资源瞬间就飙上去了。如果必须深分页,用search_after配合排序,它基于上一页最后一条记录的排序值来定位下一页,性能稳定得多。但search_after不支持跳页,适合“加载更多”这种场景,而不是页码按钮。
5.2 数据同步、索引维护与性能避坑清单
搜索服务和业务库之间的数据同步,坑也不少。MQ 消息顺序是乱序的,比如商品 A 先发了一条“更新为价格 2000”的消息,又发了一条“更新为价格 1000”的消息,消费者处理顺序反了,ES 里最终就是错误的价格。一种方案是在消息体里带上更新时间戳,消费时对比当前文档的时间戳,旧的更新直接丢弃。
ES 索引结构也不是一成不变的,业务调整了搜索字段,就要重建索引。千万别想着直接删了索引重建,线上数据量大的时候,重建索引的空窗期谁也扛不住。生产上我用的方案是索引别名切换:先建一个新索引(比如product_v2),把数据全量导入,校验无误后把别名product从旧索引切换到新索引,旧索引再删掉。整个过程中搜索服务只用别名,完全无感知。
最后整理一个避坑速查表,方便大家直接存下来:
| 现象 | 根本原因 | 快速解决 |
|---|---|---|
| term 查 text 字段无结果 | 字段被分词 | 改用 match,或字段映射为 keyword |
| 中文搜索不准 | 没有中文分词器 | 安装 IK,索引和查询都用 ik 分词 |
| terms 聚合桶数不对 | 默认 size=10、shard_size 偏小 | 调大 size 和 shard_size,但注意内存 |
| from 10000 之后分页极慢 | 深分页需要大量排序 | 改用 search_after |
| text 字段聚合报错 | text 默认关闭 fielddata | 字段设计为 keyword,或开启 fielddata(不推荐) |
| 同步后 ES 数据与 MySQL 不一致 | 消息乱序或同步失败 | 消息带时间戳,采用版本号覆盖,失败重试 |
| 索引结构变化 | 直接修改 mapping 不支持 | 新索引 + 别名切换 |
我自己的体会是,ES 的查询和聚合能力确实强,但它不是一个“部署完就能用”的中间件。你至少要把 mapping 设计、分词选型、数据同步链路、分页策略这几件事想清楚,否则上线之后各种性能问题会接踵而来。Day8 的核心收获不是多会写几个 DSL 语法,而是学会从业务角度去拆解搜索需求:哪些场景走全文检索、哪些场景走精确过滤、哪些统计要用桶聚合完成,然后把它们组装成一个完整的搜索接口。
如果你正在学 SpringCloud,下一步建议把搜索接口接到一个真实的聚合页面上。当你在侧边栏看到分类、品牌、价格区间这些统计数据和商品列表一起返回时,你会真正理解 ES 相比传统数据库的强项在哪里。