1. 项目概述与前期准备
断断续续把 SpringBoot 整合 Elasticsearch 8.0 的新版 Java API Client 用了一遍,发现网上铺天盖地还在讲旧版的 RestHighLevelClient,或者各种包装过的 Spring Data Elasticsearch。确实老客户端已经进入维护期,官方主推的就是 Java API Client for Elasticsearch。这一篇专门讲文档操作,也就是最常用的增删改查、批量写入、条件查询这些,基于我自己在一个实际项目里的完整落地记录来写,直接能拿过去用。
1.1 核心需求解析
这个项目的场景很典型,某团队内部的知识库系统需要把文档内容、标签、作者、创建时间这些信息同步到 Elasticsearch 里做全文检索和聚合分析。数据量不算大但要求响应快,而且要支持按关键词搜正文、按标签过滤、按时间范围统计这类操作,所以数据库里的内容得实时同步到 ES。技术选型上,既然已经确定用 SpringBoot 2.7 做基础框架,ES 服务端是 8.0 版本,那客户端就必须用最新的 Java API Client,原因很简单:RestHighLevelClient 在 ES 8.x 里已经不再推荐,新版 API 在连接管理、类型安全、响应式调用上都更干净。
整个项目拆成四块来做:第一块是依赖引入和配置类,第二块是索引创建和映射定义,第三块是文档的单条操作和批量操作,第四块是查询与聚合。这篇重点关注第三块,但前面两块是基础,会一并把关键代码和踩坑点说清楚。
1.2 工具选型与核心依赖
先看 Maven 依赖。新版 Java API Client 的坐标是co.elastic.clients:elasticsearch-java,版本号要和服务端对应。ES 8.0 对应 8.x 的客户端,我用的 8.5.1。这里有个容易忽略的点,新版客户端底层走的是 Elasticsearch TransportClient 重构后的 REST 协议,但它依赖一个独立的 JSON 解析库,默认是 Jackson,所以还需要引入jackson-databind和jackson-datatype-jsr310来处理 LocalDateTime 这类 Java 时间类型。
<dependency> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> <version>8.5.1</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.13.4</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> <version>2.13.4</version> </dependency>如果用的是 SpringBoot 2.7,Spring 本身自带了一个 Jackson 版本,直接引可能版本冲突。我当时的处理办法是把上面两个 Jackson 依赖的版本去掉,让 SpringBoot 的 BOM 统一管理,只保留elasticsearch-java单独指定版本。elasticsearch-java内部会传递依赖elasticsearch-rest-client,这个是底层 HTTP 通信的组件,不需要单独引。
提示:不要用
spring-boot-starter-data-elasticsearch的自动配置来管理 ES 客户端版本,它的版本管理策略和新版 Java API Client 不是一套体系,混用容易出兼容性问题。建议全部手动建 Bean,不走 SpringBoot 的自动配置。
2. 建立客户端连接的细节拆解
2.1 新版 API 的编程模型
ES 8.0 的 Java API Client 和旧版最大区别是全面拥抱 builder 模式,几乎每个请求对象都要通过XxxRequest.Builder来构造,然后用client.xxx(request)方式调用。刚开始写会觉得很啰嗦,比如查询一个文档要先new GetRequest.Builder().index("...").id("...").build(),但是好处是每个参数在 IDE 里都有提示,不容易写错,而且请求和响应都是强类型的,编译期就能发现大部分问题。
连接配置上,核心是RestClient和ElasticsearchClient两层。RestClient负责底层的 HTTP 连接管理,ElasticsearchClient在它之上做请求封装和结果解析。实际使用中我建议把ElasticsearchClient注册成一个 Spring 单例 Bean,避免每次调用都创建客户端,连接复用和线程安全都由框架帮你处理。
2.2 连接池与超时参数的取舍
下面是我项目里用的配置类,参数是跑了一段时间后调出来的结果。
@Configuration public class ElasticsearchConfig { @Bean(destroyMethod = "close") public ElasticsearchClient elasticsearchClient() { RestClientBuilder builder = RestClient.builder( new HttpHost("127.0.0.1", 9200, "http") ); builder.setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder .setConnectTimeout(5000) .setSocketTimeout(60000) .setConnectionRequestTimeout(5000) ); builder.setHttpClientConfigCallback(httpClientBuilder -> { httpClientBuilder.setMaxConnTotal(100); httpClientBuilder.setMaxConnPerRoute(20); return httpClientBuilder; }); RestClient restClient = builder.build(); JacksonJsonpMapper mapper = new JacksonJsonpMapper(); return new ElasticsearchClient(restClient, mapper); } }setConnectTimeout(5000)是建立 TCP 连接的等待时间,这个值设太大容易在 ES 集群故障时让请求线程全部挂住,设太小又可能因为网络抖动频繁报连接超时。setSocketTimeout(60000)比较关键,ES 的聚合查询和数据量大的批量写入可能超过 10 秒,如果沿用默认的 30 秒,很容易踩到SocketTimeoutException。setMaxConnTotal(100)和setMaxConnPerRoute(20)的意思是总连接数最多 100 条,单个 ES 节点最多 20 条,这个要结合项目并发量来定,如果 QPS 高,连接数太小会造成请求排队等待,我在压测环境里出现过因为连接池打满导致大量ConnectionPoolTimeoutException的情况。
JacksonJsonpMapper这一行特别值得说。ES 8 的 Java API Client 把 JSON 序列化做成可插拔设计,默认可以是 Jackson、JSON-B 或者自带的轻量实现。用 Jackson 的好处是能和我们 Spring 项目里的 ObjectMapper 保持风格一致,但要注意日期格式的处理。Elasticsearch 对日期类型默认用yyyy-MM-dd'T'HH:mm:ssZ格式存储,Jackson 默认序列化 LocalDateTime 是一串数组,比如[2023,5,10,14,30,0],ES 解析不了。解决办法是给 mapper 注册 JavaTimeModule 并设置写入日期为 ISO 格式。
ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); JacksonJsonpMapper mapper = new JacksonJsonpMapper(objectMapper);这块不改的话,你往索引里写带时间字段的文档时大概率会遇到failed to parse field [createTime] of type [date]的报错,非常坑。
3. 索引创建与映射定义
3.1 为什么文档操作前要先做映射
Elasticsearch 的索引相当于数据库里的表,文档相当于一行记录。但表和表有个重要区别:索引在写入第一个文档前,如果不显式定义映射,ES 会自动为每个字段推断类型。比如第一次写入的tags字段是字符串数组,ES 就把tags映射成text类型;后面如果又写入一个中文标题title,ES 会默认给它生成title的text类型和title.keyword子字段。
自动映射看起来省事,但对生产环境是隐患。最典型的问题是中文分词:默认 mapping 对text类型不会做中文分词,查询"分布式"三个字匹配不到内容是常有的事。所以我建议在文档操作前,先把索引的 mapping 和 settings 定义好,尤其是analysis分词器配置,以及哪些字段需要精确匹配、哪些字段需要聚合统计。
3.2 定义索引与分词器的完整代码
当时项目文档索引要求:标题用 IK 分词器,标签不做分词只做精确匹配,创建时间用 date 类型。代码如下:
public void createIndex(String indexName) throws IOException { String settingsJson = """ { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "analysis": { "filter": { "ik_smart_pinyin": { "type": "pinyin", "keep_first_letter": true, "keep_separate_first_letter": false, "keep_full_pinyin": true, "keep_joined_full_pinyin": true, "lowercase": true } }, "analyzer": { "ik_pinyin_analyzer": { "type": "custom", "tokenizer": "ik_max_word", "filter": ["ik_smart_pinyin"] } } } }, "mappings": { "properties": { "id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "author": { "type": "keyword" }, "tags": { "type": "keyword" }, "createTime": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }, "viewCount": { "type": "integer" } } } } """; CreateIndexRequest request = CreateIndexRequest.of(r -> r.index(indexName).withJson(new StringReader(settingsJson))); boolean exists = client.indices().exists(e -> e.index(indexName)).value(); if (!exists) { client.indices().create(request); } }number_of_shards设 3 是因为单节点上 3 个分片能发挥多线程并行检索的优势,但如果机器性能一般也可以设 1,分片太多反而增加协调开销。number_of_replicas至少 1,如果集群只有一个节点,副本会处于未分配状态,这时可以临时改成 0,或者等后续加节点后再调回来,否则集群健康状态是 yellow,不影响读写但不建议长期这样。
title字段这里特别设计了keyword子字段。因为在前端列表页经常要按照标题精确过滤或者做排序,text类型本身无法排序,只能通过子字段title.keyword来实现。ignore_above: 256表示超过 256 字符的字符串不会写入 keyword 子字段,防止超长标题撑爆内存。
创建索引的时机我放在应用启动后的一个初始化方法里,判断索引不存在才创建,重启不会重复报错。索引一旦创建,mapping 基本不能改,后面要加字段只能通过_mappingAPI 增量追加,所以前期想清楚字段设计能省不少事。
4. 单文档增删改查的实操详解
4.1 用 Index 和 Create 写入文档的区别
写入单条文档有两条路径:IndexRequest和CreateRequest。前者是"存在则覆盖,不存在则新增"的 upsert 语义,后者是"如果文档已存在就报版本冲突"的只新增语义。我项目里知识库文档的 ID 由业务系统生成,需要保证同一篇文档多次同步不会产生重复数据,所以用IndexRequest更合适;但要实现"仅首次导入,不允许覆盖历史记录"的场景,就用CreateRequest。
public void indexDocument(DocEntity doc) throws IOException { IndexRequest<DocEntity> request = IndexRequest.of(r -> r .index("docs") .id(doc.getId()) .document(doc) ); IndexResponse response = client.index(request); log.info("索引文档结果, id={}, result={}", response.id(), response.result()); }document()方法接收一个对象,框架内部通过 JsonpMapper 把它序列化成 JSON。这也是新版客户端比旧版舒服的地方——不需要手动构造XContentBuilder,直接丢一个 Java 对象进去就行。实体类定义如下:
public class DocEntity { private String id; private String title; private String content; private String author; private List<String> tags; private LocalDateTime createTime; private Integer viewCount; // getter/setter 省略 }字段名序列化出来和 Java 属性名保持一致,createTime会被格式化成yyyy-MM-dd HH:mm:ss字符串。
4.2 查询、更新和删除文档的完整链路
按 ID 查询文档是高频操作,代码很简单:
public DocEntity getDocById(String id) throws IOException { GetResponse<DocEntity> response = client.get(g -> g.index("docs").id(id), DocEntity.class); if (response.found()) { return response.source(); } return null; }注意found()方法有两个用途:判断文档是否存在之外,还能避免响应里 source 为空时强转 NPE。ES 允许你查询一个不存在的 ID,响应 HTTP 状态是 404,但客户端在found()返回 false 的情况下不会抛异常,所以要自己判断。
更新文档有全量替换和局部更新两种。局部更新的底层走_updateAPI,不会重传整个文档,适合只改一个字段的场景:
public void updateDoc(String id, String newContent) throws IOException { DocEntity doc = new DocEntity(); doc.setContent(newContent); UpdateRequest<DocEntity, DocEntity> request = UpdateRequest.of(u -> u .index("docs") .id(id) .doc(doc) ); UpdateResponse response = client.update(request, DocEntity.class); log.info("更新文档结果, result={}", response.result()); }这里有个难点,UpdateRequest.of方法有两个泛型参数,前面是文档类型,后面是部分更新数据类型。如果只传doc一个对象,两个泛型参数可以都用DocEntity。更新的字段会做 merge,没传的字段原样保留。
删除文档用DeleteRequest,同理不再赘述。但有一个实际项目很容易踩的坑:用update方法更新时,ES 要求传入的部分更新文档里不能包含值为 null 的字段,如果实体类里有 null 属性,Jackson 序列化的时候会写出"field":null,ES 会把该字段也更新为 null。解决办法是给需要忽略 null 的字段加@JsonInclude(Include.NON_NULL)注解,或者更新时构造一个只包含目标字段的 Map 传进去。
我踩过这个坑:有一次更新文档时,新建的 DocEntity 里没给 tags 赋值,结果 tags 为 null 直接把原来的标签清空了。后来把所有更新场景统一改成用 Map 或单独构造 partial 实体,只放要改的字段,这个问题才彻底解决。4.3 利用版本号做乐观锁并发控制
ES 文档有内部的_seq_no和_primary_term两个字段,合在一起相当于版本号。多线程同时更新同一文档时,可以用IfSeqNo和IfPrimaryTerm做乐观锁,类似数据库的乐观锁机制。
public void updateWithVersion(String id, String newTitle, long seqNo, long primaryTerm) throws IOException { UpdateRequest<DocEntity, DocEntity> request = UpdateRequest.of(u -> u .index("docs") .id(id) .ifSeqNo(seqNo) .ifPrimaryTerm(primaryTerm) .doc(Map.of("title", newTitle)) ); try { client.update(request, DocEntity.class); } catch (ElasticsearchException e) { if (e.status() == 409) { log.warn("版本冲突,文档已被他人修改,id={}", id); } } }实际使用时,先把查询出的_seq_no和_primary_term带在业务对象里,提交更新时传给服务端。如果这期间文档被别人改过,ES 返回 409 Conflict,客户端捕获后可以根据业务决定重试还是提示冲突。这个方法在旧版 RestHighLevelClient 里命名不同,新版 API 中直接叫ifSeqNo和ifPrimaryTerm,搞混了会编译不过。
5. 批量文档操作的性能之门
5.1 Bulk 批量写入的正确打开方式
同步文档进 ES,如果一篇一篇地写 IndexRequest,网络往返开销非常大。项目里第一批要导入 5 万条历史数据,单条写入耗时差不多在 20 分钟以上,换成 Bulk 批量写入后整个流程缩短到 3 分钟左右。核心思路是攒够一定数量或一段时间再一次性提交。
public void bulkIndexDocs(List<DocEntity> docList) throws IOException { BulkRequest.Builder br = new BulkRequest.Builder(); for (DocEntity doc : docList) { br.operations(op -> op .index(idx -> idx .index("docs") .id(doc.getId()) .document(doc) ) ); } BulkResponse response = client.bulk(br.build()); if (response.errors()) { for (BulkResponseItem item : response.items()) { if (item.error() != null) { log.error("批量写入失败, id={}, error={}", item.id(), item.error().reason()); } } } }BulkRequest.Builder的operations()方法可以连续追加操作,一次请求支持多个不同类型的操作混合,比如一部分是 index、一部分是 delete。实际生产里一次 Bulk 请求的 body 大小建议控制在 5MB 到 15MB 之间,太大容易把 ES 的 HTTP 服务压死,太小又浪费网络吞吐。我一般按固定条数分批,比如每 5000 条一个批次,再配合循环提交。
5.2 批量更新与批量删除
批量更新在数据订正场景很常见,比如某个字段规则调整后要全量替换。操作方式类似,但在operations()里用update子操作,需要额外指定doc内容:
public void bulkUpdateDoc(List<DocEntity> docList) throws IOException { BulkRequest.Builder br = new BulkRequest.Builder(); for (DocEntity doc : docList) { br.operations(op -> op .update(u -> u .index("docs") .id(doc.getId()) .action(a -> a.doc(doc)) ) ); } BulkResponse response = client.bulk(br.build()); ... }批量删除是给前端做"勾选删除"接口用的,一次删除几千个 ID,如果循环 DeleteRequest 也会有明显延迟。直接构建 Bulk 请求:
public void bulkDeleteDoc(List<String> ids) throws IOException { BulkRequest.Builder br = new BulkRequest.Builder(); for (String id : ids) { br.operations(op -> op.delete(d -> d.index("docs").id(id))); } BulkResponse response = client.bulk(br.build()); }批量请求返回的BulkResponse里有一个errors()方法,判断整个批量操作是否全部成功。注意这个方法是全局的,只要有一条失败就返回 true。逐条检查items(),定位失败的具体 item 和原因,一般多为字段格式错误或版本冲突。不要只在errors()为 true 时打印一个笼统的错误日志,那样排查问题没有抓手。
提示:批量写入前建议关闭 refresh 间隔,或者设置为
-1。ES 默认每 1 秒刷新一次索引,批量导入时每次 refresh 都会产生额外的段合并开销。等批量导入完了再手动执行一次_refresh让数据立即可查。
5.3 批量操作的分批与异常处理策略
我第一批导入 5 万条数据时,最初拿全部数据一次性提交,结果 ES 节点直接内存飙高,请求超时。后面调整为每批 3000 条加Thread.sleep(200)的节奏,稳定很多。这个数值不是拍脑袋定的,3000 条文档如果平均每条 1KB,一批大概 3MB 请求体,单节点完全扛得住;200 毫秒的间隔是给 ES 一点 refresh 和 disk flush 的时间。如果机器配置更高,可以适当加大批量、缩短间隔,但建议观察 ES 节点 CPU 和堆内存使用率来动态调整。
批量失败的重试策略也很关键。ES 返回的失败分两种:一种是单个文档格式错误,这种重试一万次还是错,应该记录下来走人工修正;另一种是 ES 集群临时压力大导致的EsRejectedExecutionException,这种可以退避重试。我的做法是捕获异常后,对失败批次做指数退避重试,第一次等 1 秒,第二次等 2 秒,最多重试 3 次,超过就写入本地错误日志文件,等后续补偿任务处理。
6. 结构化条件查询与结果处理
6.1 Bool 查询组合条件的开发范式
文档操作不止增删改,查询才是使用频率最高的。ES 8 的 Java API Client 里查询统一走SearchRequest,核心是通过Query.Builder构建查询 DSL。最常用的是bool查询,用来组合 must、filter、should、must_not 四种条件。这个项目里的典型需求是:首页搜索框输入关键词,要求标题和正文都匹配,同时按标签过滤、按时间范围限制、排除已删除状态。
public SearchResponse<DocEntity> searchDocs(String keyword, String tag, LocalDateTime startTime, LocalDateTime endTime) throws IOException { SearchRequest request = SearchRequest.of(s -> s .index("docs") .query(q -> q .bool(b -> { if (keyword != null && !keyword.isBlank()) { b.must(m -> m .multiMatch(mm -> mm .query(keyword) .fields("title^3", "content") .operator(Operator.And) ) ); } if (tag != null && !tag.isBlank()) { b.filter(f -> f.term(t -> t.field("tags").value(tag))); } if (startTime != null) { b.filter(f -> f.range(r -> r .date(d -> d.field("createTime").gte(startTime.toString())) )); } if (endTime != null) { b.filter(f -> f.range(r -> r .date(d -> d.field("createTime").lte(endTime.toString())) )); } b.mustNot(m -> m.term(t -> t.field("status").value("deleted"))); return b; }) ) .from(0) .size(10) .sort(srt -> srt.field(f -> f.field("createTime").order(SortOrder.Desc))) ); return client.search(request, DocEntity.class); }multiMatch是同时检索多个字段的利器。fields("title^3", "content")里的^3表示标题字段的权重是正文的 3 倍,搜索"分布式锁"时,标题命中的文档会排在正文命中的文档前面。Operator.And表示关键词分词后每个词都必须命中,防止出现只匹配一半的情况。
must、filter、should的区别是很多新手比较容易混淆的地方:must影响文档的相关性评分,filter只在过滤结果时不参与打分,性能更好。所以像标签、时间范围这类条件,我统一放filter,只有关键词搜索这种真正影响排序的才放must。从执行计划来看,filter条件可以被缓存,多次执行相同过滤时性能提升非常明显。
6.2 分页、排序与高亮的显示细节
分页参数from和size是最直观的,from默认从 0 开始,size是每页条数。但 ES 的深分页有性能陷阱,from=5000,size=10这种请求需要在每个分片上先取 5010 条文档再聚合排序,非常耗内存。要做深度分页必须换search_after或scroll方式,前者适合前端翻页,后者适合大数据量导出。
排序上,text字段不能直接排序,所以我当时在 mapping 里设计了title.keyword子字段,排序时用createTime默认倒序,这个字段是 date 类型,天然支持排序,不需要额外处理。
高亮显示是搜索场景的刚需。ES 返回的高亮片段可以直接嵌入前端页面,不需要后端再进行关键词截取。代码里给搜索请求追加highlight配置:
SearchRequest request = SearchRequest.of(s -> s .index("docs") .query(...) .highlight(h -> h .fields("title", f -> f.preTags("<em class='highlight'>").postTags("</em>")) .fields("content", f -> f.fragmentSize(100).numberOfFragments(1)) ) );fragmentSize(100)表示每个高亮片段截取 100 个字符,numberOfFragments(1)表示每个字段最多返回 1 个高亮片段。这样控制返回体大小,也避免前端展示大量重复上下文。
响应处理时,高亮内容不在source()里,要单独从response.highlight()取。做法是先拿Hit列表,对每个 hit 重新组装一个带高亮文本的 DTO。或者更简单粗暴的办法,把高亮字段直接拼进source()对应字段里返回给前端,省去前端 parsing 工作。
6.3 聚合统计的响应结构理解
聚合是 ES 相比传统数据库的差异化优势。当时的需求是统计不同标签下文档数量的分布,接口给前端画饼图用。构建聚合请求和解析响应都不复杂,但结构需要理解清楚。
public void countByTag() throws IOException { SearchRequest request = SearchRequest.of(s -> s .index("docs") .size(0) .aggregations("tagCount", agg -> agg .terms(t -> t.field("tags").size(10)) ) ); SearchResponse<DocEntity> response = client.search(request, DocEntity.class); StringTermsAggregate tagCount = response.aggregations().get("tagCount").terms(); for (StringTermsBucket bucket : tagCount.buckets().array()) { log.info("标签={}, 数量={}", bucket.key().stringValue(), bucket.docCount()); } }size(0)表示不返回文档详情,只返回聚合结果,对纯粹统计场景来说能省下大量网络传输。聚合响应里的buckets()因为 JSON 数组可能为空,老版本直接返回数组,新版本返回一个BucketCollection包装对象,要调用.array()才能拿列表。
关键词的多字段聚合有个坑:如果tags是text类型,terms聚合会报failed to parse field [tags] of type [text],必须用tags.keyword来聚合。这也是为什么前期 mapping 设计里要给需要过滤和聚合的字段多定义一个 keyword 子字段,否则后期没法加。
7. 常见问题与排查技巧实录
7.1 版本兼容与环境问题速查
整个项目过程中,我整理了一个常见问题表,基本都是实际踩过或者帮别人排查过的:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
NoSuchMethodError: RestClientBuilder.setHttpClientConfigCallback | elasticsearch-java与elasticsearch-rest-client版本不一致 | 检查依赖树,确保两者版本同属一个 release,必要时显式指定版本 |
写入LocalDateTime字段报 query 解析失败 | Jackson 默认序列化时间格式与 ES date 格式不匹配 | 注册JavaTimeModule,关闭WRITE_DATES_AS_TIMESTAMPS |
启动时报Unable to parse response body | 客户端版本低于服务端版本,响应 JSON 结构对不上 | 让客户端版本不高于服务端主版本,8.x 客户端尽量选最新小版本 |
聚合报fielddata错误 | 对text字段执行 terms 聚合 | 使用字段的 keyword 子字段,或映射时把字段类型改为 keyword |
| 连接池打满异常 | 并发请求数超过MaxConnTotal | 调高连接数配置,或排查是否有连接泄漏,确保客户端是单例 |
| bulk 批量响应 errors 为 true 但单条没有 error | 部分操作被 retry 后成功,但整体仍有失败项 | 遍历items()检查所有失败项,不要只看第一项 |
7.2 排查思路与调试手段
在排查 ES 客户端问题的时候,我的经验是先分清是客户端问题还是 ES 服务端问题。第一步看 ES 日志,如果服务端没有输出异常,多半是请求构造或响应解析的问题。第二步开启客户端的 trace 日志,新版客户端支持设置 logger 级别,能看到完整的 HTTP 请求和响应报文,这一步最直观。
logback 配置里加:
<logger name="org.apache.http" level="DEBUG"/> <logger name="co.elastic.clients" level="DEBUG"/>不过 trace 日志量非常大,生产环境不要一直开着,排查问题时临时开个 5 分钟够了。是针对某个特定请求定位字段格式问题很有效。
7.3 写入吞吐与慢查询的调优笔记
写入性能上,除了前面提到的批量大小和 refresh 间隔外,还有几个不起眼但影响很大的参数。number_of_replicas在批量导入期间可以临时设为 0,副本复制会占用大量 IO 和网络带宽,导入完成后再改回来。index.translog.durability也可以从默认的request调成async,减少每次写入后的磁盘 flush 次数,但要做好节点宕机丢失少量数据的心理准备,这个参数只在数据可重建的场景用。
慢查询调优要看_search慢日志。在索引 settings 里设置:
"index.search.slowlog.threshold.query.warn": "2s", "index.search.slowlog.threshold.fetch.warn": "1s"超过阈值的搜索会被记录到 ES 日志里,能直观看到哪些查询走了全表扫描、哪些聚合拖慢了整体响应。一次我们线上出现搜索超时,排查后发现是某个wildcard查询对keyword字段做了前缀模糊匹配,这类查询无法利用倒排索引,后来改成edge_ngram分词器才解决。
8. 实操过程与代码总结
8.1 完整链路代码串联
我把项目中经过验证的完整流程串成一段演示代码,包含了建索引、批量写入、搜索聚合的核心方法调用顺序,方便整体感知。这个演示代码对应一个测试类,可以直接运行验证环境:
@SpringBootTest public class EsDocOperationDemo { @Autowired private ElasticsearchClient client; @Test void testDocLifecycle() throws Exception { // 建索引(幂等) createIndex("docs"); // 单条写入 DocEntity doc = new DocEntity(); doc.setId("1"); doc.setTitle("SpringBoot整合ES实战"); doc.setContent("本文详细讲解新版Java API Client的文档操作,包括增删改查和批量操作。"); doc.setAuthor("某开发者"); doc.setTags(List.of("Java", "Elasticsearch")); doc.setCreateTime(LocalDateTime.now()); doc.setViewCount(100); client.index(i -> i.index("docs").id(doc.getId()).document(doc)); // 按ID查询 GetResponse<DocEntity> getResp = client.get(g -> g.index("docs").id("1"), DocEntity.class); System.out.println(JSON.toJSONString(getResp.source())); // 局部更新 client.update(u -> u.index("docs").id("1").action(a -> a.doc(Map.of("viewCount", 101))), DocEntity.class); // 条件搜索 SearchResponse<DocEntity> searchResp = client.search(s -> s .index("docs") .query(q -> q.bool(b -> b .must(m -> m.multiMatch(mm -> mm.fields("title^3", "content").query("ES 实战"))) .filter(f -> f.term(t -> t.field("tags").value("Java"))) )), DocEntity.class); // 聚合统计 SearchResponse<DocEntity> aggResp = client.search(s -> s .index("docs") .size(0) .aggregations("byTag", a -> a.terms(t -> t.field("tags.keyword"))), DocEntity.class); } }注意最后的聚合里用tags.keyword而不是tags,原因前面已经讲过。如果图省事直接用tags,在这个 mapping 定义下一定报错。
8.2 对旧项目迁移的取舍建议
如果你现在有个项目还在用 RestHighLevelClient,想迁到新版 API,我的建议是不要做全局重写,采用双客户端并存的方式过渡。在配置类里同时注册旧版 RestHighLevelClient 和新版 ElasticsearchClient,新增代码用新客户端,老代码逐步替换,等完全替换后再移除旧依赖。这样能保证业务不中断,也留出验证时间。
迁移时的映射关系大致是:RestHighLevelClient.get(...)变成client.get(...);SearchSourceBuilder变成Query.Builder;BulkRequest加doc变成BulkRequest.Builder.operations(...)。整体思路一致,但 builder 模式的写法需要适应几天。
注意:新版客户端的
client.get()返回的是GetResponse<T>,而不是旧版的GetResponse裸类型,响应里的source()方法直接返回泛型对象,不需要再手动反序列化。
8.3 从项目复盘里提炼的三个建议
第一,ES 版本升级时要顺带检查客户端版本。ES 8.0 和 8.5 之间的 API 有微调,比如UpdateRequest的doc方法在某个小版本后要改成action(a -> a.doc(...)),直接用旧代码编译不通过时,不要强行降版本,而是查一下当前版本对应的 API 写法。
第二,文档操作是把数据同步进 ES 的第一步,但它不是终点。ES 的数据一致性保障不如数据库,不要在业务代码里把 ES 当主存储。项目里知识库系统的查询走 ES,但修改和删除同时操作 MySQL 和 ES,两侧都带上事务语义,失败时用消息队列补偿,这样 ES 出问题不会导致核心数据丢失。
第三,生产环境一定要监控 ES 集群的磁盘水位和堆内存使用率,文档操作越多,段合并和 GC 压力越大。我见过因为日志索引无限增长导致磁盘打满,整个集群变成只读的严重事故,确保索引生命周期管理策略从一开始就配好,能省掉很多凌晨被叫醒的烦恼。