☰
ES 8 Java API Client实战:从迁移到聚合的完整指南
2026/10/19 8:53:23 网站建设 项目流程

做后端项目做久了,你会发现一个现象:但凡需求里出现“搜索”“统计”“日志分析”这几个词,最后落地的方案大概率是 Elasticsearch。ES 的 REST 接口在控制台里写起来很直观,可一旦要嵌入 Java 工程,客户端的选择和用法就成了第一道坎。很多人用惯了老版本的 RestHighLevelClient,升级到 ES 8 之后发现它被移除了,换成新的 Java API Client 又搞不定序列化和聚合解析,卡在环境配置上好几天。

这篇文章我会用一套可以直接运行的工程代码,把 ES Java 客户端最常用的写入、查询、更新、删除和聚合 API 完整过一遍,涉及的版本坑、类型坑、序列化坑也会逐个拆解。适合正在做 ES 8.x 对接的后端开发,也适合刚从 RestHighLevelClient 迁移过来想快速上手的朋友。全文代码都是我在生产环境里实际跑过的写法,不是那种只讲原理不给例子的文档,你照着抄基本就能通。

1. 客户端选型:为什么我抛弃了 RestHighLevelClient

1.1 三代客户端的演进

ES 官方 Java 客户端这十年换了三代,很多老代码还在用第一代的 TransportClient,也有不少人停留在 RestHighLevelClient。先说清楚它们之间的关系,你才知道自己手里的代码属于哪个时代。

客户端适用服务端版本现状
TransportClientES 1.x ~ 7.x已经废弃,7.x 版本彻底移除
RestHighLevelClientES 5.x ~ 7.x7.15 标记废弃,8.0 版本移除
Java API ClientES 7.16+ / 8.x官方当前主推,持续维护

RestHighLevelClient 我在项目里用了挺长时间,它的问题在于所有请求都靠一个 Client 暴露一大堆方法,参数靠大量的 Builder 组合,响应封装得也比较粗糙,你想拿个聚合结果得一层层 cast。到了 8.x 它直接被移除,官方把重心放到了新的 elasticsearch-java 上,所有请求和响应都基于协议规范自动生成代码,类型安全做得更彻底。

1.2 新版客户端的核心设计

新客户端最明显的特征就是 Builder 链式写法,每个请求都是一个 Request 的 Builder,每个响应都是一个强类型对象。比如写文档用的 IndexResponse,聚合解析用的 Aggregate,字段类型在编译期就是确定的,不像老客户端那样到处是 Map、List 里面套 Object。

它的底层仍然通过 HTTP 与 ES 通信,但把 JSON 序列化和反序列化交给了 JsonpMapper。默认实现是 JacksonJsonpMapper,所以工程里必须引入 Jackson 的依赖。这个依赖问题我在后面专门说,很多人启动报 ClassNotFound 就是漏了这一步。理解了这两点,下面初始化代码你就能看懂为什么需要三层对象了。

2. 项目初始化:Maven 依赖、连接配置与序列化器

2.1 依赖引入:两个必不可少的东西

先看 pom.xml 里的核心依赖。我用的是 ES 8.11 的服务端,客户端版本必须和服务端保持同一个大版本,不然会有协议兼容问题。

<dependency> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> <version>8.11.2</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.3</version> </dependency>

第一个是官方 Java API Client,第二个是 Jackson。很多人只加了第一个依赖,结果一执行就报ClassNotFoundException: com/fasterxml/jackson/databind/ObjectMapper,因为 JacksonJsonpMapper 需要 jackson-databind 在 classpath 里。这是新客户端最容易踩的第一个坑。

2.2 Client 初始化与超时参数

初始化代码我封装成了一个静态类,整个应用复用同一个 ElasticsearchClient 实例。这个类是线程安全的,不要每次都 new,连接资源会被白白浪费。

package com.example.esdemo.config; import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.json.jackson.JacksonJsonpMapper; import co.elastic.clients.transport.ElasticsearchTransport; import co.elastic.clients.transport.rest_client.RestClientTransport; import org.apache.http.HttpHost; import org.elasticsearch.client.RestClient; public class ESClientHolder { private static final ElasticsearchClient CLIENT; static { RestClient restClient = RestClient.builder( new HttpHost("localhost", 9200, "http") ).setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder .setConnectTimeout(5000) .setSocketTimeout(120000) ).build(); ElasticsearchTransport transport = new RestClientTransport( restClient, new JacksonJsonpMapper() ); CLIENT = new ElasticsearchClient(transport); } public static ElasticsearchClient getClient() { return CLIENT; } }

三个参数值得说下:connectTimeout 是建立连接的超时,默认值有时候偏小,网络抖动一下就挂;socketTimeout 是等待响应的超时,这个一定要给足,后面聚合查询和深分页都比较耗时,我生产环境里设的是 120 秒。RestClientTransport 是 HTTP 层和客户端之间的桥,JacksonJsonpMapper 负责把 Java 对象序列化成 ES 认识的 JSON。

2.3 连接安全:8.x 默认认证怎么配

ES 8.x 起,安全认证默认是开启的,本地装完会生成一个 elastic 用户的密码。很多人用新客户端连本地 ES,啥都没配,结果报 SSL 握手失败或者认证失败。如果服务端开了 HTTPS 和认证,初始化要改成下面这样:

import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.CredentialsProvider; import org.apache.http.impl.client.BasicCredentialsProvider; CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("elastic", "你的密码")); RestClient restClient = RestClient.builder(new HttpHost("localhost", 9200, "https")) .setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder .setDefaultCredentialsProvider(credentialsProvider) .setSSLContext(createSslContext())) .build();

如果是本地开发,不想折腾证书,最简单的方式是在配置文件里关闭 security 再重启 ES。生产环境建议用 API Key 或更细粒度的账号权限,不要拿超级用户到处跑。

3. 增删查改四件套:从单条写入到批量操作的完整实现

3.1 先建索引:Mapping 决定后续能不能聚合

实际操作里,我建议先显式创建索引和 Mapping,而不是依赖 ES 自动推断类型。自动推断的坑在聚合那一步会集中爆发,比如 text 字段没法直接做 terms 聚合。

client.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("stock", p -> p.long_(l -> l)) .properties("createTime", p -> p.date(d -> d)) ) );

这里我把 name 设置为 text 用于全文搜索,category 设置为 keyword 用于精确匹配和分组聚合,price 和 stock 分别是 double 和 long,createTime 是 date。这个映射和后面的查询、聚合代码是配套的,你改字段类型的时候要同步思考这些操作会不会受影响。

3.2 写入文档:Index 与幂等性

写入文档用 index 方法,核心参数是 index 名称、文档 ID 和业务实体对象。

Product product = new Product(); product.setId("1001"); product.setName("无线机械键盘"); product.setCategory("外设"); product.setPrice(499.0); product.setStock(200L); product.setCreateTime(LocalDateTime.now()); IndexResponse response = client.index(i -> i .index("products") .id(product.getId()) .document(product) ); System.out.println(response.result());

结果是枚举类型,CREATED表示首次创建,UPDATED表示同 ID 文档被覆盖。这隐含了一个重要特性:ES 的写入不是追加,ID 相同会直接覆盖整篇文档。所以业务上你要自己保证 ID 的生成策略是稳定的,否则每次更新数据都会丢字段。

3.3 查询文档:GetResponse 的判空

按 ID 查询用 get 方法,查出来的结果可以反序列化成实体类。

GetResponse<Product> response = client.get(g -> g .index("products") .id("1001"), Product.class); if (response.found()) { Product product = response.source(); System.out.println(product.getName()); } else { System.out.println("文档不存在"); }

这里一定要先判断found()再拿source()。有些新手直接调 source(),文档不存在的时候返回 null,后面一访问属性就空指针。还有一点要注意,如果索引里存的时候字段缺失,反序列化出来的对象里那个字段就是 null,业务侧要做好兜底。

3.4 更新与删除:局部更新和物理删除

更新用 update 方法,和 index 覆盖不同的是,update 默认是合并字段,不会把整篇文档替换掉。

Product updateData = new Product(); updateData.setPrice(459.0); UpdateResponse<Product> response = client.update(u -> u .index("products") .id("1001") .doc(updateData), Product.class);

这段代码只更新 price 字段,name、category、stock 都保持不变。一个容易被忽视的细节:Jackson 序列化默认会把实体类里的 null 字段也序列化进 JSON,如果你的 updateData 里某个字段没赋值,它也会被当成“把该字段置为 null”写进 ES。所以做局部更新时,要么只给要改的字段赋值,要么给 update 单独建一个 DTO,而不是复用完整实体。

删除就简单多了:

DeleteResponse response = client.delete(d -> d .index("products") .id("1001") ); System.out.println(response.result());

结果可能返回DELETED、NOT_FOUND两种,删除不存在的文档不算报错,业务上要根据返回值决定是继续流程还是抛异常。

3.5 批量接口:Bulk 的性能与错误检查

单条写入在数据量大的时候性能很差,正确做法是用 bulk 批量提交。批量接口支持混合操作,索引、更新、删除都能放进去。

List<Product> products = buildProductList(); BulkRequest.Builder bulkRequestBuilder = new BulkRequest.Builder(); for (Product product : products) { bulkRequestBuilder.operations(op -> op .index(i -> i .index("products") .id(product.getId()) .document(product) ) ); } BulkResponse bulkResponse = client.bulk(bulkRequestBuilder.build()); if (bulkResponse.errors()) { for (BulkResponseItem item : bulkResponse.items()) { if (item.error() != null) { System.out.println("文档 " + item.id() + " 写入失败:" + item.error().reason()); } } }

批量接口有个关键点:整体响应只会告诉你有没有错误,具体的失败项要看 items 里的 error。我刚开始用的时候只判断了errors(),以为批量执行有失败会抛异常,结果数据少了一批完全没发现。生产环境里批量写入一定要逐条检查失败项,并做好重试或告警。

4. 查询与搜索:Match、Term、Bool 组合和分页排序

4.1 搜索请求的整体结构

增删查改只是基础,ES 的真正价值在搜索和聚合。search 方法返回 SearchResponse,里面既有命中的文档,也有聚合结果。一个搜索请求通常包含 index、query、分页、排序、高亮这些部分。

SearchResponse<Product> response = client.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(v -> v.stringValue("外设")))) .mustNot(mn -> mn.range(r -> r .number(n -> n.field("price").lt(300.0)))) ) ) .from(0) .size(20) .sort(so -> so.field(f -> f .field("price") .order(SortOrder.Desc))), Product.class ); for (Hit<Product> hit : response.hits().hits()) { Product product = hit.source(); System.out.println(product.getName() + " : " + product.getPrice()); }

这个请求的含义是:搜索 name 字段匹配“键盘”、category 精确等于“外设”、价格不低于 300 的商品,按价格降序返回前 20 条。

4.2 Match 与 Term 的本质区别

很多新手分不清 match 和 term,用错之后查出来的结果诡异无比。简单理解:match 是全文搜索,查询词会被分词器拆开再匹配,适合 name、content 这类 text 字段;term 是精确查询,不做分词,适合 category、status 这类 keyword 字段。

比如上面代码里,name 用 match,“键盘”会被拆成单字或词组去倒排索引里找;category 用 term,必须和“外设”这个完整词完全一致才能命中。如果你拿 term 查一个 text 字段,大概率什么都查不到,因为 text 字段在索引时已经被分词,存的不是原始字符串。这就是为什么我在建索引时把类型分开定义。

4.3 Bool 组合查询的典型写法

一个搜索需求往往包含多个条件,bool 查询就是用来组合这些条件的。must 表示必须满足且参与打分,filter 表示必须满足但不参与打分,should 是可选条件,mustNot 是排除条件。

BoolQuery.Builder boolQuery = new BoolQuery.Builder(); boolQuery.must(m -> m.match(t -> t.field("name").query("鼠标"))); boolQuery.filter(f -> f.term(t -> t .field("category") .value(v -> v.stringValue("外设")))); boolQuery.filter(f -> f.range(r -> r .number(n -> n.field("price").gte(100.0).lte(500.0)))); boolQuery.mustNot(mn -> mn.term(t -> t .field("status") .value(v -> v.stringValue("下架")))); SearchResponse<Product> response = client.search(s -> s .index("products") .query(q -> q.bool(boolQuery.build())), Product.class );

一个实用建议:纯过滤条件尽量放 filter 而不是 must,比如类目、价格区间、状态。filter 不会参与相关度打分,ES 内部可以对它做缓存,查询性能明显更好。我优化线上接口时,把一批 should 条件改成 filter 后,响应时间降了一半。

4.4 分页、排序与高亮

分页就是 from 和 size 两个参数,from 表示跳过多少条,size 表示返回多少条。排序用 sort 指定字段和顺序,可以叠加多个排序条件。

这里要提醒一句:深分页是大坑。from 到一万以后性能断崖式下跌,因为 ES 要在每个分片上先把 from+size 条数据都找出来排序再合并。生产环境里滚动翻页建议用 search_after 或者 scroll,而不是无脑加大 from。这个后面踩坑部分我还会细说。

高亮也很常用,搜索关键字后希望把命中的词标红。高亮配置在 search 请求上加 highlight:

SearchResponse<Product> response = client.search(s -> s .index("products") .query(q -> q.match(t -> t.field("name").query("键盘"))) .highlight(h -> h .preTags("<em>") .postTags("</em>") .fields("name", f -> f) ), Product.class ); for (Hit<Product> hit : response.hits().hits()) { System.out.println(hit.highlight().get("name")); }

hit.highlight() 返回的是一个 Map,key 是字段名,value 是带高亮标签的内容列表。前端拿到带 em 标签的文本后,套用样式就能实现关键字标红效果。

5. 聚合分析:Terms 分组、子聚合与日期直方图

5.1 聚合的整体结构:size 置零是关键

聚合是 ES 另一个杀手级能力,常用于统计报表。聚合请求和搜索请求结构相同,只是 query 可以省略或者只做过滤,然后通过 aggregations 参数定义统计逻辑。

一个最常见的需求:按类目统计商品数量、平均价格、总库存。对应的聚合是 terms 分组,组内再嵌两个子聚合:avg 求平均价格,sum 求总库存。

这里要注意,纯聚合查询一定要把 size 设为 0,这样 ES 就不会返回命中的明细文档,省掉大量网络和内存开销。我见过有人忘了设 size,一次聚合查询拉回几十万条文档,服务直接被打挂。

5.2 Terms 分组与 Bucket 解析

看代码:

SearchResponse<Void> response = client.search(s -> s .index("products") .size(0) .aggregations("group_by_category", a -> a .terms(t -> t .field("category") .size(10) ) .aggregations("avg_price", a1 -> a1 .avg(avg -> avg.field("price")) ) .aggregations("total_stock", a1 -> a1 .sum(sum -> sum.field("stock")) ) ), Void.class );

第二个参数我传的是 Void.class,因为没有查询明细的需求,聚合结果是放在 response.aggregations() 里的,跟命中文档的类型无关。

解析聚合结果的老手都知道,聚合响应要一层层取出来:

Aggregate groupAgg = response.aggregations().get("group_by_category"); TermsAggregate termsAgg = groupAgg.terms(); for (TermsBucket bucket : termsAgg.buckets().array()) { String category = bucket.key().stringValue(); long docCount = bucket.docCount(); double avgPrice = bucket.aggregations().get("avg_price").avg().value(); double totalStock = bucket.aggregations().get("total_stock").sum().value(); System.out.printf("%s: 商品数=%d, 平均价格=%.2f, 总库存=%.0f%n", category, docCount, avgPrice, totalStock); }

bucket.key().stringValue()取分组的 key,bucket.docCount()取分组内文档数,子聚合结果要通过bucket.aggregations().get("子聚合名")再往下取。这一段是很多新手最容易卡住的地方,报错经常是类型转换失败,核心原因就是没搞清聚合响应的嵌套结构。

5.3 子聚合:组内求均值、求和

上面的代码已经展示了子聚合的写法:在 terms 聚合的 builder 里继续调用 aggregations 方法,可以无限往下嵌套。比如在“按类目分组”的基础上再按“上架时间”二次分组,实现下钻分析。

子聚合的类型不仅限于 avg、sum,常用的还有 min、max、cardinality、percentiles。percentiles 常用于接口耗时分析,cardinality 用于去重计数。这些统计因为是在每个分组内单独计算的,所以叫子聚合,理解成 SQL 里的GROUP BY 类目 HAVING AVG(price) > xxx就顺了。

5.4 日期直方图:按时间维度统计

报表里按天、按月统计是刚需,对应 date_histogram 聚合。下面这段按创建时间统计每月商品数:

import co.elastic.clients.elasticsearch._types.aggregations.CalendarInterval; import co.elastic.clients.elasticsearch._types.Time; SearchResponse<Void> response = client.search(s -> s .index("products") .size(0) .aggregations("orders_per_month", a -> a .dateHistogram(d -> d .field("createTime") .calendarInterval(Time.of(t -> t.interval(CalendarInterval.Month))) .format("yyyy-MM") ) ), Void.class ); DateHistogramAggregate histogram = response.aggregations() .get("orders_per_month") .dateHistogram(); for (DateHistogramBucket bucket : histogram.buckets().array()) { System.out.println(bucket.keyAsString() + " : " + bucket.docCount()); }

date_histogram 有 fixedInterval 和 calendarInterval 两种写法。fixedInterval 是固定时间窗口,比如 30 天、90 天;calendarInterval 是日历时间,比如按月、按周。两者的区别在于:30 天和自然月不完全重合,涉及月末统计时 CalendarInterval 更符合业务直觉。format参数控制返回的 key 显示格式,对应 Kibana 里的日期格式化。

日期直方图最常见的坑是字段映射成 text 或者字符串,聚合直接报错,这个我在下一节详细说。

6. 踩坑实录:版本、类型与序列化的三重门

6.1 聚合报错:text 字段引发的血案

第一次用 terms 聚合时,我拿 name 字段直接分组,ES 返回了一个让人摸不着头脑的异常:

Text fields are not optimised for operations that require per-document field data, like aggregations and sorting

原因前面提过:text 字段会经过分词器拆解,存进索引的不是完整字符串,自然没法按原始值分组。解决方案有两种:一种是建索引时把字段类型设成 keyword,另一种是用多字段映射,保留一个 text 子字段用于搜索、一个 keyword 子字段用于聚合排序。实际项目里 name 既要模糊搜索又要分组统计的场景不少,多字段才是正解。

6.2 LocalDateTime 序列化问题

实体类里用了 LocalDateTime,结果写入 ES 时各种报错,或者写进去的日期格式 ES 不认。根因是 JacksonJsonpMapper 默认的 ObjectMapper 没有注册 JavaTimeModule,序列化 LocalDateTime 时会输出成数组,ES 的 date 字段解析不了。

解决方案是在初始化时手动构造 ObjectMapper 并注册时间模块:

import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); JacksonJsonpMapper jsonpMapper = new JacksonJsonpMapper(mapper); ElasticsearchTransport transport = new RestClientTransport(restClient, jsonpMapper);

关键是disable(WRITE_DATES_AS_TIMESTAMPS),这会让 LocalDateTime 序列化成 ISO-8601 格式的字符串,ES 默认的 date 解析器能直接识别。忘了这一步,时间字段永远是数字数组,查询按日期范围过滤时结果完全对不上。

6.3 版本兼容与 SSL 认证坑

ES 8 开始默认开启安全认证,很多人第一次连本地实例就碰到 SSLHandshakeException,或者认证失败 401。我在 2.3 小节给了带 CredentialsProvider 的初始化写法。除此之外,还有一个新手常犯的错:客户端版本和服务端大版本不一致。

比如服务端是 ES 7.17,代码里用了 8.x 的 elasticsearch-java,请求发出去之后各种反序列化异常。ES 官方维护了一份客户端和服务端的兼容矩阵,核心原则是一个大版本内兼容,跨大版本不保证。我自己的习惯是客户端版本和服务端完全一致,避免不必要的兼容性问题。生产环境升级 ES 版本前,先查一下客户端对应版本的 Release Notes。

6.4 深分页与响应体解析的性能账

ES 默认的 from+size 分页在超过 10000 条之后就会报错,这是index.max_result_window的限制。即使你能调大这个参数,深分页的性能也很糟糕,因为每个分片都要把排好序的前 N 条数据全部返回给协调节点。

我的建议是:业务上的翻页控制在浅分页范围,超过 10000 条改用 search_after 游标方式,或者用 scroll 做导出任务。search_after 的核心思路是记住上一条结果的排序值,下一次查询从这个值之后开始取。

另外再说一个响应体解析的细节:如果你的查询不需要明细文档,只用聚合结果,务必把 size 设成 0,最好再配合fetchSource(fs -> fs.enable(false))关闭 _source 返回。这能让响应体小好几个量级,聚合接口 P99 延迟能明显降下来。

7. 工程结构、运行步骤与代码获取

7.1 工程目录结构

这套代码我整理成了普通的 Spring Boot 工程,方便你直接跑起来测试。目录结构如下:

es-java-api-demo ├── pom.xml ├── src/main/java/com/example/esdemo │ ├── EsDemoApplication.java │ ├── config/ESClientHolder.java │ ├── model/Product.java │ ├── service/ProductIndexService.java │ ├── service/ProductQueryService.java │ └── service/AggregationService.java └── src/main/resources/application.yml

ESClientHolder 负责客户端初始化,Product 是实体类,ProductIndexService 里封装了建索引、单条写入、批量写入、更新、删除,ProductQueryService 里封装了各种查询,AggregationService 里封装了分组聚合和日期直方图。每个 Service 类的命名按照操作职责划分,你直接看方法名就能对应上文章里的代码片段。

7.2 运行步骤与验证方式

拿到工程后按下面顺序操作:

  1. 启动一个 ES 8.x 实例,确保 9200 端口可访问,必要时在 application.yml 里改连接地址和认证信息。
  2. 运行 EsDemoApplication 启动工程,会自动执行初始化逻辑。
  3. 调用 ProductIndexService 的 initProductData 方法写入一批测试数据。
  4. 依次执行查询和聚合的方法,控制台会打印结果。

我在代码里预留了一批模拟商品数据,类目涵盖外设、存储、显示器等,价格和库存都有差异,方便你直接看到分组聚合的效果。

7.3 代码仓库与获取方式

项目源码已经整理成可直接运行的完整工程,发布在公开代码仓库。因为平台规则不方便直接贴链接,仓库名称是 es-java-api-demo,你可以在代码托管平台按名字搜索,或者直接私信我获取完整工程包。README 里写了启动步骤和每个测试用例对应的输出样例,拉下来照着跑一遍,比只看文章理解得更快。

最后分享一个我实际养成的习惯:任何 ES 客户端操作,先把异常场景跑一遍再上线。批量写入故意混入一条错误数据看 errors 判断是否生效,聚合查询故意用 text 字段看报什么错,Get 之前故意查不存在的 ID 看返回什么。这些边界场景在测试环境跑明白了,生产环境就不会半夜被叫起来处理数据问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询