之前我在一个电商项目的后端升级里,把服务从 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.9 | 8.8/8.9 | 5.1 |
| ES 8.10 - 8.11 | 8.10/8.11 | 5.2 |
| ES 8.12 - 8.13 | 8.12/8.13 | 5.3 |
| ES 8.14+ | 8.14+ | 5.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> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> <version>8.12.2</version> </dependency> <dependency> <groupId>jakarta.json</groupId> <artifactId>jakarta.json-api</artifactId> <version>2.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 List<String> 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.type=single-node" \ -e "xpack.security.enabled=false" \ -e "xpack.security.enrollment.enabled=false" \ -e "xpack.security.http.ssl.enabled=false" \ 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 用了 text,category 用了 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 把查询构造完全类型安全化了,你不需要手写任何 JSON,IDE 就能帮你做字段校验和自动补全。
SearchRequest 是整个查询链路的入口,它同时承载了查询条件、分页、排序、聚合、高亮这些能力。一个最基本的查询只需要指定索引和 query:
SearchResponse<Product> 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 查询把多个子条件组合起来。
SearchResponse<Product> 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 配置,返回结果里每个命中的文档就会带上高亮片段。
SearchResponse<Product> 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 页的时候,你还在用 from=9900, size=100 去查询,大概率会被 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)); SearchResponse<Product> resp = esClient.search(req.build(), Product.class); // 取当前页最后一条的排序值,作为下一页的 search_after List<String> sort = resp.hits().hits().getLast().sort(); req.searchAfter(sort); // 带着新的 search_after 发下一页请求 SearchResponse<Product> 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 | 全量导出、后台批量任务 | 快照会占用资源,用完必须 clear |
| PIT + search_after | 需要稳定视图的深度分页 | 配合 point_in_time 使用,避免数据漂移 |
5. 聚合分析与多条件筛选:贴近业务场景的组合实战
5.1 聚合的基本套路:terms、sum与date_histogram
搜索和聚合是 ES 最重要的两板斧。聚合的基本套路其实不难,它就是在 SearchRequest 上挂一个 aggregations 配置,ES 在完成查询后对结果集做统计,最后把统计结果一起返回。
常见的三种聚合分别是:terms 做分组计数,sum 做求和,date_histogram 做时间维度直方图。它们的代码写法都非常函数式。
SearchResponse<Void> 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 是 String,long 字段聚合出来的 key 是 Long,两者的实例类型完全不同。
所以解析聚合之前,最好先看一眼映射,或者用 instanceof 兜底。我在代码里通常写一个小的解析工具方法,根据 bucket.key() 的实际类型做分支处理,这样哪怕不同环境映射有差异,代码也不会炸。
5.4 聚合与query的编排技巧
聚合结果默认是在查询命中的文档集上计算的。如果你的页面是“搜索所有商品,同时统计每个分类的数量”,那直接把 query 和 aggregations 挂在同一个 SearchRequest 上就行。
但有一种场景需要特别注意:搜索“键盘”,同时统计“所有分类”的数量分布。这里的“所有”不是指“键盘”这个查询结果里,而是全量索引。这时直接用同一个 query 算出来的聚合结果是错的,因为聚合默认只在命中集合上做。
解决方案是让 query 和聚合用不同的范围:
SearchResponse<Void> 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 -Dincludes=co.elastic.clients如果你看到同一 groupId 下存在多个版本,就要在 pom 里显式排除传递依赖,或者直接在 dependencyManagement 里锁定 elasticsearch-java 的版本。锁版本这种方式最省心,Spring Boot 的 BOM 管不到那些不带版本号的传递依赖,你得自己声明。
6.2 Jackson日期序列化与字段命名
ES 8 Java API Client 默认用 JacksonJsonpMapper 做序列化,但如果你直接 new 一个默认的 ObjectMapper,Java 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 版本小升级中,都没有再被版本兼容和连接池问题折腾到半夜。技术方案都说到底是细节堆出来的,把最底层的那几步走稳,后面加功能才有底气。