写这篇东西的起因很简单前阵子接了个活儿帮某电商后台做订单检索模块数据量不算夸张但也到了百万级数据库里那几个LIKE查询已经明显拖慢了接口响应。我当时第一反应就是上ElasticSearch用SpringBoot做了一层薄封装核心查询全部走QueryBuilder。折腾完以后发现网上关于QueryBuilder的讲解大多停留在“有这些方法”的层面真正讲清楚什么时候用哪个、为什么这么用、踩了哪些坑的太少。这篇就把我实际用下来的完整思路和代码贴出来按我自己的踩坑顺序写希望对正在搞SpringBootES的人有点用。1. 集成准备与基础配置1.1 版本选型和依赖引入先聊版本问题。SpringBoot、SpringData Elasticsearch、ElasticSearch Server三者之间的版本关系可以说是整个集成环节里最容易翻车的地方没有之一。我见过太多人把SpringBoot 2.5配上ES 8.x的客户端结果启动直接报版本兼容错误然后一脸懵。我在这个项目里选用的组合是SpringBoot 2.6.13 SpringData Elasticsearch 4.4.x ElasticSearch Server 7.17.x。当初这么选主要是基于两点一是SpringData Elasticsearch 4.4对ES 7.x的支持非常成熟和稳定二是7.x版本里QueryBuilder体系的文档和社区案例最丰富遇到问题比较容易查到解决方案。Maven依赖就三个核心dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-elasticsearch/artifactId /dependency dependency groupIdorg.elasticsearch.client/groupId artifactIdelasticsearch-rest-high-level-client/artifactId version7.17.14/version /dependency dependency groupIdorg.elasticsearch/groupId artifactIdelasticsearch/artifactId version7.17.14/version /dependency这里有个坑必须提前说SpringData Elasticsearch仓库里虽然传递依赖了RestHighLevelClient但版本经常和你的服务端不一致最好的做法是像上面这样显式锁定版本确保客户端和服务端版本完全一致或者至少大版本号一致。我见过一个案例ES服务端是7.17客户端被传递依赖到了7.6结果部分QueryBuilder生成的DSL在服务端解析时直接报错。所以版本一致这条怎么强调都不过分。1.2 客户端连接配置配置文件我用了application.yml简洁直观spring: elasticsearch: uris: http://10.0.0.5:9200 connection-timeout: 3s read-timeout: 10s username: elastic password: xxxxxx如果ES开启了安全认证username和password必须带上否则每次请求都会报401。没有认证的话这两个配置不写就行。还有一个细节SpringData Elasticsearch 4.x支持自动装配ElasticsearchClient和ElasticsearchOperations你不用手动创建RestHighLevelClient的Bean。但如果像我一样需要自定义连接池参数建议还是手动声明一个RestHighLevelClient的Bean然后把SpringData的自动配置排除掉。我当时的连接池配置是这样的Configuration public class EsConfig { Bean(destroyMethod close) public RestHighLevelClient restHighLevelClient() { RestClientBuilder builder RestClient.builder( new HttpHost(10.0.0.5, 9200, http)) .setRequestConfigCallback(requestConfigBuilder - requestConfigBuilder .setConnectTimeout(3000) .setSocketTimeout(10000) .setConnectionRequestTimeout(3000)) .setHttpClientConfigCallback(httpClientBuilder - httpClientBuilder .setMaxConnTotal(200) .setMaxConnPerRoute(100) .setKeepAliveStrategy((response, context) - TimeValue.minutes(5).millis())); return new RestHighLevelClient(builder); } }setMaxConnTotal这个参数经常被人忽略默认只有20还是50来着并发上来以后连接不够用请求全堆积在等待队列里接口延迟就上去了。我在这里直接放到200实测下来单机够用如果你的服务实例多还可以再调低给其他实例留空间。1.3 文档模型与索引映射设计查询好不好写一半取决于索引映射设计得好不好。ES不像关系型数据库那样有严格的Schema约束但正因为灵活反而容易出现字段类型混乱的问题。比如我业务里的订单文档模型简化以后大概是这样Document(indexName order_info, createIndex false) public class OrderInfo { Id private String id; Field(type FieldType.Keyword) private String orderNo; Field(type FieldType.Text, analyzer ik_max_word) private String customerName; Field(type FieldType.Integer) private Integer orderStatus; Field(type FieldType.Double) private BigDecimal totalAmount; Field(type FieldType.Date, format DateFormat.date_optional_time) private LocalDateTime createTime; Field(type FieldType.Text, analyzer ik_max_word) private String goodsName; Field(type FieldType.Keyword) private String city; // getter/setter省略 }这里有几个设计原则exact value查询如订单号、状态码、城市一律用Keyword类型不要用Text否则TermQuery查不到精确值这是我见过最多的问题之一。Text类型的字段用于全文检索如果想支持中文分词需要配置IK分词器。你可以在创建索引时指定analyzer也可以在Field注解里指定。我推荐在Field里写死analyzer这样即便索引不是通过代码创建的注解也能起到元数据说明的作用。金额用Double在ES里其实是有精度隐患的但实际业务中金额一般用分来存储就是Long或Integer欧元美金这种有小数位的才需要Double。我这里为了演示直接用了Double生产环境建议换算成分。索引的创建我建议不要用SpringData自动建虽然设置createIndextrue能帮你省事但自动生成的映射往往不符合真实业务需求尤其是分词器、自定义分词规则这些还是得手动创建索引。下面是我在Kibana DevTools里执行的建索引语句IT从业者应该都能看懂{ settings: { number_of_shards: 3, number_of_replicas: 1, analysis: { analyzer: { ik_analyzer: { type: custom, tokenizer: ik_max_word } } } }, mappings: { properties: { orderNo: { type: keyword }, customerName: { type: text, analyzer: ik_max_word }, orderStatus: { type: integer }, totalAmount: { type: double }, createTime: { type: date, format: date_optional_time }, goodsName: { type: text, analyzer: ik_max_word }, city: { type: keyword } } } }shard数量我设了3对单节点环境来说没什么问题。如果只是本地开发一个shard就够了少一点还能省点资源。得注意副本数是1单节点环境下副本会处于未分配状态集群健康状态会显示yellow这是正常的不用慌。2. QueryBuilder条件查询的核心用法2.1 精确查询TermQuery与TermsQueryQueryBuilder是ES Java客户端构建查询DSL的入口在SpringData里最常用的封装是NativeSearchQueryBuilder配合各种QueryBuilder实现类。先看最基础的精确查询。TermQuery翻译过来就是“词条查询”它查询的是索引中某个字段的精确值不分词、不模糊。适合的场景有订单号精确匹配、用户ID查询、状态码查询、城市名查询。所有这些场景的数据在映射里必须定义为Keyword类型。QueryBuilder termQuery QueryBuilders.termQuery(orderStatus, 1);这行代码的意思是查所有orderStatus等于1的文档。注意这里的1是Integer如果你传字符串1ES会尝试做类型转换大多数情况下能成功但为了性能和不必要的转换建议类型保持一致。TermsQuery一次查多个值类似于MySQL里的INQueryBuilder termsQuery QueryBuilders.termsQuery(orderStatus, 1, 2, 3);这个就很有用了。比如业务里订单状态有0待支付、1已支付、2已发货、3已完成、4已取消前端做一个多选筛选后台直接拼一个termsQuery就搞定不用循环去OR。实际编码时我有两个习惯所有字段名写成常量类集中管理避免字符串散落各处。比如写一个EsField常量类把orderStatus、orderNo这些字段名定义好。项目大了以后字段改名或者重构的时候你就知道这个习惯有多香了。布尔查询里TermQuery作为叶子查询通常和BoolQuery组合使用因为实际业务很少只有一个筛选条件。2.2 全文检索MatchQuery与MultiMatchQuery和TermQuery的精确匹配不同MatchQuery是全文检索的核心。它会把查询语句先做分词再去索引里匹配分词后的词项。这个特性决定了它天然适合搜索类场景比如在前端搜索框里输入“华为手机”系统能智能分词出“华为”、“手机”然后匹配文档。QueryBuilder matchQuery QueryBuilders.matchQuery(goodsName, 华为手机);如果你有两三个字段都要做全文检索比如搜索商品时想在商品名称、商品描述、品牌名里同时搜那就用MultiMatchQueryQueryBuilder multiMatchQuery QueryBuilders.multiMatchQuery( 华为手机, goodsName, goodsDesc, brandName );MultiMatchQuery还有一个很实用的field增强语法可以给不同字段设置不同的权重。比如你觉得商品名称的匹配应该比描述更重要可以通过boost实现QueryBuilder multiMatchBoostQuery QueryBuilders.multiMatchQuery( 华为手机, goodsName^3, goodsDesc^1 );这样在相关性评分时goodsName命中的文档得分会乘以3排序时优先展示。这个技巧在做搜索功能时很常用但很多人不知道默认权重都一样出来的结果相关性差强人意。不过有个点要注意Text字段上的MatchQuery依赖于分词器。如果索引映射里没配IK分词器用默认的StandardAnalyzer那“华为手机”会被切分成“华”、“为”、“手”、“机”四个单字匹配结果会非常发散经常出现搜出来一堆不相关的内容。我一开始就吃了这个亏后来老老实实把所有中文检索字段都配上了IK分词。2.3 范围查询与大小比较查订单金额在1000到5000之间、查创建时间在某段时间内这些需求用RangeQuery最合适。它支持gte、gt、lte、lt四个操作符恰好对应大于等于、大于、小于等于、小于。先看数值范围查询QueryBuilder rangeQuery QueryBuilders.rangeQuery(totalAmount) .gte(1000) .lte(5000);再看时间范围查询QueryBuilder timeRangeQuery QueryBuilders.rangeQuery(createTime) .gte(2024-01-01 00:00:00) .lte(2024-12-31 23:59:59);ES对日期字符串的解析能力很强只要你的日期格式是标准的ISO或者你指定了format都没问题。但我实际开发中更推荐传入Long类型的时间戳避免日期格式在不同时区下被解析偏移。时区这事真的坑曾经有个需求查8点到9点的订单结果服务器时区是UTC查出来的是16点到17点的数据排查了半天才反应过来是时间格式被ES当成UTC解析了。如果你在Field注解里指定了format DateFormat.date_optional_time那传入yyyy-MM-dd HH:mm:ss格式没问题。想避免时区坑可以统一用epoch_millis格式QueryBuilder timeRangeByTs QueryBuilders.rangeQuery(createTime) .gte(System.currentTimeMillis() - 7 * 24 * 3600 * 1000L) .lt(System.currentTimeMillis());2.4 布尔组合查询BoolQueryBuilder实际业务中没有一个查询是只用一个条件的。比如查“已支付的城市为北京的华为手机订单”这就是三个条件的组合BoolQueryBuilder就是为了解决这个问题而存在的。BoolQueryBuilder有三个核心方法对应DSL里的三种逻辑关系must必须满足等价于AND且参与相关性评分filter必须满足等价于AND但不参与相关性评分只过滤数据should满足其一即可等价于OR可以设置minimumShouldMatch来控制至少满足几个BoolQueryBuilder boolQuery QueryBuilders.boolQuery() .filter(QueryBuilders.termQuery(orderStatus, 1)) .filter(QueryBuilders.termQuery(city, 北京)) .must(QueryBuilders.matchQuery(goodsName, 华为手机));注意到我用filter而不是must来筛状态和城市为什么因为filter不计算_score性能上比must好不少。对于状态、城市这种纯粹的属性筛选我们根本不需要相关性打分用filter就够了。而goodsName这种全文检索字段相关性打分决定排序才用must。should的用法也常见比如“订单备注包含加急或者优先”BoolQueryBuilder boolQuery QueryBuilders.boolQuery() .must(QueryBuilders.termQuery(orderStatus, 1)) .should(QueryBuilders.termQuery(remark, 加急)) .should(QueryBuilders.termQuery(remark, 优先)) .minimumShouldMatch(1);minimumShouldMatch(1)表示两个should条件至少要满足一个如果不设置这个参数且同时存在must条件时ES默认是should可以一个都不满足的这是很多人忽略的细节。2.5 模糊查询与通配符模糊匹配和通配符查询属于比较“重”的查询性能开销大一般不建议用在核心链路的高频查询上但在一些特定场景确实很好使。WildcardQuery支持通配符星号代表任意字符问号代表一个字符。比如查所有订单号以ORD2024开头的QueryBuilder wildcardQuery QueryBuilders.wildcardQuery(orderNo, ORD2024*);FuzzyQuery可以做模糊匹配允许一定程度的拼写错误编辑距离默认是2。比如用户搜索“大纵电视”可能是“大中电视”的错别字fuzzy能帮忙兜住这种错误QueryBuilder fuzzyQuery QueryBuilders.fuzzyQuery(goodsName, 大纵电视) .fuzziness(Fuzziness.ONE);这里把fuzziness设为ONE即编辑距离1允许一个字符的差异。不要用默认的AUTO它对中文场景经常匹配出意外结果。这个查询的性能开销与字段内容长度呈正比尤其是前缀匹配、模糊匹配这种无法利用倒排索引优势的查询。在数据量大的索引上要谨慎建议配合其他条件先缩小数据范围。通配符还有个坑如果搜索关键词里本身带了*或者?需要转义不然ES会把它当成通配符解析。可以利用QueryStringQuery语法转义但这里就不展开了实际开发中遇到再查也来得及。3. 完整实操一个订单检索功能从零到一3.1 场景设定与索引初始化我拿这个模拟项目完整走一遍核心逻辑是做一个订单管理后台的检索页。页面有这些筛选条件订单号模糊查询、客户姓名全文检索、订单状态多选、城市下拉、金额范围、创建时间范围再加分页和按创建时间倒序排列。第一步先把索引建好。在真实开发中索引的创建一般由DBA或者有ES权限的后端同学执行用Kibana的DevTools或者跑一个初始化脚本都行。为了保证可复现我上面已经给过创建索引的DSL了这里直接假设索引里已经有数据。往ES里灌数据就不展开了代码里可以批量调用saveAll方法。需要注意的是如果Id标记的字段没有值每次save都会生成新文档如果有值且存在相同ID的文档就是更新操作了。批量插入的性能优化也是一门学问。3.2 动态组合查询的实现重点来了。检索页的筛选项不是固定的用户可能填也可能不填。这就要求查询条件动态拼接不能写死。我在Service层把多个条件封装起来核心是用一个方法接收DTO参数然后根据每个字段是否有值来动态往BoolQueryBuilder里塞条件。先看查询参数DTOData public class OrderSearchDTO { private String orderNo; private String customerName; private ListInteger orderStatusList; private String city; private BigDecimal minAmount; private BigDecimal maxAmount; private LocalDateTime startTime; private LocalDateTime endTime; private Integer pageNum 1; private Integer pageSize 10; }再写Service里的核心查询代码我把整段逻辑完整贴出来再解释Service public class OrderSearchService { Resource private ElasticsearchRestTemplate elasticsearchRestTemplate; public PageOrderInfo searchOrder(OrderSearchDTO dto) { BoolQueryBuilder boolQuery QueryBuilders.boolQuery(); // 订单号模糊查询 if (StringUtils.hasText(dto.getOrderNo())) { boolQuery.must(QueryBuilders.wildcardQuery(orderNo, * dto.getOrderNo() *)); } // 客户姓名全文检索 if (StringUtils.hasText(dto.getCustomerName())) { boolQuery.must(QueryBuilders.matchQuery(customerName, dto.getCustomerName())); } // 订单状态多选 if (CollectionUtils.isNotEmpty(dto.getOrderStatusList())) { boolQuery.filter(QueryBuilders.termsQuery(orderStatus, dto.getOrderStatusList())); } // 城市精确匹配 if (StringUtils.hasText(dto.getCity())) { boolQuery.filter(QueryBuilders.termQuery(city, dto.getCity())); } // 金额范围 if (dto.getMinAmount() ! null || dto.getMaxAmount() ! null) { RangeQueryBuilder rangeQuery QueryBuilders.rangeQuery(totalAmount); if (dto.getMinAmount() ! null) { rangeQuery.gte(dto.getMinAmount()); } if (dto.getMaxAmount() ! null) { rangeQuery.lte(dto.getMaxAmount()); } boolQuery.filter(rangeQuery); } // 创建时间范围 if (dto.getStartTime() ! null || dto.getEndTime() ! null) { RangeQueryBuilder timeRange QueryBuilders.rangeQuery(createTime); if (dto.getStartTime() ! null) { timeRange.gte(dto.getStartTime()); } if (dto.getEndTime() ! null) { timeRange.lte(dto.getEndTime()); } boolQuery.filter(timeRange); } // 构建原生查询 NativeSearchQueryBuilder queryBuilder new NativeSearchQueryBuilder() .withQuery(boolQuery) .withPageable(PageRequest.of(dto.getPageNum() - 1, dto.getPageSize())) .withSort(SortBuilders.fieldSort(createTime).order(SortOrder.DESC)); SearchHitsOrderInfo searchHits elasticsearchRestTemplate.search( queryBuilder.build(), OrderInfo.class); return PageConverter.convert(searchHits, dto.getPageNum(), dto.getPageSize()); } }这里有几个细节值得展开第一订单号查询我用了wildcard前后都加*。说实话这个查询在数据量大以后性能不佳但后台管理系统的低并发场景尚可接受。如果要走线上高性能接口建议改成前缀查询prefixQuery。第二orderStatusList用termsQuery且放在filter里查多选状态既准确又高效算是标准做法了。客户姓名用matchQuery做全文检索也可以用IK分词后的关键词去匹配。第三金额和时间范围查询都用了RangeQueryBuilder而且用filter包裹。记住一个原则只要不需要算相关度得分一律扔进filter里ES的缓存机制会帮我们缓存filter的结果第二次查同样的条件会快很多。第四分页用的是PageRequest页码要减一因为ES的页码从0开始这是新手最容易忽略的细节代码写错就是第一页数据和接口定义不一致。排序默认按创建时间倒序这是后台系统的常见需求。3.3 聚合查询与去重计数QueryBuilder不仅用于条件过滤还能配合聚合查询做统计分析。比如后台页面上显示“所有订单状态的数量分布”用MySQL做这种GROUP BY可能慢在ES里就是一个TermsAggregation的事。NativeSearchQueryBuilder queryBuilder new NativeSearchQueryBuilder() .withQuery(QueryBuilders.boolQuery()); TermsAggregationBuilder aggregation AggregationBuilders.terms(status_count) .field(orderStatus) .size(10); queryBuilder.addAggregation(aggregation); SearchHitsOrderInfo searchHits elasticsearchRestTemplate.search( queryBuilder.build(), OrderInfo.class); Aggregations aggregations searchHits.getAggregations(); ParsedLongTerms statusCount aggregations.get(status_count); List? extends ParsedLongTerms.Bucket buckets statusCount.getBuckets();这段代码返回的是每个status值下的文档数量可以直接拿来渲染柱状图或者饼图。注意terms聚合对Keyword类型字段天然友好如果你的orderStatus是Integer聚合结果会通过keyAsString展示。我这里用到了聚合的size参数注意如果你是做网站类业务ES的terms聚合默认只返回前10个桶要把size设置成你期望的桶数量上限否则统计不完整。3.4 高亮展示查询命中内容搜索页需要把命中的关键词高亮显示比如客户姓名命中了“张”页面上要标红。ES提供了Highlighter能力NativeSearchQueryBuilder里也可以直接配置HighlightBuilder highlightBuilder new HighlightBuilder() .field(customerName) .preTags(span stylecolor:red) .postTags(/span); queryBuilder.withHighlightBuilder(highlightBuilder); SearchHitsOrderInfo searchHits elasticsearchRestTemplate.search( queryBuilder.build(), OrderInfo.class);拿到高亮片段的方法for (SearchHit hit : searchHits.getSearchHits()) { ListString highlightFields hit.getHighlightFields().get(customerName); if (CollectionUtils.isNotEmpty(highlightFields)) { String highlightedName highlightFields.get(0); // 这里就是带标签的客户姓名 } }高亮功能实际上是根据查询词做片段截取并加标签不会修改原始索引数据。注意你必须在matchQuery的字段上才能高亮如果字段是keyword类型或者没被索引为text是没法高亮的因为ES对keyword类型不会做分词。4. 常见问题与排查技巧4.1 字段类型不一致导致的查询失败这是我被人问得最多的一个问题现象是termQuery查一个text类型的字段结果什么都查不到。原因也很简单text字段在索引时会被分词器拆成多个词项termQuery查询的是不分词的精确词项那肯定对不上。比如我的例子中customerName是text类型用termQuery去查张三索引里实际存的是张、三两个词项你怎么查张三这个整体都不会命中。解决办法就是明确你的查询意图精确匹配某个整体值 → 用keyword类型的字段映射或字段注解里改type FieldType.Keyword全文检索 → 用matchQuery让ES对查询词也做分词有些场景你可能既需要精确匹配又需要全文检索可以在映射里对同一个字段使用multi-fields。比如customerName既配text又配keyword子字段查询时customerName用于matchcustomerName.keyword用于term。这也是我设计订单模型时的一个进阶技巧后续很多业务都用得上。4.2 超过10000条限制的问题ES默认限制了from size的最大值不能超过10000。后台管理系统的导出功能经常踩到这个坑用户一筛选就出几万条数据分页翻到后面就报错reason: Result window is too large, from size must be less than or equal to: [10000]有三种方案解决方案一是调大max_result_window参数PUT order_info/_settings { index.max_result_window: 50000 }方案二是用Scroll API做深度分页。如果你要导出全量数据scroll是唯一方案。ES会把数据快照放到内存里然后分批取。SpringData里对应的API是startScroll和continueScroll但7.x之后Recommended做法已经开始转向SearchAfter。方案三是使用SearchAfter做深度分页。它没有fromsize那种性能瓶颈通过上一页最后一个文档的排序值来获取下一页适合需要实时性的场景比如Web端滚动加载。但这种模式不支持跳页只能一页接一页往下翻。实际项目中后台管理我通常直接用方案一把窗口调到50000就够用了数据导出用方案二scrollWeb端海量数据滚动翻页用方案三searchAfter。4.3 连接池线程耗尽问题ES客户端底层用了Apache HttpClient的连接池默认连接数不高。高并发场景容易出现ConnectionPoolTimeoutException: Timeout waiting for connection from pool我自己调参的经验是用restHighLevelClient的Bean手动设置连接参数。上面已经给出了完整的配置代码我再强调两个关键参数setMaxConnTotal连接池总连接数建议设置为实例数乘以单实例预期并发量比如200setMaxConnPerRoute单个路由的并发连接数可以理解为对单个ES节点的最大连接数不要超过ES节点的最大线程数太多另外连接空闲时间也要设置默认不清理的话长时间占着连接不释放。我用的KeepAliveStrategy设置为5分钟既避免频繁建连也能及时回收空闲连接。4.4 查询DSL日志打印排查问题的第一步一定是先看ES到底执行了什么查询语句。SpringData把QueryBuilder对象封装得挺好但阅读困难。有个非常实用的调试技巧在你的配置文件里加上日志级别把ES请求的request body输出到日志里。logging: level: org.elasticsearch.client.RestClient: DEBUGDEBUG级日志会打印每个请求的完整DSL。如果你用的是RestHighLevelClient而SpringData的日志配置没有生效还可以在代码里手动获取DSLNativeSearchQuery searchQuery queryBuilder.build(); SearchRequest searchRequest new SearchRequest(); searchRequest.indices(order_info); searchRequest.source(searchQuery.getSource());然后直接把searchRequest的source打印出来它打印的JSON就是将要发给ES的完整DSL。有了DSL你可以搬到Kibana的DevTools里去执行、分析也可以对比预期逻辑找问题。排查慢查询的时候这个技巧价值极大。4.5 布尔查询must与filter混用时的坑最后再说一个我实际调试中的案例。有个同事在查询里同时用了must和filterfilter里筛了orderStatusmust里也筛了orderStatus两个条件不一致导致结果为空。他找了好久才意识到must和filter里的条件默认是AND关系不是覆盖关系。也就是说filter里查orderStatus1must里又查orderStatus2ES会认为需要同时满足最终结果自然是空集。遇到这类问题的排查思路很简单把DSL打印出来按条件逐个检查看有没有冲突的查询条件。另外在设计接口时最好明确哪些条件是过滤条件哪些是全文检索条件避免同一个字段被重复添加逻辑。写到这里该说的差不多都说透了。我个人实际用的感受是QueryBuilder这套东西上手并不难难的是真正理解每种查询背后的分词、索引和评分机制然后才能在合适的场景选对正确的查询类型。ES的坑多是出了名的但踩过之后把经验沉淀下来往后就顺了。如果你正在做SpringBoot集成ES的检索模块建议先把我说的字段类型设计原则想清楚再动手写查询逻辑会比边写边改省心得多。