同一段查询逻辑,在 7.x 的 RestHighLevelClient 里是一堆 setter,在 8.x 的 Elasticsearch Java Client 里是一串 lambda builder。前者 7.15 起标记废弃,后者是 8.x 唯一在维护的官方 Java 客户端。迁移不是换个 import 就完事:依赖坐标、请求构造、响应解析、异常模型四处都会断。
下面按迁移的实际顺序拆:先认清三代客户端的边界,再逐层对照代码,最后处理 Spring Boot 3 的版本对齐和检查清单。
1. 三代客户端的坐标与生命周期
| 代际 | 类 | 依赖坐标 | 状态 |
|---|---|---|---|
| 一代 | TransportClient |
org.elasticsearch.client:transport |
7.0 废弃,8.0 移除 |
| 二代 | RestHighLevelClient |
org.elasticsearch.client:elasticsearch-rest-high-level-client |
7.15 起废弃,7.17 是最后的版本线 |
| 三代 | ElasticsearchClient |
co.elastic.clients:elasticsearch-java |
8.x 主推 |
TransportClient 走的是节点间二进制协议(9300 端口),要感知集群拓扑,客户端与服务器版本必须严格一致。它被移除的原因是这套协议不做向前兼容承诺,每次大版本升级都要重编客户端。
RestHighLevelClient 改走 HTTP(9200),把协议问题转成了 REST 兼容性问题,这是它能活很久的原因。代价是它把整个 org.elasticsearch:elasticsearch 核心包拖进依赖树——那是个几十 MB 的服务器端 jar,客户端只需要其中的查询 DSL 类。
ElasticsearchClient 把 DSL 类重新生成了一遍(co.elastic.clients.elasticsearch._types.query_dsl),不再依赖服务器核心包。请求/响应对象全部是不可变 POJO + builder,序列化交给 JSON-P(jakarta.json)。
2. 依赖坐标差异
二代的 pom:
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.17.22</version>
</dependency>
<!-- 传递依赖里会带进 org.elasticsearch:elasticsearch 与 elasticsearch-rest-client -->
三代的 pom 需要三个坐标,缺一个就在运行期炸 NoClassDefFoundError 或 JsonpMappingException:
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.15.3</version>
</dependency>
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>2.1.3</version>
</dependency>
<dependency>
<groupId>org.eclipse.parsson</groupId>
<artifactId>parsson</artifactId>
<version>1.1.7</version>
</dependency>
| 坐标 | 作用 | 漏了会怎样 |
|---|---|---|
elasticsearch-java |
客户端本体 + 生成的 DSL 类 | 编译不过 |
jakarta.json-api |
JSON-P 接口 | NoClassDefFoundError: jakarta/json/Json |
parsson |
JSON-P 的运行时实现 | JsonProvider 找不到,序列化时抛 JsonException |
elasticsearch-rest-client |
底层 HTTP(三代仍依赖它) | 一般随 elasticsearch-java 传递进来,不用手写 |
jakarta.json 是 javax.json 的改名版(Jakarta EE 9 之后)。如果项目里还有 javax.json 的旧包,两个实现会在 ServiceLoader 阶段抢 JsonProvider,报"provider 不是唯一"。
3. 请求对象构造方式
| 维度 | RestHighLevelClient |
ElasticsearchClient |
|---|---|---|
| 入口对象 | SearchRequest |
SearchRequest(同名,不同包) |
| 构造方式 | new SearchRequest("article") + setter |
SearchRequest.of(b -> b.index("article")) |
| 查询体 | SearchSourceBuilder 独立对象 |
直接挂在 SearchRequest 的 query 字段 |
| 可变性 | 可变,可反复改 | 不可变,每次 .of() 出新对象 |
| 字段名 | 字符串("title")易拼错 |
仍是字符串,但类型检查更严(FieldValue) |
| 泛型 | 无(getSourceAsString) |
SearchResponse<T> 按类反序列化 |
三代最大的写法变化是没有独立的 source builder:SearchSourceBuilder 这个概念被吸收了,query、from、size、sort、aggregations 都是 SearchRequest 自己的字段。
4. 同一个 bool + match 查询,两种写法
需求:在 article 索引里查 title 含"Netty"且 status 为 published 的文档,取前 10 条。
二代:
SearchSourceBuilder source = new SearchSourceBuilder();
source.query(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("title", "Netty"))
.filter(QueryBuilders.termQuery("status", "published")));
source.from(0).size(10);
source.sort("publish_time", SortOrder.DESC);
SearchRequest request = new SearchRequest("article");
request.source(source);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
三代:
SearchRequest request = SearchRequest.of(s -> s
.index("article")
.from(0)
.size(10)
.query(q -> q.bool(b -> b
.must(m -> m.match(mm -> mm.field("title").query("Netty")))
.filter(f -> f.term(t -> t.field("status").value("published")))))
.sort(so -> so.field(fs -> fs.field("publish_time").order(SortOrder.Desc))));
SearchResponse<Article> response = client.search(request, Article.class);
三段对照值得记住:
| 概念 | 二代 | 三代 |
|---|---|---|
| bool 查询 | QueryBuilders.boolQuery() |
q.bool(b -> ...) |
| must 子句 | .must(QueryBuilder) |
.must(m -> m.match(...)),接收 Function 或 Query |
| 字段值 | String / 基本类型 |
FieldValue.of(...),多数场景有重载可直接传字面量 |
| 排序 | SortBuilder + SortOrder.DESC |
SortOrder.Desc(枚举命名改了大小写) |
| 类型绑定 | 运行时 getSourceAsString() |
编译期 SearchResponse<Article> |
must 接收的是 Function<Query.Builder, ObjectBuilder<Query>>,所以嵌套一层 m.match(mm -> ...)——这是三代写起来"括号变多"的根源,也是它能在编译期校验字段名的原因。
上面的代码按
elasticsearch-java8.15 的 API 形态编写;8.x 早期版本里SortOrder与FieldValue的重载略有差异,升级小版本时按对应版本的 javadoc 核对一次。
5. 响应解析差异
二代:
SearchHits hits = response.getHits();
long total = hits.getTotalHits().value;
for (SearchHit hit : hits.getHits()) {
String json = hit.getSourceAsString(); // 自己反序列化
Map<String, Object> map = hit.getSourceAsMap(); // 或拿 Map
float score = hit.getScore();
}
三代:
HitsMetadata<Article> meta = response.hits();
long total = meta.total().value();
for (Hit<Article> hit : meta.hits()) {
Article doc = hit.source(); // 已按 Article.class 反序列化
Double score = hit.score(); // 注意是 Double,可能是 null
}
| 差异点 | 二代 | 三代 |
|---|---|---|
| 命中集合 | response.getHits().getHits() |
response.hits().hits() |
| 总数 | getTotalHits().value |
hits().total().value() |
| 文档对象 | getSourceAsString() / getSourceAsMap() |
hit.source(),泛型反序列化 |
| 评分 | float,无命中时为 NaN |
Double,可为 null |
| 取字段 | 手动解析 JSON | hit.fields()(需在请求里 docvalue_fields 或 fields) |
最容易踩的是评分:二代 getScore() 返回 float,三代 score() 返回 Double 且可能为 null(比如 size=0 的聚合请求、或 track_scores=false 的排序查询)。直接拆箱会 NPE。
另一个坑是 hit.source() 需要 Jackson 能反序列化目标类。三代默认用 JacksonJsonpMapper,如果类里有 LocalDateTime,得自己注册 JavaTimeModule,否则抛 InvalidDefinitionException。
6. 异常模型
| 场景 | 二代 | 三代 |
|---|---|---|
| 索引不存在 | ElasticsearchStatusException(404) |
ElasticsearchException,error().type() 为 index_not_found_exception |
| 查询语法错 | ElasticsearchStatusException(400) |
同上,type() 为 parsing_exception |
| 版本冲突 | ElasticsearchStatusException(409) |
同上,type() 为 version_conflict_engine_exception |
| 连接失败 | IOException |
TransportException(IOException 的子类) |
| 获取原始响应 | e.getResponse() 返回 Response |
e.response() |
三代统一成一个 co.elastic.clients.elasticsearch._types.ElasticsearchException,把 HTTP 状态码收进 ErrorCause:
try {
client.index(i -> i.index("article").id("1").document(article));
} catch (ElasticsearchException e) {
String type = e.error().type(); // index_not_found_exception / version_conflict...
int status = e.status(); // 404 / 409
if ("version_conflict_engine_exception".equals(type)) {
// 走重试或乐观锁重建
}
} catch (TransportException e) {
// 网络层,可重试
}
二代把所有非 2xx 都压成 ElasticsearchStatusException,只能靠 getResponse().getStatusLine() 判状态码;三代给了结构化的 error().type(),分支判断不用再解析字符串。
7. Spring Boot 3 下的版本对齐
Spring Data Elasticsearch 5.x(对应 Spring Boot 3.x)内部已经切到 ElasticsearchClient,RestHighLevelClient 不再是它的依赖。这意味着一个 Boot 3 项目里可能同时存在两套客户端——旧代码用 RHLC、新代码用新客户端——两套各自建连接池,各自吃线程。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
版本关系要按你所用 Boot 版本的 spring-boot-dependencies POM 核对,大致是这条链:
| 层 | 谁管版本 | 注意 |
|---|---|---|
| Spring Boot BOM | spring-data-bom |
决定 Spring Data 大版本 |
| Spring Data Elasticsearch | 声明 elasticsearch-java 版本 |
5.x 对应 8.x 客户端 |
| 应用 pom | 可覆盖 | 覆盖后要保证客户端 minor 不高于服务端 |
覆盖的写法是显式钉版本,不要靠传递依赖碰运气:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.15.3</version>
</dependency>
</dependencies>
</dependencyManagement>
如果要保留 RHLC 对接 8.x 服务端,必须加兼容头,否则 8.x 会拒掉 7.x 的 Content-Type:
RequestOptions options = RequestOptions.DEFAULT.toBuilder()
.addHeader("Accept", "application/vnd.elasticsearch+json; compatible-with=7")
.addHeader("Content-Type", "application/vnd.elasticsearch+json; compatible-with=7")
.build();
这条兼容路径是过渡手段,不是长期方案:RHLC 7.17 之后不再发版,8.x 的新 API 它根本不知道。
8. 迁移检查清单
| # | 检查项 | 判定方式 |
|---|---|---|
| 1 | 依赖树里没有 org.elasticsearch:elasticsearch 核心包 |
mvn dependency:tree 搜 org.elasticsearch |
| 2 | 没有残留 org.elasticsearch.client.RestHighLevelClient 引用 |
grep -rn "RestHighLevelClient" src/ |
| 3 | 请求从 SearchRequest + SearchSourceBuilder 改成单层 builder |
编译期能过即可 |
| 4 | SortOrder.DESC 全部改成 SortOrder.Desc |
编译错误会直接指出 |
| 5 | hit.getScore() 的拆箱点改成判空 |
静态扫描 score() 调用 |
| 6 | 捕获块从 ElasticsearchStatusException 换成 ElasticsearchException + error().type() |
Review 全部 catch |
| 7 | 自定义 JsonpMapper 注册了 JavaTimeModule |
跑一次带时间字段的读写 |
| 8 | 连接池参数在新客户端重建(RestClientBuilder 的 setHttpClientConfigCallback) |
检查 Bean 定义 |
| 9 | 关闭旧客户端的 close() 调用 |
容器销毁阶段观察是否报错 |
9. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
启动报 NoClassDefFoundError: jakarta/json/Json |
只加了 elasticsearch-java,漏 jakarta.json-api |
补坐标 |
序列化时 JsonException: provider not found |
有 api 没有实现 | 加 parsson |
IllegalArgumentException: Provider ... not found 或 provider 冲突 |
同时存在 javax.json 与 jakarta.json |
排除旧包 |
JsonpMappingException / InvalidDefinitionException |
目标类有 LocalDateTime 等类型,mapper 没配模块 |
自定义 JacksonJsonpMapper 并注册 JavaTimeModule |
查询返回 0 条但服务端 _search 有数据 |
索引名/字段名拼错,三代不会在编译期校验字符串 | 用 _analyze/_mapping 对齐字段名 |
NullPointerException 在评分处 |
hit.score() 为 Double 且可能为 null |
判空或用 track_scores |
| 4xx 全部抛同一个异常、无法区分 | 还在按二代思路 catch ElasticsearchStatusException |
改 catch ElasticsearchException 读 error().type() |
| 8.x 服务端拒绝 7.x 客户端请求 | Content-Type 不带兼容版本 | 加 compatible-with=7 头,或直接迁移 |
10. 边界:什么时候不必迁移
| 场景 | 建议 | 理由 |
|---|---|---|
| 集群还停在 7.17 且两年内不升级 | 留在 RHLC,只做版本钉死 | 迁移收益为零,风险不小 |
只做 _cat、_cluster/health 这类运维调用 |
直接用 elasticsearch-rest-client 或 curl |
不需要 DSL 层 |
重度依赖 SearchSourceBuilder 的动态拼装 |
迁移要重写拼装逻辑,评估工时 | 三代不可变 builder 不适合"边算边加" |
| 大量聚合、脚本字段 | 迁移后逐个验证 Aggregation 泛型 |
聚合 DSL 的泛型嵌套最深,最容易写错 |
| 只需要写入日志 | 考虑 Bulk API 封装的轻量客户端 | 全量客户端对单场景是过重依赖 |
一句话:7.17 服务端 + RHLC 是能跑的组合,8.x 服务端 + RHLC 是能跑但不该跑的组ation。真正必须迁移的时点,是服务端跨过 8.0 那一刻。
11. 自测
RestHighLevelClient为什么要把整个服务器核心包拖进依赖树,三代怎么绕开的?- 三代只加
elasticsearch-java一个坐标会在哪一步失败,报什么? q.bool(b -> b.must(m -> m.match(...)))里两层 lambda 分别对应原来哪两个对象?hit.score()在什么情况下返回null,直接拆箱会怎样?- 三代如何区分"索引不存在"和"版本冲突",二代靠什么?
- Spring Boot 3 项目里同时存在两套客户端,会带来哪些隐性成本?