做了几年 Java 后端,检索这块一直是“差点意思”的存在。最初用 Spring Data Elasticsearch 就是从 Repository 接口的派生方法开始的,findByName、findByStatus这种,写起来确实爽,但一旦需求变成“关键词 + 状态 + 时间范围 + 价格区间,而且每个条件都可选”,你算算要写多少个方法?我当时数了一下:三个可选条件就有 8 种组合,四个可选条件 16 种,总不能都派生成方法吧。于是只能转向ElasticsearchRestTemplate,用QueryBuilder手动拼接条件查询。今天这篇就围绕 SpringBoot + ElasticSearch 中 QueryBuilder 条件查询这个主题,把从 Repository 到原生查询的切换、各种 QueryBuilder 的选型、组合条件的写法,以及我在实际项目里踩过的坑一次讲清楚。适合正准备让搜索接口“活起来”的 Java 后端同学参考。
1. Repository 查询的边界与 QueryBuilder 的登场时机
很多初学者接触 Spring Data Elasticsearch,第一反应都是“先用 Repository”。这个思路本身没毛病,但它解决的只是“固定条件、单表式”的查询。一旦查询条件变成动态组合,派生方法就完全不够用了。
1.1 派生方法查询的“够用但不好用”
先举个最简单的例子。假设你有一个订单索引,需要按订单编号、状态、创建时间三个维度查询,用 Repository 派生方法可以写成这样:
public interface OrderRepository extends ElasticsearchRepository<Order, String> { List<Order> findByOrderNo(String orderNo); List<Order> findByStatusAndCreateTimeBetween(Integer status, Date start, Date end); List<Order> findByStatusOrOrderNo(String status, String orderNo); }看起来挺规整,但问题很快就暴露了:
- 三个条件里只要有一个“可选”,你就得把所有组合都列出来,方法数量呈指数膨胀。
- 想加一个排序、分页,虽然可以通过继承
Pageable参数实现,但条件逻辑依然被锁死在方法名里。 - 搜索关键词如果要同时命中多个字段(比如模糊搜“商品名称”又搜“商品描述”),派生方法几乎写不出来。
我在某公司的订单检索项目中就踩过这个坑。一开始全用 Repository 派生方法,每加一个筛选条件就要加好几个接口方法,代码越来越像“方法名仓库”,维护成本飙升。后来我意识到:当查询条件开始“动态化”的时候,就该切到 QueryBuilder 了。
1.2 从 Repository 到 ElasticsearchRestTemplate 的切换
Spring Data Elasticsearch 为我们提供了另外一套底层操作入口,就是ElasticsearchRestTemplate。它和JdbcTemplate的概念类似,直接操作文档和查询条件,不依赖接口方法名推断。
@Service public class OrderSearchService { private final ElasticsearchRestTemplate template; public OrderSearchService(ElasticsearchRestTemplate template) { this.template = template; } public SearchHits<Order> search(OrderQuery query) { // 用 QueryBuilder 构建条件 QueryBuilder queryBuilder = QueryBuilders.boolQuery(); // 组装完 queryBuilder 之后,再交给 NativeSearchQuery NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(queryBuilder) .build(); return template.search(searchQuery, Order.class); } }从这之后,条件就从“方法签名”变成了“代码逻辑”。条件存在,就拼进去;条件为空,就跳过。这才是搜索接口该有的形态。
2. QueryBuilder 家族图谱:常用条件查询选型逻辑
Elasticsearch 提供了十几种 QueryBuilder 实现,很多新手一上来就懵:什么时候用term,什么时候用match,什么时候用wildcard?选错的结果要么是查不到,要么是性能崩。我直接梳理一份选型逻辑,大家在项目里照着判断即可。
2.1 精确匹配:TermQuery 与 TermsQuery
TermQuery是 ES 里最基础的精确匹配查询。注意这个词:精确匹配。它不会做分词解析,直接把输入值作为一个完整词条去倒排索引里找。
典型场景:订单状态、用户ID、商品类目ID、是否删除标记这类“枚举型字段”。
QueryBuilder termQuery = QueryBuilders.termQuery("status", 2);如果你要匹配一个字段的多个值,比如一次查“状态为 2 或 3 且都合法”的场景,用TermsQuery:
QueryBuilder termsQuery = QueryBuilders.termsQuery("status", 2, 3);有两点必须注意:
termQuery对嵌套对象和其他复杂结构不友好,如果你有 Nested 类型字段,要走专门的NestedQueryBuilder。- 字符串类型在 ES 中如果被做了分词处理(
text类型),term查询大概率查不到完整短语,因为倒排索引里存的已经是分词后的词条。所以在设计索引映射时,需要精确匹配的字段要用keyword类型。
2.2 全文搜索:MatchQuery 与 MultiMatchQuery
MatchQuery才是真正的“搜索引擎”查询,适合对商品标题、文章内容、描述这类text类型字段做全文检索。它会先把查询词做分词,再去倒排索引里匹配,最后按相关度打分排序。
QueryBuilder matchQuery = QueryBuilders .matchQuery("title", "手机 壳") .operator(Operator.AND);operator参数值得单独说一下。默认情况下match的多个词是 OR 关系,也就是“手机”或“壳”任意一个命中,文档就会被检索出来,可能带来大量不相关结果。设置成AND之后,所有词都必须出现,更贴近用户的真实意图。
还有一个高频场景:用户输入一个关键词,想同时搜“商品标题”和“商品描述”,甚至“卖家昵称”。这时候用MultiMatchQuery最省事:
QueryBuilder multiMatchQuery = QueryBuilders .multiMatchQuery("手机壳", "title", "description", "sellerName");2.3 范围、通配符与前缀查询
范围查询 RangeQuery
只要是数字、日期、价格,几乎都离不开范围查询。比如查订单金额在 100 到 500 之间:
QueryBuilder rangeQuery = QueryBuilders .rangeQuery("amount") .gte(100) .lte(500);日期范围也类似,但要注意时区问题。ES 里日期统一以 UTC 存储,查询端如果直接传yyyy-MM-dd,通常会把零点误判成 UTC 零点,和东八区差 8 小时。稳妥的做法:查询入参带上ZoneOffset,或者直接传Instant。
通配符查询 WildcardQuery
有些场景需要对字符串做模糊前缀匹配,比如按订单号模糊搜索。WildcardQuery支持*和?:
QueryBuilder wildcardQuery = QueryBuilders .wildcardQuery("orderNo", "SO*2024");但这里必须提醒:通配符查询的性能非常不可控。如果通配符放在关键词开头,比如*2024,ES 没法直接走索引加速,只能全扫描匹配。数据量一大,这个查询能把整个集群拖慢。线上环境能不用就不用,如果只是要“前缀匹配”,优先选择PrefixQuery:
QueryBuilder prefixQuery = QueryBuilders .prefixQuery("orderNo", "SO2024");ID 批量查询 IdsQuery
按 ES 文档主键批量取数据,用IdsQuery最干净,避免循环单条查询:
QueryBuilder idsQuery = QueryBuilders.idsQuery() .addIds("order_001", "order_002", "order_003");3. 组合条件核心:BoolQueryBuilder 的逻辑拼接
前面讲的是“单个条件怎么查”,但实际业务没有一个搜索接口只传单个条件的。谁能把BoolQueryBuilder玩明白,谁就能在 Spring Boot 里把复杂搜索接口写顺。这一节我重点拆解组合查询,这也是标题“QueryBuilder 条件查询”里最核心的部分。
3.1 must/filter/should/must_not 的语义差异
很多初学者会混淆filter和must。从执行结果上看,它们都能筛选文档,但本质差异有三点:
| 特性 | must | filter |
|---|---|---|
| 相关性打分 | 参与打分,影响排序 | 不参与打分,命中即过 |
| 性能 | 相对较重 | 可以走缓存,性能更好 |
| 适用场景 | 全文检索、关键词相关度排序 | 状态、时间范围、类目等过滤条件 |
对于“订单状态”“创建时间范围”这类业务约束,强烈建议放进filter。它们对相关性没有帮助,留在must里只会白白增加算分开销。我见过很多线上慢查询,就是全把条件塞进must,其实换成filter之后查询时长能下降一半以上。
should则是“或”逻辑。在bool查询里,must和filter存在时,should起“加分项”作用;只有bool里没有任何must/filter时,should才真正表示“只要命中其中一个就算匹配”。
must_not含义很简单:取反过滤。但要注意,它同样不参与打分,和filter一样可以被缓存。
3.2 动态条件拼接的完整代码范例
说这么多不如直接上一段实际项目里能跑的代码。下面这个场景是我做过的一个商品搜索接口:三种条件均可选,分别是关键词(匹配标题和描述)、类目 ID 精确匹配、价格区间。用BoolQueryBuilder动态拼接:
public SearchHits<Product> searchProduct(ProductSearchParam param) { BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 1. 关键词:match 全文检索,放入 must,影响相关度排序 if (StringUtils.hasText(param.getKeyword())) { boolQuery.must(QueryBuilders.multiMatchQuery( param.getKeyword(), "title", "description" )); } // 2. 类目过滤:精确匹配,不影响打分,放入 filter if (param.getCategoryId() != null) { boolQuery.filter(QueryBuilders.termQuery("categoryId", param.getCategoryId())); } // 3. 价格区间过滤:范围查询,同样放入 filter if (param.getMinPrice() != null && param.getMaxPrice() != null) { boolQuery.filter(QueryBuilders.rangeQuery("price") .gte(param.getMinPrice()) .lte(param.getMaxPrice())); } NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(boolQuery) .build(); return elasticsearchRestTemplate.search(searchQuery, Product.class); }这段代码比你写几十个派生方法强在哪儿?每加一个新条件,只需要再加一段if拼接,而不是新写一个方法名。条件之间是“与”的关系,搜索意图清晰,后续加“排除某类目”“只搜上架商品”都是往里加逻辑的事。
3.3 分页、排序与 Source 过滤:NativeSearchQuery 的完整组装
条件拼接只是第一步。真实接口一定会涉及分页和排序。NativeSearchQueryBuilder里还有几个办法值得记住:
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(boolQuery) .withPageable(PageRequest.of(page, size)) .withSort(Sort.by(Sort.Direction.DESC, "createTime")) .withSourceFilter(new FetchSourceFilter( new String[]{"id", "title", "price"}, null)) .build();withPageable:分页参数,注意页码从 0 开始,前端传 1 的话要减 1。withSort:指定排序字段。如果同时想按相关度排序又按时间排序,ES 默认按_score排序,你也可以在withSort里追加Sort.by(Sort.DEFAULT_DIRECTION, "_score")。withSourceFilter:控制返回哪些字段。搜索引擎场景里,详情大字段往往不需要在列表接口返回,用这个过滤器能减少网络传输和内存占用,属于“做了就有收益”的优化。
分页还有一个隐藏问题。ES 的from + size深度分页,默认只支持到 10000 条,超过会直接报错。如果你真的需要翻很深,要考虑searchAfter或者游标查询,这块我在后面排错章节再细说。
4. 从能查到好用:性能与索引层面的实战取舍
QueryBuilder 只是把查询“写出来”了,能不能在线上扛住,是另一件事。这一节聊三个我在实际项目中反复踩到的场景:查询超时、深分页、以及分词/大小写对查询结果的影响。
4.1 深分页:from+size 的边界与 searchAfter 方案
先说结论:搜索接口永远不要做无限制深翻页。
ES 默认index.max_result_window是 10000。也就是说from + size超过 1 万条,查询会直接抛异常:
Result window is too large, from + size must be less than or equal to: [10000]这时候有三条路可以走:
- 限制最大分页深度,比如业务上就允许翻到前 1000 条,超出提示用户缩小条件。
- 改用
searchAfter,通过上一页最后一个文档的排序值,去取下一页数据。它本质上是一个“游标位移”方案,适合跳页不深、但连续翻页很多的场景。 - 用
Scroll,适合一次性导出大量数据的离线任务,不建议用在实时搜索接口里,因为 Scroll 会占用服务端资源直到 scroll 超时释放。
searchAfter在 Spring Data Elasticsearch 里的表达方式是这样的:
SearchAfterRequest searchAfterRequest = new SearchAfterRequest( searchQuery, new Object[]{lastSortValue}, Product.class );这要求你的排序字段必须唯一值稳定,通常在withSort里附加一个id排序兜底,避免相同排序值导致翻页错乱。
4.2 分词策略与大小写:为什么 term 查询会“查不到”
这是我见过最多的“假 bug”。明明索引里有这条数据,用match也能搜出来,但用term一查就是空。原因基本都出在字段类型上。
如果你的字段在索引映射里是默认的text类型,ES 写入时会用标准分词器做处理。比如"iPhone 15 Pro"可能被拆成"iphone"、"15"、"pro"三个词项,并且做了小写化。这时候你用:
QueryBuilders.termQuery("productName", "iPhone 15 Pro")它拿整串去做精确匹配,当然什么都匹配不到。
解决方案很直接:需要精确匹配、聚合、排序的字段,一定要在映射里单独设置keyword子字段:
{ "productName": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } }然后查询时用productName.keyword作为目标字段,才能实现真正意义上的精确匹配。
反过来,如果你发现某字段写入的是全大写,比如"ABC123",默认分词器会把词项转成小写"abc123",你查询时用大写的term依然查不到。这种情况下,要么写数据前就统一大小写,要么查询时也统一转小写,总之要在管道里定好规范,不能指望 ES 自动帮你兼容。
4.3 查询超时与慢查询的基本排查思路
QueryBuilder 写得再漂亮,查询一直超时也白搭。我常用的排查顺序是这样的:
- 先确认有没有条件进了
must但根本不需要打分,把仅用于过滤的条件挪到filter。 - 再检查字段有没有开启
fielddata。如果你对text类型字段做了terms聚合或排序,ES 会在内存里加载 fielddata,容易引发 OOM 或 GC 长暂停。解决办法是改用keyword子字段排序聚合。 - 确认通配符查询是否“前缀打头”。
*开头的WildcardQuery走不了索引,等于全表扫描,这种查询在百万级文档下响应时间几乎是不可控的。 - 检查单次查询返回的文档数量。列表页一次不要取超过百条,搜索场景别忘了用
withSourceFilter裁剪字段。 - 如果单索引数据量特别大,可以考虑按业务维度拆索引,比如按月份拆订单索引,查询时指定月份直接命中对应的别名,比在索引里加一堆过滤条件高效得多。
5. 落盘之前:动态拼装的坑与工程化建议
代码能跑、性能也调优之后,最后剩下的就是工程层面的稳妥性。QueryBuilder 系列代码写多了,你会发现最大的风险不是 API 记不住,而是“边界条件没人防御”。
5.1 字段名映射:Entity 字段和 ES 字段名的错位问题
Spring Data Elasticsearch 里,@Document注解标注的实体类,默认会把 Java 字段名转成 ES 字段名。比如createTime对应的 ES 字段就是createTime。但如果你用了类似@Field(name = "create_time")做显式映射,那么 QueryBuilder 里就必须写create_time,而不是createTime。
这种情况下,字符串散落在代码里特别容易写错,我最推荐的做法是把字段名抽成常量类:
public final class ProductFields { public static final String TITLE = "title"; public static final String CREATE_TIME = "create_time"; public static final String PRICE = "price"; private ProductFields() {} }查询时统一用常量,字段改名只改一处,全局生效。
5.2 空值条件与非法参数的防御
动态拼接最大的隐患是“传参为空却仍然构造了子查询”。比如前端没传价格上限,但你因为某处逻辑失误把maxPrice设成了0,rangeQuery.lte(0)会把所有正价商品过滤掉,产生一个“查得到但结果永远是空”的诡异 bug。
我的防御习惯是三层:入参校验、拼接前判空、构造完做必要检查。
if (param.getMaxPrice() != null && param.getMaxPrice() > 0) { boolQuery.filter(QueryBuilders.rangeQuery(ProductFields.PRICE).lte(param.getMaxPrice())); }另外,关键词如果只是空白字符串,匹配查询也会变成一个低效的“匹配所有”,同样先StringUtils.hasText判断再拼装。
如果项目里类似搜索接口很多,我会把“条件项”封装成 List 结构的谓词集合,走一个统一的 SearchCriteria 模型。这样条件增删不影响 Service 层方法签名,测试也更好覆盖。
5.3 可观测性:日志里别只打印 keyword
最后提一个平时不太注意,但排障时极其有用的习惯:把最终生成的 QueryBuilder 以 JSON 形式打到日志里。
log.info("search product query: {}", boolQuery.toString());QueryBuilder.toString()会输出完整的 JSON 格式查询体。线上出问题,你只要拉住日志就能拿到“ES 实际执行的查询语句”,再用 Kibana 的 Search Profiler 跑一遍,就能直观看到哪个子查询耗时最高。这个习惯帮我排查过至少三次线上问题,强烈建议大家都加上。
5.4 给初学者的几条“少走弯路”清单
写了这么多,最后整理几条我对 QueryBuilder 条件查询的总结性体会,谈不上系统教程,但都是从项目里砸出来的判断:
- 默认优先用
boolQuery.filter放过滤条件,只有真正需要相关性排序的字段才进must。 - 精确匹配用
term,全文搜索用match,这俩选错是最常见的“查不到”根因。 - 凡是动态条件,一律先判空再拼接,引入
StringUtils.hasText和!= null的习惯。 - 分页别贪深,超过万条直接换
searchAfter,别为难 ES 也别为难自己。 - 字段名用常量统一管理,不要字符串到处飞,否则重构一次想哭一次。
- 日志里一定要能看到查询 JSON,这是所有排障的起点。
我在实际项目里写完第一个完整的 QueryBuilder 搜索接口之后,最大的感受是:这套 API 真正把“查询”从“方法名消消乐”里解放了出来,代码结构和业务条件的映射关系变得非常直观。之后再做任何需要动态筛选的后台列表、前台搜索,我都会优先选择ElasticsearchRestTemplate + QueryBuilder的组合。它不一定是最炫的技术方案,但绝对是在 Spring Boot 工程里最容易维护、最容易排查问题的一条路。