Spring Boot 3.x整合Elasticsearch 8实战:客户端迁移与聚合查询避坑指南
之前我在一个电商项目的后端升级里把服务从 Spring Boot 2.7 整体迁到 Spring Boot 3.2数据检索层也顺手从 Elasticsearch 7.10 提到了 ES 8.12。这两个版本跨度看着不算大但整合方式其实已经被官方推倒重来了一遍——以前项目里用得顺手的 High Level REST Client在 ES 8.x 里被正式标记为废弃Spring Data Elasticsearch 的自动配置路径也变了。这篇博文我就拿 Spring Boot 3.x 整合 ES 8.x 的完整实战来展开把依赖选型、连接装配、索引管理、查询聚合这条链路逐段走一遍同时把我在真实项目里踩过的版本、序列化、连接池这些坑一并记录下来。如果你是正准备把老项目升到 ES 8或者刚开始接触 Spring Boot 3.x 的检索模块这篇文章可以直接当参考手册用。我不打算只贴零散代码而是把每一步“为什么这么做”也讲清楚。1. ES 8.x客户端大换血整合前必须搞懂的几个关键变化1.1 老客户端为何被官方放弃在 ES 7.15 之前Java 项目里最常用的整合方式就是 TransportClient 和 High Level REST Client尤其是后者几乎成了 Spring Boot 项目的标配。但到 ES 8.0 正式发布后官方态度非常明确TransportClient 直接移除High Level REST Client 被标记为废弃不再增加任何新功能新项目一律推荐使用新的 Java API Client。High Level REST Client 的问题在于它本质上是把 RethinkDB 风格的请求对象再做一层高级封装但底层依然是老的 HTTP 请求模型。ES 8 改成了基于 protobuf 与 JSON 双协议并存的 Rust 生态客户端体系之后老客户端的维护成本越来越高官方已经没有精力让它继续跟上新特性。更现实的问题是ES 7.10 以后引入的绝大多数新能力比如异步搜索、kNN 向量检索、TSDB 时序数据优化在新客户端里都有原生支持而 High Level REST Client 永远停在 ES 7.x 的能力边界上。对于 Spring Boot 3.x 项目来说这个问题显得更紧迫。Spring Boot 3 基于 Spring Framework 6整个依赖体系已经切换到了 Jakarta EE 命名空间和旧版客户端里的 javax 命名空间摩擦很大。如果硬把老客户端往 Spring Boot 3.x 里塞光是依赖冲突就能让你折腾半天。1.2 新客户端的分层设计ES 8 的 Java API Client 在设计上分了好几层理解这个分层对排查问题特别有帮助。最底层是 RestClient也就是原来的 Low Level REST Client负责最基础的 HTTP 通信、负载均衡和故障转移。它在 ES 8 里仍然保留只是改成了内部组件。往上一层是 Transport用它把请求对象编码成 HTTP 请求并把响应解码回 Java 对象。再往上才是给业务代码直接使用的 ElasticsearchClient所有索引、文档、搜索、聚合操作都是通过它来调用。这种分层带来的一个好处是你可以非常方便地替换序列化方式。客户端默认使用 JacksonJsonpMapper但如果你项目里已经有一套自定义的 ObjectMapper完全可以把自定义的传进去让 ES 客户端的序列化行为和你业务系统保持一致。这一点后面讲序列化坑的时候还会再展开。另一个好处是调用链路上可以随时插入自定义逻辑比如拦截器、日志、认证信息。这些在 Spring Boot 项目里非常有用因为你可以在启动时把 ES 客户端配置成和其他 HTTP 客户端一样具备可观测性。1.3 版本对应关系该怎么选ES 8.x 的 Java API Client 版本虽然和服务器版本不完全绑定但官方强烈建议客户端与服务器保持同一主版本。这里说的不是小版本要一模一样而是主版本必须对齐跨大版本连会导致很多莫名其妙的序列化兼容问题。服务器版本Java API Client 推荐版本Spring Data Elasticsearch 推荐版本ES 8.8 - 8.98.8/8.95.1ES 8.10 - 8.118.10/8.115.2ES 8.12 - 8.138.12/8.135.3ES 8.148.145.4这里要特别提醒一句Spring Boot 3.2 的 BOM 里虽然管理了 Spring Data Elasticsearch 的版本但它带动的服务端依赖版本不一定能追上你部署的 ES 版本。比如某个新版本中服务端开始返回新的字段旧客户端因为缺少对应的模型类就会报 unknown named object 之类的错。保险做法是在 pom 里显式指定 elasticsearch-java 的版本优先保证和服务器版本同步。还有一点ES 8 从 8.10 开始对部分 API 做了调整比如某些聚合返回类型的内部样式有变化。如果你在 8.10 之后遇到客户端解析聚合结果报类型转换异常先检查一下是不是服务和客户端版本跨了 8.10 这个临界点。2. Spring Boot 3.x的依赖与连接配置版本对齐是第一道坎2.1 Maven依赖引入的正确姿势我是直接使用官方的 Java API Client没有走 Spring Data Elasticsearch 的 Repository 抽象这样对于复杂查询和聚合的掌控力度更大也能避开 Spring Data 在 ES 8 上那层薄薄的封装带来的很多异常语义。在 pom.xml 里需要引入两个依赖第一个是主要客户端第二个是 JSON-P 的基础库缺少第二个依赖时程序启动往往没有任何提示等到第一次发请求才会爆 NoClassDefFoundError。dependency groupIdco.elastic.clients/groupId artifactIdelasticsearch-java/artifactId version8.12.2/version /dependency dependency groupIdjakarta.json/groupId artifactIdjakarta.json-api/artifactId version2.1.3/version /dependency如果你在引入电报试过不加 jakarta.json-api 也能跑那是因为 Spring Boot 的 starter-json 里间接带上了某些 JSON-P 的实现类但这个依赖并不稳定不建议赌它。显式声明反而能避免后续升级 Spring Boot 小版本时出现意外的传递依赖变化。2.2 连接参数与 ElasticsearchClient 装配连接配置我习惯放到 application.yml 里用一个自定义配置类去接收。这样在测试环境切集群、换账号都很方便不用改代码。es: uris: - http://localhost:9200 username: elastic password: changeme connect-timeout: 5s socket-timeout: 60s对应的配置类Component ConfigurationProperties(prefix es) public class EsProperties { private ListString uris new ArrayList(); private String username; private String password; private Duration connectTimeout Duration.ofSeconds(5); private Duration socketTimeout Duration.ofSeconds(60); // 省略 getter / setter }然后新增一个配置类来构建客户端 Bean。这里我使用的是标准两步构建先创建 RestClient再创建 RestClientTransport最后用 transport 构建 ElasticsearchClient。Configuration EnableConfigurationProperties(EsProperties.class) public class EsConfig { Bean(destroyMethod close) public ElasticsearchClient elasticsearchClient(EsProperties props) { RestClientBuilder builder RestClient.builder( props.getUris().stream() .map(HttpHost::create) .toArray(HttpHost[]::new) ); builder.setRequestConfigCallback(cb - cb .setConnectTimeout(Math.toIntExact(props.getConnectTimeout().toMillis())) .setSocketTimeout(Math.toIntExact(props.getSocketTimeout().toMillis())) ); if (StringUtils.hasText(props.getUsername())) { String token props.getUsername() : props.getPassword(); byte[] encoded Base64.getEncoder().encode(token.getBytes(StandardCharsets.UTF_8)); builder.setDefaultHeaders(new BasicHeader(Authorization, Basic new String(encoded))); } RestClient restClient builder.build(); JacksonJsonpMapper mapper new JacksonJsonpMapper(buildObjectMapper()); RestClientTransport transport new RestClientTransport(restClient, mapper); return new ElasticsearchClient(transport); } private ObjectMapper buildObjectMapper() { ObjectMapper mapper new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } }这段代码里的 destroyMethod close 非常重要它保证应用关闭时 ES 客户端会主动释放 HTTP 连接池。不写的话Spring 容器在销毁 Bean 时不会调用 close你就会看到 Tomcat 停了但进程里还有一堆 TIME_WAIT 连接挂着。2.3 本地开发环境的ES 8怎么起本地调试我建议直接用 Docker 起单节点集群没必要在本地宿主机装一套完整的 ES。为了让开发阶段少点麻烦第一次启动时可以把安全认证关掉。docker run -d --name es8 \ -p 9200:9200 \ -e discovery.typesingle-node \ -e xpack.security.enabledfalse \ -e xpack.security.enrollment.enabledfalse \ -e xpack.security.http.ssl.enabledfalse \ docker.elastic.co/elasticsearch/elasticsearch:8.12.2关掉 HTTP 层的 SSL 之后application.yml 里的地址就是 http://localhost:9200不需要处理证书转发。开发环境下这样最快。但我要强调一句如果你需要模拟生产环境的 HTTPS 和账户密码流程就得重新开启 security。生产环境开启 HTTPS 后客户端那边要做证书信任配置这个我在第六节会专门讲。3. 索引与文档CRUD实战打通ES 8的读写链路3.1 索引创建与字段映射在 ES 8 里索引创建和字段映射依然用 JSON 表达只不过 Java API Client 把整个请求对象层变成了类型安全的构建器。一个常见的电商商品索引可以这样创建。boolean exists esClient.indices() .exists(e - e.index(products)) .value(); if (!exists) { esClient.indices().create(c - c .index(products) .mappings(m - m .properties(name, p - p.text(t - t)) .properties(category, p - p.keyword(k - k)) .properties(price, p - p.double_(d - d)) .properties(createdAt, p - p.date(d - d)) ) ); }这里有一点很容易被忽略name 用了 textcategory 用了 keyword。text 类型会分词用于全文检索keyword 类型不分词用于精确匹配、排序和聚合。如果你把商品名称做成 keyword搜索“机械键盘”时就必须输入全称才能精确命中这显然不符合需求。如果把商品分类做成 text那 terms 聚合出来的结果会被切成一堆单词也完全没法看。所以设计映射时先想清楚这个字段是给用户搜索用的还是给系统过滤用的。前者用 text后者用 keyword。价格用 double_时间用 date这几个都是最基础的类型。3.2 文档写入、更新与删除写入文档可以直接调 index 方法指定索引名、文档 ID 和对象。Java API Client 会自动把对象序列化成 JSON 发送给服务端。Product product new Product( p001, 无线机械键盘, 外设, 399.0, Instant.now() ); IndexResponse resp esClient.index(i - i .index(products) .id(product.getId()) .document(product) );document 方法接受任意 Object内部通过注册的 JacksonJsonpMapper 完成序列化。所以你的实体类不需要继承任何接口也不需要加 ES 注解POJO 就能直接落库。更新文档有两种思路。一种是整个文档覆盖写另一种是部分字段更新。部分更新更常用因为业务系统里往往只是改了个价格。esClient.update(u - u .index(products) .id(p001) .doc(Map.of(price, 379.0)), Product.class );注意 update 请求里 doc 放的是一个普通 Map而不是完整的 Product 对象。ES 收到后会把 Map 和已有文档做合并其他没传的字段保持不变。删除文档就相对简单了esClient.delete(d - d.index(products).id(p001));条件删除用 DeleteByQuery比如删除某个分类下所有过期的商品可以在 query 里组合 bool 和 range 条件和搜索请求的查询结构一模一样。3.3 批量写入的实践建议批量操作的性能提升非常明显。一次性提交 1000 条单条文档请求和 1 个 BulkRequest后者效率高出一个数量级。新客户端的批量构建器支持在一个操作里混入 index、update、delete非常灵活。BulkRequest.Builder br new BulkRequest.Builder(); products.forEach(p - br.operations(op - op .index(i - i.index(products).id(p.getId()).document(p)) )); BulkResponse resp esClient.bulk(br.build()); if (resp.errors()) { for (BulkResponseItem item : resp.items()) { if (item.error() ! null) { log.error(批量写入失败: id{}, error{}, item.id(), item.error().reason()); } } }bulk 的 size 控制是我在实践中反复调过的。一次提交的数据量建议在 5MB 到 10MB 以内或者条数控制在 1000 到 5000 条之间。数据量太大容易让 ES 禁掉内存缓冲区太小又体现不出批量优势。如果你拿不准可以先从 2000 条开始压测观察服务端 bulk 队列的积压情况再调整。3.4 refresh策略对实时性的影响写完文档之后有时候立刻搜索查不到这就是 refresh 的问题。ES 默认每秒自动刷新一次索引让新写入的文档可以被搜索到。如果你需要写完立即查可以在写入请求上带 refresh 参数。esClient.index(i - i .index(products) .id(p002) .document(new Product(p002, 有线鼠标, 外设, 99.0, Instant.now())) .refresh(Refresh.WaitFor) );Refresh.WaitFor 表示写入后等待下一次刷新完成再返回这样后续读操作一定能看到刚写入的数据。但它会牺牲一些吞吐量不建议在批量导入场景使用只在单条关键写入时用。生产环境里我通常的做法是普通导入完全不 care refresh让 ES 默认每秒刷新即可关键业务数据写入后如果需要回显再单独加 wait_for。还有一点经验别在循环里对每条文档都设置 refresh那会让写入性能直接降到大打折扣。4. 查询条件构造与全文检索把SearchRequest吃透4.1 从JSON字符串到类型安全的查询构造器ES 7 时代Java 项目里最常见的查询写法是拼 JSON 字符串或者用 Map 套 Map可读性和可维护性都很差。ES 8 的 Java API Client 把查询构造完全类型安全化了你不需要手写任何 JSONIDE 就能帮你做字段校验和自动补全。SearchRequest 是整个查询链路的入口它同时承载了查询条件、分页、排序、聚合、高亮这些能力。一个最基本的查询只需要指定索引和 querySearchResponseProduct resp esClient.search(s - s .index(products) .query(q - q.match(m - m.field(name).query(机械键盘))), Product.class );match 查询会把“机械键盘”这个短语先分词再对每个词做匹配返回包含“机械”或“键盘”的文档。这是 ES 全文检索的默认行为符合大多数搜索产品的预期。如果你想要的是整个短语必须连续出现就要用 match_phrase。4.2 结构化查询term、range、bool的组合实际业务里的搜索条件不会只有关键词一个维度往往是“名称包含某某 分类是某某 价格在某个区间”这就必须用 bool 查询把多个子条件组合起来。SearchResponseProduct resp esClient.search(s - s .index(products) .query(q - q.bool(b - b .must(m - m.match(t - t.field(name).query(机械键盘))) .filter(f - f.term(t - t.field(category).value(外设))) .filter(f - f.range(r - r .field(price) .gte(JsonData.of(200)) .lte(JsonData.of(500)) )) )), Product.class );这里有两个容易混淆的点。must 里的 match 会参与相关性评分结果会根据匹配程度排序。filter 里的 term 和 range 只是过滤条件完全不参与评分所以 ES 内部可以对它们做缓存性能上比 must 里塞一堆条件要高不少。能用 filter 表达的过滤条件就不要放进 must。第二个点是 range 的上下界参数用的是 JsonData.of() 包装。因为 ES 客户端的 range 条件数值字段要兼容 int、long、double、string 各种来源直接用原始值会有泛型推断麻烦包一层 JsonData 是最稳妥的写法。4.3 全文检索与高亮搜索页上常见的高亮需求Java API Client 也支持得挺好。给搜索请求加上 highlight 配置返回结果里每个命中的文档就会带上高亮片段。SearchResponseProduct resp esClient.search(s - s .index(products) .query(q - q.match(m - m.field(name).query(机械键盘))) .highlight(h - h .fields(name, f - f .preTags(em) .postTags(/em) .fragmentSize(50) ) ), Product.class );解析的时候从 hit.highlight() 里取对应字段的高亮内容有值就用高亮内容替换原始字段值没值就回退到原始字段。真机上跑的时候你会发现高亮片段的边界不是从字段开头算起的它会把命中的词附近的内容截取出来所以前端展示时最好配合省略号做上下文提示。这里要提醒一句全文检索效果好不好很大程度取决于分词器。默认 standard 分词器对英文很友好对中文就只会按字拆或者按整句拆效果非常差。生产环境如果检索的是中文内容建议给 text 字段配置 IK 分词器否则你会看到搜“键盘”匹配不到“机械键盘”这种尴尬情况。IK 分词器的插件需要你自己部署到 ES 的 plugins 目录下客户端这边只需要在映射里给 text 字段指定 analyzer 参数。4.4 深度分页的正确姿势用户翻到第 100 页的时候你还在用 from9900, size100 去查询大概率会被 ES 警告甚至拒绝。from size 在 ES 里的实现是先把 from size 条数据全部查出来然后丢弃前 from 条这个操作在深度分页时开销极大ES 默认限制最多只能查到第 10000 条。解决深度分页的标准方案是用 search_after它适合那种需要按顺序逐页滚动获取数据的场景。SearchRequest.Builder req new SearchRequest.Builder() .index(products) .size(50) .sort(s - s.field(f - f.field(createdAt).order(SortOrder.Desc))) .query(q - q.matchAll(m - m)); SearchResponseProduct resp esClient.search(req.build(), Product.class); // 取当前页最后一条的排序值作为下一页的 search_after ListString sort resp.hits().hits().getLast().sort(); req.searchAfter(sort); // 带着新的 search_after 发下一页请求 SearchResponseProduct next esClient.search(req.build(), Product.class);search_after 的关键点在于必须搭配 sort而且排序字段最好是有唯一性的否则数据在排序值相同的情况下容易漏。如果是要导出全部数据更合适的是 scroll也就是快照式批量拉取。但在 ES 8 里官方对 scroll 的态度也是尽量少用能用 search_after 就优先 search_after。分页方式适用场景注意点from size前 10000 条以内的浅分页默认索引层面有 max_result_window 限制search_after深度分页、实时滚动列表必须配 sort状态放在请求参数里scroll全量导出、后台批量任务快照会占用资源用完必须 clearPIT search_after需要稳定视图的深度分页配合 point_in_time 使用避免数据漂移5. 聚合分析与多条件筛选贴近业务场景的组合实战5.1 聚合的基本套路terms、sum与date_histogram搜索和聚合是 ES 最重要的两板斧。聚合的基本套路其实不难它就是在 SearchRequest 上挂一个 aggregations 配置ES 在完成查询后对结果集做统计最后把统计结果一起返回。常见的三种聚合分别是terms 做分组计数sum 做求和date_histogram 做时间维度直方图。它们的代码写法都非常函数式。SearchResponseVoid aggResp esClient.search(s - s .index(orders) .size(0) .aggregations(byCategory, a - a .terms(t - t.field(category).size(10)) .aggregations(totalSales, a2 - a2 .sum(sum - sum.field(amount)) ) ), Void.class );size(0) 表示我们只需要聚合结果不关心具体命中了哪些文档。字段名用 category它必须是 keyword 类型聚合才有意义。如果字段是 text 类型聚合时会报错或者得到一堆分词后的碎片值。5.2 一个具体的销售分析例子拿销售订单来说我做过一个按分类统计销售额看板这个需求的实现就是上面的 code 加一段解析逻辑。LongTermsAggregate byCategory aggResp.aggregations() .get(byCategory) .lterms(); for (LongTermsBucket bucket : byCategory.buckets().array()) { SumAggregate totalSales bucket.aggregations().get(totalSales).sum(); System.out.println(bucket.key() : totalSales.value()); }这里 get(byCategory) 返回的是 Aggregate 对象需要根据聚合类型调用 lterms() 或 sterms()。第一次写这段代码时容易犹豫因为 IDE 自动补全给出的方法很多容易选错。判断依据很简单keyword 字段的 terms 聚合走 sterms()数值类型的字段走 lterms()。如果你想把统计维度加上时间比如按月统计每个分类的销售额趋势那就再加一个 date_histogram 嵌套聚合。.aggregations(byMonth, a - a .dateHistogram(d - d .field(orderDate) .calendarInterval(DateHistogramInterval.Month) ) .aggregations(totalSales, a2 - a2.sum(sum - sum.field(amount))) )日期格式记得用 ISO 8601 或时间戳ES 8 对日期格式要求比 7.x 更严谨传错格式会直接报 parse 错误。5.3 keyword与long聚合返回类型的差异这个坑我在生产环境里踩过一次。当时一个简单的 terms 聚合解析时用 lterms() 去强转结果本地测试一直没问题联调环境里突然报 ClassCastException。排查到最后发现原因是班级目收集的字段是 keyword 类型但某个环境里这个字段被映射成了 long或者反过来。keyword 字段聚合出来的 bucket key 是 Stringlong 字段聚合出来的 key 是 Long两者的实例类型完全不同。所以解析聚合之前最好先看一眼映射或者用 instanceof 兜底。我在代码里通常写一个小的解析工具方法根据 bucket.key() 的实际类型做分支处理这样哪怕不同环境映射有差异代码也不会炸。5.4 聚合与query的编排技巧聚合结果默认是在查询命中的文档集上计算的。如果你的页面是“搜索所有商品同时统计每个分类的数量”那直接把 query 和 aggregations 挂在同一个 SearchRequest 上就行。但有一种场景需要特别注意搜索“键盘”同时统计“所有分类”的数量分布。这里的“所有”不是指“键盘”这个查询结果里而是全量索引。这时直接用同一个 query 算出来的聚合结果是错的因为聚合默认只在命中集合上做。解决方案是让 query 和聚合用不同的范围SearchResponseVoid resp esClient.search(s - s .index(products) .query(q - q.bool(b - b .must(m - m.match(t - t.field(name).query(键盘))) .filter(f - f.term(t - t.field(status).value(on_sale))) )) .aggregations(allCategories, a - a .terms(t - t.field(category).size(10)) ), Void.class );如果需求确实是“搜出来的是键盘统计的是全量分类”那就需要做两次查询或者用 post_filter。post_filter 是查询和聚合都执行完毕之后再对搜索结果做过滤所以聚合是基于未过滤的完整数据算的。这个理解起来有点绕但实际开发里非常有用。6. 真实项目里的避坑记录版本、序列化与连接池6.1 依赖冲突的排查链路Spring Boot 3.x 的 BOM 里管理了一堆组件版本Spring Data Elasticsearch 也在其中。它底层依赖的 elasticsearch-java 版本如果和项目里声明的版本不一致Maven 会按就近原则选取其中一个但另一个版本可能在你没意识到的地方被带入。我遇到过的现象是本地 pom 声明了 elasticsearch-java 8.12.2服务端也是 8.12但启动时报错说找不到某个请求构建类的构造器。看依赖树才发现Spring Data Elasticsearch 5.3 带了 8.11 的客户端两个版本共存导致编译时用的是新类运行时 ClassLoader 加载的却是旧类。排查这类问题最快的方式是看依赖树mvn dependency:tree -Dincludesco.elastic.clients如果你看到同一 groupId 下存在多个版本就要在 pom 里显式排除传递依赖或者直接在 dependencyManagement 里锁定 elasticsearch-java 的版本。锁版本这种方式最省心Spring Boot 的 BOM 管不到那些不带版本号的传递依赖你得自己声明。6.2 Jackson日期序列化与字段命名ES 8 Java API Client 默认用 JacksonJsonpMapper 做序列化但如果你直接 new 一个默认的 ObjectMapperJava 8 里用的 LocalDateTime 序列化就会出问题。Jackson 核心库不认 JavaTimeModule 以外的日期时间类型会报 Java 8 date/time type not supported 之类的异常。解决方式我上面已经给过在创建 JacksonJsonpMapper 时注入注册了 JavaTimeModule 的 ObjectMapper并且禁用 WRITE_DATES_AS_TIMESTAMPS让日期统一序列化成 ISO 格式字符串。还有一个很隐蔽的坑是字段命名策略。业务系统的字段名习惯用驼峰命名比如 createdBy但 ES 索引里字段名往往是下划线风格比如 created_by。默认 ObjectMapper 按字段名原样序列化两边不一致会导致你写入的是 createdBy查询时按 created_by 匹配永远查不到数据。我建议在 ObjectMapper 上统一配置 SNAKE_CASE 命名策略让实体类里写驼峰ES 索引里存下划线两边各取所长。实体类自己也会整体更符合 Java 代码规范。mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);如果某些字段的命名比较特殊不想被全局策略影响就在实体类字段上加 JsonProperty 显式指定。6.3 连接池配置与连接耗尽ES 的 RestClient 底层用的是 Apache HttpClient 5 的连接池机制默认最大连接数是 20不到对生产环境来说太少了。你的应用如果并发量稍微上来一点就会频繁出现连接等待超时。我一般在配置类里显式调大连接池参数builder.setHttpClientConfigCallback(hc - { hc.setMaxConnTotal(200); hc.setMaxConnPerRoute(100); hc.setKeepAliveStrategy((response, context) - Duration.ofSeconds(30).toMillis() ); return hc; });maxConnTotal 是整个连接池上限maxConnPerRoute 是每个目标节点最多的并发连接数。如果是单节点 ES这两个值设成一样就行。如果是集群perRoute 可以按节点数分摊但也要留出余量。还有一个我栽过的跟头是一个服务里注入了两个 ElasticsearchClient Bean一个查业务索引一个查日志索引结果两个 Bean 各自维护一套连接池流量高峰时把宿主机端口资源占满应用层疯狂报 Too many open files。后来我把两个客户端合并成一个 Bean按索引名区分请求连接池资源瞬间宽裕了。6.4 HTTPS证书与鉴权的那些坑ES 8 默认是开安全认证的如果你是照着官方快速开始文档启动的一不小心就会被 HTTPS 证书卡住。客户端访问 https://localhost:9200 时会抛 SSLHandshakeException因为它不信任 ES 自签的证书。开发环境想省事的话可以先把 HTTP 服务层 SSL 关掉就是我在第 2 节给的 Docker 启动命令。但如果你要联调测试环境就必须正确配置证书信任。一个比较稳妥的做法是把 CA 证书导入到 Java 的 truststore 里然后给 RestClient 指定这个 truststore。代码里也可以直接读证书文件构建 SSLContext。Path caPath Paths.get(/path/to/http_ca.crt); CertificateFactory cf CertificateFactory.getInstance(X.509); X509Certificate ca (X509Certificate) cf.generateCertificate( Files.newInputStream(caPath) ); KeyStore trustStore KeyStore.getInstance(KeyStore.getDefaultType()); trustStore.load(null, null); trustStore.setCertificateEntry(es-ca, ca); SSLContext sslContext SSLContexts.custom() .loadTrustMaterial(trustStore, null) .build(); builder.setHttpClientConfigCallback(hc - hc.setSSLContext(sslContext) );生产环境强烈建议走这一步别图省事在代码里信任所有证书。我在开发环境见过太多人图方便直接装了一个 NoopTrustManager这个习惯带到生产环境就是事故。鉴权方面除了 Basic 认证ES 8 还支持 API Key。如果你在配置类里用 setDefaultHeaders 设置 Basic 头那么所有请求都会带这份认证。如果某个索引的访问需要单独用 API Key可以在客户端外面再包一层按请求动态添加但这在 Spring Boot 里就属于进阶玩法了不展开。6.5 几个让检索层更稳的习惯整合的事讲完了我最后分享几条个人总结的实操习惯不算标准流程但确实帮我在项目里省了不少事。第一所有 ES 客户端的调用一定要封装成独立的仓储层不要散落在 Service 里。这样不管是切换版本还是加日志都只需要改一个地方。第二搜索接口务必配置超时时间。ES 集群偶发 GC 停顿或者负载升高时请求可能在服务端挂很久应用层如果没有超时控制线程池能被堵穿。客户端 socketTimeout 我一般设成 30 到 60 秒但业务接口层面如果再包一层 CircuitBreaker 会更稳妥。第三索引模板先规划好别让 ES 自动创建索引。ES 默认在写入文档时如果索引不存在会自动创建但自动创建的映射完全不可控keyword 字段会被猜成 text后续再改映射就得重建索引。生产环境建议关闭自动创建索引或者在服务启动时主动检查索引存在性不存在就按 Java API Client 创建。第四大促或高并发场景下查询不要一上来就要求精确到秒的实时性。ES 的默认 refresh 间隔是一秒如果你在写入之后立刻读取要走 refresh 状态确认流程。对大多数业务来说能接受 1 秒延迟的数据就不需要额外加 wait_for 拖慢写入。第五升级 ES 客户端版本之后线上验证不要只看业务接口正常最好加一个检索健康检查定时用 match_all 查询一个固定索引确认客户端和集群的通信链路和序列化兼容性都是健康的。这个检查在很多出问题的场景里都能提前暴露问题。这些习惯让我在后来几次 ES 版本小升级中都没有再被版本兼容和连接池问题折腾到半夜。技术方案都说到底是细节堆出来的把最底层的那几步走稳后面加功能才有底气。