大概两年前,我接手了一个基于SpringBoot的搜索服务改造项目。原来的实现是这样的:所有筛选条件拼成SQL,靠MySQL的LIKE查询硬扛。数据量在百万级的时候还能忍,等到某个业务表冲到三千万行,一次带多个条件的模糊查询直接飙到三秒多,接口超时告警一天能响几十次。后来把搜索链路替换成了ElasticSearch,核心查询构建用的是Spring Data Elasticsearch里的QueryBuilder,线上接口的耗时从三秒多降到了几十毫秒。这篇文章就把我在这个改造过程中对QueryBuilder的完整理解、实际踩过的坑和最终沉淀下来的代码组织方式整理出来,希望能给正打算在SpringBoot项目里接入ES条件查询的同学一些参考。
这套东西我自己使用下来的感受是:它并不是一套门槛很高的技术,但里面一些细节确实坑了不少人,尤其是第一次从SQL思维切到ES思维的时候,最容易在bool查询、term与match的区分、以及动态拼接条件这几块上懵。我会从最基础的依赖版本开始,一直讲到组合查询怎么写,再配合排查实录,尽量让这篇内容能做到“看完能直接照着写”。
1. 项目整体思路拆解:为什么选择QueryBuilder而不是写死JSON
先说个很多人刚接触Spring Data Elasticsearch时的困惑:既然ES本身提供一整套REST API,直接把JSON查询串写在代码里不就行了吗,为什么要用QueryBuilder?
我的答案是:如果你只是写一个固定不变的查询,直接上JSON字符串确实更直观、更贴近ES原生语法。但现实业务里的搜索条件几乎不会是固定的,用户可能选城市、可能筛价格区间、可能只按分类过滤、也可能什么都不选只做一个关键词搜索。这种“动态拼条件”的场景,如果靠字符串拼接JSON,代码会很快失控,而且很容易在拼接过程中漏掉引号、多一个逗号,线上查不出数据的时候排查成本极高。QueryBuilder的价值就在于它用Java对象来描述查询逻辑,允许你在代码里以编程的方式按需组合条件,构建过程本身就是一段可维护的逻辑,而不是一堆要被解析的文本。
还有一个很重要的原因:QueryBuilder最终会由框架转化成ES可以理解的查询DSL。也就是说你不需要自己关心JSON结构长什么样,框架帮你序列化,出错概率低很多。像我接手那个搜索服务时,团队之所以选这种方式,核心就是后续扩展方便。比如今天只支持“关键词+城市”两个条件,明天业务说要加一个“人均消费区间”,那只需要在构建QueryBuilder的地方追加一个RangeQueryBuilder,不需要改动拼接JSON的逻辑和对应的解析器。
另外要说明的是,Spring Data Elasticsearch在不同版本里对QueryBuilder的封装是有差异的,后面会单独讲版本兼容问题。整体思路确认后,项目落地的顺序大致是:先准备依赖和连接配置,再根据业务字段建好索引映射,然后核心地实现查询构建服务,最后统一封装返回结果、做分页和排序。下面按这个顺序把关键环节挨个拆开。
2. 环境准备与基础配置:版本选型是第一个大坑
2.1 依赖引入与版本对应关系
Spring Data Elasticsearch和Elasticsearch之间的版本兼容是出了名的“一不留神就翻车”。我见过不止一个项目因为依赖版本没对齐,启动时直接抛UnsupportedOperationException或者NoSuchMethodError,排查半天才发现是客户端版本和服务端版本不匹配。我自己目前用得比较多、也比较推荐的一套组合是:SpringBoot 2.7.x + Spring Data Elasticsearch 4.4.x + Elasticsearch 7.17.x。
如果你用的是SpringBoot 2.7,可以在pom.xml里这样引入:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> </dependencies>这里需要注意一个关键点:SpringBoot的BOM已经帮你锁定了spring-boot-starter-data-elasticsearch对应的版本,所以正常情况下不需要手动指定spring-data-elasticsearch和elasticsearch这两个jar的版本号。但如果你在私服或者公司内部仓库里单独引了ES的依赖,并且版本不是7.17.x,那启动时大概率会遇到客户端与服务端版本协商失败的问题。
如果不确定自己该用哪套版本组合,最稳妥的方法是到Spring Data Elasticsearch官方文档里查“Version Compatibility”表格,确保
spring-data-elasticsearch的版本和你的ES集群主版本对齐。
2.2 连接配置
配置项写在application.yml里,我习惯用下面这套:
spring: elasticsearch: uris: - http://localhost:9200 connection-timeout: 3s read-timeout: 10s socket-timeout: 30s注意属性名是spring.elasticsearch.uris而不是spring.elasticsearch.host,这是SpringBoot 2.x比较新的写法,很多人沿用老版本的习惯写host,结果发现配置不生效。连接超时和读取超时这两个参数建议一定要显式配置,默认值在某些生产环境里会表现得过于“乐观”,如果ES集群压力大、响应慢,客户端会长时间挂住不报错,接口调用方那边看起来就像是服务假死。
配置好连接之后,下一步就是定义索引实体。实体定义这一块直接决定了后面QueryBuilder里面字段名怎么使用,所以我会把字段映射的规范放在查询之前的准备工作里讲清楚。
2.3 索引实体与字段映射注意点
我用一个实际的业务场景来举例。假设我们做一个门店搜索,索引里存了门店名称、所属城市、分类、人均消费、评分、经纬度、营业状态等字段。对应的实体类大致是这样的:
@Data @Document(indexName = "store_info") public class StoreInfo { @Id private String id; @Field(type = FieldType.Keyword) private String city; @Field(type = FieldType.Keyword) private String category; @Field(type = FieldType.Text, analyzer = "ik_max_word") private String storeName; @Field(type = FieldType.Text, analyzer = "ik_max_word") private String description; @Field(type = FieldType.Double) private Double avgPrice; @Field(type = FieldType.Double) private Double rating; @Field(type = FieldType.Boolean) private Boolean isOpen; }字段类型的选择是这里面的核心细节。ES的Text和Keyword是两种查询方式截然不同的类型:Keyword类型不分词,适合精确匹配、过滤、聚合排序;Text类型会经过分析器分词,适合全文搜索。像我上面的门店名称和描述,因为要做“关键词搜索”的效果,所以用Text加ik_max_word中文分词;而城市和分类是要精确筛选的,就用Keyword。
如果这个映射关系没想清楚,后面写QueryBuilder的时候就会出现很典型的问题:用term查询一个Text字段往往什么都查不到,因为Text字段在索引时已经被分词器拆成了多个词项,term默认不做分析,拿整个输入去匹配词项,自然匹配不上。这个问题后面在“常见问题”部分还会再展开。
实体类定义好以后,可以用ElasticsearchRestTemplate的indexOps()方法创建索引,也可以让框架在启动时自动根据实体创建。我建议在开发环境可以自动创建,但生产环境最好预先通过ES的API或者专门的初始化任务来建索引,因为生产索引的shard数量、副本数、分词器配置往往比实体注解里能表达的东西更复杂。
3. 条件查询核心拆解:从简单条件到组合条件
3.1 几个最常用的QueryBuilder实现类
Spring Data Elasticsearch里,所有具体的QueryBuilder实现都会实现org.elasticsearch.index.query.QueryBuilder这个顶层接口。我在日常开发中最常用的是下面这几个:
| 实现类 | 作用 | 使用场景 |
|---|---|---|
MatchQueryBuilder | 分词匹配查询 | 搜索名称、描述、内容等需要分词的字段 |
TermQueryBuilder | 精确词项查询 | 精确匹配某个ID、分类、状态等不分词字段 |
TermsQueryBuilder | 多个精确值查询 | 类似SQL里的IN查询 |
RangeQueryBuilder | 范围查询 | 价格区间、时间范围、数值区间 |
BoolQueryBuilder | 布尔组合查询 | 把多个条件组合在一起 |
WildcardQueryBuilder | 通配符查询 | 模糊匹配,但性能开销较大,需慎用 |
这些类在使用上有一个通用的规律:每个QueryBuilder一般都有对应的静态方法,可以通过QueryBuilders.xxx()一行代码创建出来。比如QueryBuilders.matchQuery("storeName", "火锅")、QueryBuilders.termQuery("city", "上海")。这种方式写起来简洁,团队里其他人读代码的时候也能一眼看出查询意图。
3.2 BoolQueryBuilder:条件组合的地基
真实的搜索场景里,几乎没有只靠一个单一条件就能搞定需求的。我自己的经验是,90%以上的查询最终都得落到BoolQueryBuilder上。它的核心逻辑是用四个子句来组织条件:
must:文档必须满足所有条件,参与相关性评分。filter:文档必须满足条件,但不参与评分,性能更好。should:文档满足任意一个条件即可,最少满足数由minimumShouldMatch控制。mustNot:文档不能满足条件,相当于排除。
用SQL来类比的话:must和filter都相当于AND,should相当于OR,mustNot相当于NOT。但must和filter在ES内部有一个核心差别——filter会使用缓存,查询速度更快。所以我的习惯是:纯过滤性质的等值条件、范围条件、状态条件,全部放到filter里;只有影响相关性排序的全文搜索条件才放到must里。举个例子,用户搜“火锅”同时筛选城市为“上海”,那“火锅”放进must,“城市”放进filter,因为城市这个条件并不需要影响相关性,我们只需要它做过滤。
3.3 从第一个简单查询到组合查询的完整演变
先说最简单的单条件查询。用Spring Data Elasticsearch的ElasticsearchRestTemplate配合NativeSearchQuery,核心代码像这样:
QueryBuilder queryBuilder = QueryBuilders.termQuery("city", "上海"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query(queryBuilder); NativeSearchQuery searchQuery = new NativeSearchQuery(queryBuilder); searchQuery.setPageable(PageRequest.of(0, 10)); SearchHits<StoreInfo> searchHits = elasticsearchRestTemplate.search(searchQuery, StoreInfo.class); SearchPage<StoreInfo> page = SearchHitSupport.searchPageFor(searchHits, searchQuery.getPageable());这段代码跑通之后,就可以在这个基础上不断加条件。比如加上分类筛选和价格区间:
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); boolQueryBuilder.filter(QueryBuilders.termQuery("city", "上海")); boolQueryBuilder.filter(QueryBuilders.termQuery("category", "火锅")); boolQueryBuilder.filter(QueryBuilders.rangeQuery("avgPrice").gte(50).lte(200)); NativeSearchQuery searchQuery = new NativeSearchQuery(boolQueryBuilder);这里每一行都是独立的语义,代码读起来很清楚。你甚至可以先把所有过滤条件封装到一个方法里,方法返回BoolQueryBuilder,后续业务加筛选条件就只改这个方法,查询入口完全不用动。
关于NativeSearchQuery还有个容易忽略的细节:这个类本身已经废弃了一些构造方式,新版本更推荐直接构建Query对象传入SearchSourceBuilder。但不管API怎么演进,核心逻辑都是一样的——你先构建出QueryBuilder,然后把它塞进整个Search查询对象里。
3.4 全文搜索与精确匹配的取舍
在实际业务里,我发现最容易让新手纠结的就是什么时候用match,什么时候用term。我提供一个非常简单的判断标准:只要是给用户输入框使用的搜索词,一律用match;只要是对字段做程序化过滤(比如下拉筛选项、勾选状态、指定ID列表),一律用term或terms。
原因在于match查询会先对搜索关键词做分词,然后再去匹配分词后的词项列表。比如用户输入“好吃的火锅”,分词器会把这句话拆成“好吃”“的”“火锅”等词,然后逐个去匹配文档里的字段内容;而term查询不会做分词,它把“好吃的火锅”这个完整字符串当成一个整体去匹配词项。如果字段本身是Keyword类型,term是能匹配上的;但如果字段是Text类型,索引里存的是分词后的结果,拿“好吃的火锅”整句去term匹配,几乎匹配不到,除非某个词项恰好包含了整句内容。
下面用代码演示两种典型查法:
// 全文搜索门店名 QueryBuilders.matchQuery("storeName", keyword); // 精确匹配某个分类 QueryBuilders.termQuery("category", "火锅");区别就一句话:分词或不分词。这两个如果不搞清楚,后面所有查询都会出问题,尤其是当你发现某个条件单独查没问题、组合起来却少数据的时候,十有八九就是分词字段和精确匹配混用了。
3.5 范围查询和时间范围的处理
范围查询在门店搜索里对应的是价格、评分这类数值属性,在订单系统里对应的是时间范围。用法很直接:
// 价格区间 QueryBuilders.rangeQuery("avgPrice").gte(50).lte(200); // 评分不低于4.5 QueryBuilders.rangeQuery("rating").gte(4.5); // 时间范围,注意时区问题 QueryBuilders.rangeQuery("createTime") .gte("2024-01-01T00:00:00") .lte("2024-12-31T23:59:59");这里要提醒一个极易踩的坑:时间字段在ES里如果按Date类型存储,默认格式通常是带时区的ISO标准格式。如果你从前端拿到一个LocalDateTime直接转成字符串传进去,时区对不上,会导致查询结果偏差好几个小时。我自己的处理方式是统一在服务端把时间转成Instant或者带时区的格式化字符串再传给RangeQueryBuilder。如果你用的是@Field(type = FieldType.Date),建议在实体上指定日期格式,例如format = DateFormat.date_hour_minute_second,这样查询时字符串格式、索引映射格式、返回结果格式全部对齐。
4. 核心环节实操:一个可复用的动态条件查询服务
4.1 先从业务需求出发拆解条件
搜索服务最常见的形态是“传入一个查询对象,根据非空字段动态组合查询条件”。比如前端可能传来这样的请求参数:关键词(可选)、城市(可选)、分类(可选)、最低评分(可选)、价格上限(可选)、是否营业(可选)。后端要做的事情其实很纯粹:哪个字段有值,就往BoolQueryBuilder里追加对应的条件;哪个字段为空,就跳过这个条件。
这段逻辑本身不复杂,但如果不做代码结构上的整理,很容易写成一个几十行不断if拼接的“面条代码”。我建议把每个条件的构建逻辑抽成独立方法,主流程只负责组合。下面是我在项目里沉淀下来的一个示例结构。
4.2 代码实现:查询条件构建器
先定义一个查询参数类:
@Data public class StoreSearchQuery { private String keyword; private String city; private String category; private Double minRating; private Double maxPrice; private Boolean isOpen; private Integer pageNum = 1; private Integer pageSize = 10; }然后构建查询条件:
@Service @RequiredArgsConstructor public class StoreSearchService { private final ElasticsearchRestTemplate elasticsearchRestTemplate; public SearchPage<StoreInfo> search(StoreSearchQuery query) { BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); // 关键词:全文搜索,影响评分 if (StringUtils.hasText(query.getKeyword())) { boolQueryBuilder.must(QueryBuilders.matchQuery("storeName", query.getKeyword())); } // 城市:精确过滤,不参与评分 if (StringUtils.hasText(query.getCity())) { boolQueryBuilder.filter(QueryBuilders.termQuery("city", query.getCity())); } // 分类:精确过滤,不参与评分 if (StringUtils.hasText(query.getCategory())) { boolQueryBuilder.filter(QueryBuilders.termQuery("category", query.getCategory())); } // 评分下限 if (query.getMinRating() != null) { boolQueryBuilder.filter(QueryBuilders.rangeQuery("rating").gte(query.getMinRating())); } // 价格上限 if (query.getMaxPrice() != null) { boolQueryBuilder.filter(QueryBuilders.rangeQuery("avgPrice").lte(query.getMaxPrice())); } // 营业状态 if (query.getIsOpen() != null) { boolQueryBuilder.filter(QueryBuilders.termQuery("isOpen", query.getIsOpen())); } NativeSearchQuery searchQuery = new NativeSearchQuery(boolQueryBuilder); searchQuery.setPageable(PageRequest.of(query.getPageNum() - 1, query.getPageSize())); SearchHits<StoreInfo> searchHits = elasticsearchRestTemplate.search(searchQuery, StoreInfo.class); return SearchHitSupport.searchPageFor(searchHits, searchQuery.getPageable()); } }这段代码的可读性就在于:你一眼就能看出每个业务条件对应哪个ES查询条件,而且在“关键词为空”的时候,must里不会有任何条件,查询就自动退化为纯过滤查询。加分页的时候也记得一点——用户传pageNum=1,但ES的PageRequest是从0页开始的,所以这里要做pageNum - 1的转换,这也是一个很常见的初学者问题。
4.3 关键字搜索多个字段的写法
很多时候,用户输入的关键词并不只想匹配一个字段。比如搜索“海底捞”,可能门店名里有,也可能描述文案里有。这时候常见的做法是把关键词同时匹配多个字段,然后在逻辑上取“或”关系。QueryBuilder里有专门的写法:
QueryBuilder multiMatch = QueryBuilders.multiMatchQuery(keyword, "storeName", "description"); boolQueryBuilder.must(multiMatch);上面的代码等同于同时用storeName和description两个字段去匹配同一段关键词,只要任意一个字段匹配上,文档就被召回。这个写法比分别构建两个match查询再用should组合要简洁得多,而且在相关性评分上ES会统一考虑各字段的匹配程度,结果更符合直觉。
如果业务上还要求某些字段权重更高,比如门店名匹配的权重高于描述,可以进一步用field^boost的语法指定权重:
QueryBuilder multiMatch = QueryBuilders.multiMatchQuery(keyword, "storeName^3", "description");意思就是“门店名”字段匹配到的得分权重是“描述”字段的三倍。这个细节在搜索结果排序精度要求较高的场景里非常有用。
4.4 分页、排序与高亮的组装
搜索服务还有一个基本逃不掉的需求就是分页和排序。这里需要大家记住一个ES的安全底线:from + size不能超过index.max_result_window默认的10000,否则查询会直接报错。如果业务确实会遇到深分页场景,就需要改用search_after或者游标方案,这属于另一个话题。普通管理后台类的搜索,到10000条以前基本够用。
排序的写法也很直观。比如按评分降序、按距离升序(假设有地理位置数据):
searchQuery.addSort(SortBuilders.fieldSort("rating").order(SortOrder.DESC)); searchQuery.addSort(SortBuilders.geoDistanceSort("location", 31.2304, 121.4737).order(SortOrder.ASC));上面这行geoDistanceSort适用的情况是:索引里存了经纬度坐标,用户期望按照“离我最近”的顺序展示结果。这是ES在搜索场景里比起MySQL要便利得多的一项能力——SQL要写复杂的地理计算,ES直接在排序阶段把距离算出来了。
高亮返回是全文搜索体验里的重头戏。在QueryBuilder之外,NativeSearchQuery同样可以设置高亮字段和标签:
HighlightBuilder highlightBuilder = new HighlightBuilder(); HighlightBuilder.Field highlightName = new HighlightBuilder.Field("storeName") .preTags("<span style='color:red'>") .postTags("</span>"); highlightBuilder.field(highlightName); searchQuery.setHighlightBuilder(highlightBuilder);搜索结果获取高亮片段的方式:
SearchHit<StoreInfo> searchHit = ...; List<String> highlights = searchHit.getHighlightFields().get("storeName");拿到了高亮片段以后,前端展示搜索结果时就能直接渲染带红色标记的关键词,体验会明显好于“搜索后只展示原文”。
4.5 聚合统计的简单应用
搜索场景里免不了要展示“按城市分组有多少家店”“每个分类下有哪些门店”。这种需求在ES里对应的是聚合(Aggregation)。聚合和QueryBuilder是独立的体系,但经常配合使用。QueryBuilder先决定搜哪些文档,聚合再对这堆文档做统计。
举个例子,在搜索“火锅”的结果中,按城市统计门店数量:
TermsAggregationBuilder cityAgg = AggregationBuilders.terms("cityAgg") .field("city") .size(20); NativeSearchQuery withAggQuery = new NativeSearchQuery(boolQueryBuilder); withAggQuery.addAggregation(cityAgg);返回结果里通过Aggregations解析桶数据,就能拿到“上海有150家、北京有96家”这种维度的统计结果。这个能力在做筛选栏动态展示和报表分析的时候非常实用,相当于把原来要单独发一个SQL分组统计请求的事,合并到同一个搜索请求里完成了。
5. 复杂场景进阶:多条件组合、嵌套查询与脚本字段
5.1 should与minimumShouldMatch的搭配
should子句代表的是“或”关系,但很多人忽略了一个细节:在只有should没有must和filter的BoolQuery里,should至少匹配一个条件文档才会被召回;但如果一个BoolQuery里同时有must和should,那should条件默认不强制满足。也就是说,should是否必须生效,取决于上下文里有没有其他强制条件。
这个默认行为很容易造成一种“查询条件写了但没生效”的错觉。比如用户想搜“上海”地区的门店,同时也希望能匹配“火锅”或者“烧烤”分类,如果你把“上海”放进filter,把两个分类放进should,结果可能就会变成:所有上海门店都返回了,分类筛选形同虚设。原因就是should不强制满足。
解决办法是显式指定minimumShouldMatch:
boolQueryBuilder.should(QueryBuilders.termQuery("category", "火锅")); boolQueryBuilder.should(QueryBuilders.termQuery("category", "烧烤")); boolQueryBuilder.minimumShouldMatch(1);这样语义才真正等同于“上海且(火锅或烧烤)”。我建议项目里只要用到should,就明确写出minimumShouldMatch的值,不要依赖默认行为,避免不同ES版本升级后行为出现偏差时无从排查。
5.2 嵌套对象查询
门店索引里如果存了多套餐具或服务项目,并且每个项目都带有自己的属性,那会涉及到nested类型。ES对nested对象做条件查询,如果直接拼term会失效,必须使用专门的嵌套查询语法。
假设实体里有个字段:
@Field(type = FieldType.Nested) private List<StoreFacility> facilities;要查询“提供免费WiFi且支持24小时营业”的门店,QueryBuilder写法是:
QueryBuilders.nestedQuery("facilities", QueryBuilders.boolQuery() .must(QueryBuilders.termQuery("facilities.name", "wifi")) .must(QueryBuilders.termQuery("facilities.available", true)), ScoreMode.None);这里有个关键点:nestedQuery内部的条件字段路径必须带上外层嵌套字段的完整路径名,比如facilities.name,不能只写name。这个坑我也踩过,当时查结果一直为空,排查了很久才发现是路径写短了。另外ScoreMode的选择也有讲究,None表示不参与评分,Avg、Max等会影响到最终相关性排序结果,除非特意需要,一般我用None。
5.3 脚本字段做动态计算
有些业务需要返回在搜索时动态计算的字段值。比如门店搜索的结果里要算出门店当前评分与区域平均评分的一个差值,这类逻辑不适合在Java代码里循环算,因为会徒增内存和耗时。ES支持在查询时用Script字段动态生成:
NativeSearchQuery searchQuery = new NativeSearchQuery(boolQueryBuilder); searchQuery.addScriptField("ratingDiff", new Script(ScriptType.INLINE, "painless", "doc['rating'].value - params.avgRating", Collections.singletonMap("avgRating", 4.5)));返回结果里动态字段的获取方式是:
searchHit.getFields().get("ratingDiff").getValue();Script能力很灵活,但也要慎用。它是在ES的每个分片上执行脚本,复杂的脚本会对查询性能有比较明显的影响。能用doc取值就尽量不要加载整个_source文档,脚本里也尽量别写循环遍历的逻辑。
6. 常见问题与排查技巧实录
6.1 查询结果为空,但直接在Kibana里查有数据
这是出现频率最高的问题。原因绝大多数是字段类型不匹配,比如索引里某个字段是Text类型,但代码里用了termQuery做精确匹配。排查方法很简单:先在Kibana或者Dev Tools里执行GET store_info/_mapping,看字段的真实类型,再回看代码里的QueryBuilder使用方式。如果字段是Text而你确实需要精确匹配,正确的办法是对这个字段额外配置一个.keyword子字段,然后用termQuery("storeName.keyword", "海底捞")来查询。
顺带一提,在Spring Data Elasticsearch实体里,可以用@Field(type = FieldType.Text, fielddata = true)为Text字段启用fielddata,但通常不建议这么干,因为fielddata会占用大量堆内存。更推荐的方式是为需要精确匹配的字段单独定义Keyword子字段。
6.2 bool查询里must和filter混用导致评分异常
有一段时间我们线上搜索结果的相关性排序很奇怪,明明完全匹配“海底捞”的门店,却排在了只提到“海底捞式服务”的门店后面。排查下来发现,问题出在很多人把搜索词同时放进了must和filter:filter负责过滤,must负责评分,结果同一个条件被计算了两次,干扰了评分。解决方式其实很简单——一个查询条件只用一种身份存在。如果只是过滤,就放filter;如果要影响评分,就放must。不要两个都放。
这个问题的背后其实是理解上的一个误区:同学容易把filter直接等同于SQL里的AND,却忽略了它“不计分”的特性。正确的做法是,按需求把条件分成两类:一类是“结果里必须包含但用户并不在意它如何影响排序”的,如城市、营业状态、价格区间;另一类是“用户搜索时明确输入的关键内容”,如关键词,要影响评分。分开管理后,排序结果才符合直觉。
6.3 深分页报错
页码翻到几百页的时候,ES突然抛Result window is too large异常。这是from + size超过默认max_result_window(10000)导致的。对于用户端搜索,建议通过search_after方案解决;如果是后台导出场景,建议用Scroll游标方案。不过这两种方案都有各自的限制:search_after依赖排序字段保证稳定性,Scroll在快照期间数据更新可能不一致。
如果你的业务里页码不需要超过100页,那用最普通的from + size分页就完全足够了,不要为了形式上好看去上Scroll,复杂度会大很多。
6.4 时区差8小时
时间字段搜索范围差出8小时是开发环境里特别经典的问题。排查方向主要在两点:Es客户端连接串的时区配置,以及Java侧传入时间字符串的时区。ES默认时区是UTC,如果Java侧传入的字符串不带时区,ES会按UTC解析,但业务实际时间可能是东八区。最简单的统一办法是:在Java侧把时间转成Instant(带时区信息)再传入;索引映射的时间格式里明确指定时区;或者全局配置集群时区。只要保证写入、查询、展示三段都使用同一时区基准,这个坑就能彻底绕开。
6.5 字段名没有显式声明导致搜索不到
还有一次我们的查询条件明明正确、字段类型也正确,但结果为空,最后发现是实体里缺少@Field注解,框架默认把字段映射成了Keyword类型。所以当发现“类型看起来不对”的时候,先别急着怀疑ES,回去翻一下实体类上的字段注解,确认映射符合预期。尤其是新加的字段,最容易被遗漏。
7. 那个夜间上线后的教训:一次真实的故障复盘
最后这段不是额外总结,而是我实际经历过的一个教训。那次事故的起因很简单:我们要上线一个新搜索功能,我在本地测试时一切正常,数据没问题,查询速度也快。结果上线之后,第二天早上业务反馈“搜索不到新增门店”。我登录Kibana一查,索引里有数据,但查询结果就是少几条。排查了半天发现是新增门店的isOpen状态字段,在索引映射里是Text类型,而查询里用的termQuery。因为索引创建于最初版本,新字段靠动态映射生成,ES把布尔值自动映射成了Text。当时代码里没有显式声明字段类型,全靠ES猜。它猜的结果和我们查询的方式不匹配,数据就永远查不到。
那次之后我有了一个非常顽固的操作习惯:任何涉及到索引字段的改动,第一件事就是确认线上索引的真实映射。实体注解只是开发时的约定,真正生效的是索引里已经存在的mapping。如果索引是历史创建的,加新字段时必须通过ES的_mapping接口做增量更新,而不是指望实体类改一下就能自动同步。
还提醒一点:生产环境尽量不用“自动创建索引”这个能力。开发环境图省事可以,但在生产环境,索引的shard数量、分片副本、分词器、字段类型都应该经过设计后由脚本或配置统一管理。否则一旦出现映射和代码不一致的问题,线上搜索会变得非常被动。
如果让我给一个最快上手路径的建议:先搭一个最小可运行的SpringBoot工程,把ElasticsearchRestTemplate打通,然后照着本文第三、第四部分的内容跑通单条件、多条件、动态组合三个场景。跑通之后,再去研究聚合、排序、高亮这些附加能力。QueryBuilder学起来不复杂,真正有价值的是你能够在业务里熟练判断:哪个条件该放must、哪个该放filter、哪个字段该用term、哪个该用match。这些判断一旦形成肌肉记忆,ES条件查询这块基本就没什么能难住你的了。