2. 初始化客户端与核心配置
说句实话,很多人写 ES 客户端代码,第一步就栽在配置上。这不是危言耸听,默认配置在本地玩没问题,一上生产环境,各种超时、连接池打满的报警就来了。
1.2 依赖引入的正确姿势
先解决依赖。用 Maven 的话,RestHighLevelClient 的坐标长这样:
xml复制<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.15.2</version>
</dependency>
这里有个老生常谈但极其关键的坑:客户端版本必须和服务端版本保持大版本一致。我用 7.10 的客户端连过 7.15 的服务端,表面上能连上,但某些 REST API 的响应结构已经变了,反序列化直接报错。所以别问“差一个小版本行不行”,答案是不行,老老实实对齐版本。
另外提醒一句,如果你的项目用的是 Spring Boot,别用它自动管理的 ES 版本,Spring Boot 2.x 默认管理的是 7.x 早期版本,版本可能很老。建议自己显式声明 rest-high-level-client 的版本,避免冲突。
2.2 客户端初始化的完整配置
接上讲初始化。最基础的方式:
java复制RestHighLevelClient client = new RestHighLevelClient(
RestClient.builder(
new HttpHost("localhost", 9200, "http")
)
);
这段代码能跑,但生产环境只写这个是远远不够的。实际项目中我会补上三层超时和连接池配置:
java复制RestHighLevelClient client = new RestHighLevelClient(
RestClient.builder(
new HttpHost("es-node-01", 9200, "http"),
new HttpHost("es-node-02", 9200, "http"),
new HttpHost("es-node-03", 9200, "http")
)
.setRequestConfigCallback(builder -> builder
.setConnectTimeout(5000)
.setSocketTimeout(60000)
.setConnectionRequestTimeout(0)
)
.setHttpClientConfigCallback(builder -> builder
.setMaxConnTotal(200)
.setMaxConnPerRoute(100)
.setKeepAliveStrategy((response, context) -> TimeValue.timeValueMinutes(5).getMillis())
)
);
这里逐项解释:
- connectTimeout:建立 TCP 连接的超时时间,我一般设 5 秒。不是越大越好,连接都建立不起来的时候,等 30 秒只会让调用方更崩溃。
- socketTimeout:等待响应的超时时间,这个要按业务来。普通查询我设 30 秒,但如果有 scroll 或大批量 bulk,会放到 60 秒。注意,ES 服务端也有
search.default_search_timeout,客户端超时应该比服务端稍大,否则客户端先掐断连接,服务端还在跑,浪费资源。 - connectionRequestTimeout:从连接池获取连接的超时时间。默认是 -1,也就是不限制。如果你的服务并发高,我建议设成 0(立即失败)或较短时间,让调用方快速感知连接池耗尽。
- maxConnTotal / maxConnPerRoute:连接池总连接数和单路由最大连接数。单节点环境这两个值含义接近,多节点集群时要让 maxConnPerRoute 乘以节点数略小于 maxConnTotal。
KeepAlive 策略容易被忽略。ES 其实建议复用 HTTP 连接,默认的 keep-alive 策略在某些场景下可能失效,导致频繁建连。上面这段代码显式把 keep-alive 设为 5 分钟,实测能减少大量 TCP 连接开销。
2.3 生命周期管理与单例设计
RestHighLevelClient 是线程安全的,设计上就是让你复用的。永远不要在每次请求时 new 一个客户端,用完再 close。这样干,连接池会频繁创建销毁连接,性能差,还容易把 ES 的连接数打爆。
在 Spring 项目里正确的做法是声明成一个单例 Bean:
java复制@Configuration
public class EsConfig {
@Bean(destroyMethod = "close")
public RestHighLevelClient restHighLevelClient() {
return new RestHighLevelClient(
RestClient.builder(
new HttpHost("localhost", 9200, "http")
)
// 配置省略,参考上面
);
}
}
destroyMethod = "close" 保证了 Spring 容器关闭时会释放 HTTP 连接。如果不用 Spring,也要在应用关闭钩子里手动 close。
还有一个提一下:如果用 RestHighLevelClient 访问 ES 集群,可以配置 setSniff 相关功能,在 RestClientBuilder 上设置 setMaxRetryTimeoutMillis,并启用节点嗅探(7.x 里是 EnableSniffer)。不过如果集群前面有负载均衡或者代理,嗅探反而会出问题,这个要结合部署架构来决定。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
3. 索引操作的细节与映射设计
客户端搞定之后,下一个高频操作就是索引(index)管理。很多新手把这步当“建表”理解,思路没错,但 ES 里的 mapping 比关系型数据库的表结构更灵活,同时也更需要提前规划。
3.1 创建索引:settings 与 mapping 的权衡
我见过很多人创建索引时什么都不指定,直接用默认。默认 1 个分片、1 个副本,对本地开发没问题,但生产上一旦数据量大,shard 想改又很麻烦。所以创建索引时我建议至少指定分片数和副本数。
java复制CreateIndexRequest request = new CreateIndexRequest("user");
request.settings(Settings.builder()
.put("index.number_of_shards", 3)
.put("index.number_of_replicas", 1)
);
request.mapping("{\n" +
" \"properties\": {\n" +
" \"userId\": {\"type\": \"keyword\"},\n" +
" \"userName\": {\"type\": \"keyword\"},\n" +
" \"age\": {\"type\": \"integer\"},\n" +
" \"createTime\": {\"type\": \"date\", \"format\": \"yyyy-MM-dd HH:mm:ss\"}\n" +
" }\n" +
"}", XContentType.JSON);
CreateIndexResponse response = client.indices().create(request, RequestOptions.DEFAULT);
这里有几个设计要点:
- keyword 和 text 要分清楚。
userId、userName这种不需要分词、只做精确匹配或排序的字段,用 keyword。需要全文检索的字段才用 text。如果你不确定,可以同时配fields多字段类型,但这是后话,新手别一上来就搞复杂。 - date 格式一定要显式声明。不声明的话,ES 默认的 date 格式是
strict_date_optional_time||epoch_millis,你用yyyy-MM-dd HH:mm:ss写入会直接报格式解析错误。 - mapping 一旦创建,字段类型就冻结了。ES 只能新增字段,不能修改已有字段的类型。所以前期设计不好,后期只能重建索引,非常痛苦。
如果团队协作,我更推荐把 mapping 写成单独的 JSON 文件,用 XContentType.JSON 读入,而不是像上面这样写一大段字符串。代码可读性更好,也方便 review。
3.2 判断索引、删除索引与常见坑
判断索引存在:
java复制GetIndexRequest request = new GetIndexRequest("user");
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
删除索引:
java复制DeleteIndexRequest request = new DeleteIndexRequest("user");
AcknowledgedResponse response = client.indices().delete(request, RequestOptions.DEFAULT);
这里有两个容易踩的坑。
第一个坑:exists 判断之后立即创建索引,可能会踩“索引刚创建但未完全 ready”的竞态。ES 是近实时的,索引刚创建完,立刻写入有可能碰到 index_not_found_exception。我在测试环境遇到过,处理方式是创建后做一次 cluster health 等待 yellow 状态。
第二个坑:删除索引是不可逆操作。线上环境一定要通过权限控制防止误删,代码里最好也加上索引名前缀白名单。
3.3 动态模板与字段映射的补充方案
如果你的业务字段是动态变化的,比如日志系统,每条日志的字段都不一样,挨个手写 mapping 不现实。这时候可以用动态模板:
java复制request.mapping("{\n" +
" \"dynamic_templates\": [\n" +
" {\n" +
" \"strings_as_keyword\": {\n" +
" \"match_mapping_type\": \"string\",\n" +
" \"mapping\": {\"type\": \"keyword\"}\n" +
" }\n" +
" }\n" +
" ]\n" +
"}", XContentType.JSON);
这个模板会把所有新出现的字符串字段默认映射为 keyword,避免 ES 默认把字符串同时映射成 text 和 keyword 造成存储浪费。日志类场景强烈建议这样配。当然它不适合正文字段检索需求明确的业务,需要灵活取舍。
4. 文档 CRUD:从单条到批量
索引建好之后就是对文档的操作。这是平时写得最多的代码,也是出错率较高的部分。
4.1 写入文档:指定 id 还是不指定
IndexRequest 可以指定文档 id,也可以不指定:
java复制IndexRequest request = new IndexRequest("user")
.id("12345")
.source("{\"userId\":\"12345\",\"userName\":\"张三\",\"age\":25,\"createTime\":\"2024-01-01 12:00:00\"}", XContentType.JSON);
IndexResponse response = client.index(request, RequestOptions.DEFAULT);
指定 id 时,如果 id 已存在,会走更新逻辑(version 会递增)。不指定 id,ES 会自动生成随机 id,这个 id 是 URL-safe 的 base64 字符串。
一个容易被忽略的点是 refresh 策略。写入后立刻查询,有可能查不到,因为 ES 默认近实时,refresh 间隔是 1 秒。测试代码里可以设:
java复制request.setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE);
生产环境不要用 IMMEDIATE,否则写入性能会大幅下降。要保证读写一致性,合理的做法是写入后由业务层做短时间等待,或者查询时考虑近实时特性。
4.2 更新文档:局部更新与 upsert
更新文档我优先推荐 UpdateRequest,因为它是局部更新,不需要你先把整个文档读出来再覆盖:
java复制UpdateRequest request = new UpdateRequest("user", "12345")
.doc("{\"age\":26}", XContentType.JSON);
UpdateResponse response = client.update(request, RequestOptions.DEFAULT);
如果想在文档不存在时自动写入,可以加 upsert:
java复制request.upsert("{\"userId\":\"12345\",\"userName\":\"张三\",\"age\":25,\"createTime\":\"2024-01-01 12:00:00\"}", XContentType.JSON);
UpdateRequest 还有个常用的 docAsUpsert(true),把 doc 内容同时作为 upsert 内容。看场景用。
4.3 删除文档
删除单条:
java复制DeleteRequest request = new DeleteRequest("user", "12345");
DeleteResponse response = client.delete(request, RequestOptions.DEFAULT);
注意,删除不存在的文档不会报错,response 的 getResult 会是 NOT_FOUND。如果你想通过删除后的 result 判断文档之前是否存在,别用异常来判断,要判断 result。
4.4 批量操作 Bulk:性能与失败处理
批量写入是生产环境的日常操作。BulkRequest 允许你把多个增删改请求打包成一个请求发出,大幅提升吞吐量。
java复制BulkRequest bulkRequest = new BulkRequest();
for (User user : userList) {
IndexRequest indexRequest = new IndexRequest("user")
.id(user.getUserId())
.source(userToJson(user), XContentType.JSON);
bulkRequest.add(indexRequest);
}
BulkResponse response = client.bulk(bulkRequest, RequestOptions.DEFAULT);
这里有个关键点:Bulk 里每条请求独立成功或失败。所以一定要遍历检查每一条的返回结果:
java复制if (response.hasFailures()) {
for (BulkItemResponse item : response) {
if (item.isFailed()) {
log.error("批量写入失败,id={}, 原因={}", item.getId(), item.getFailureMessage());
}
}
}
批量大小怎么定?没有绝对值,一般建议每批数据量控制在 5MB~15MB,或者条数控制在 1000~5000 条。你可以通过 bulkRequest.estimatedSizeInBytes() 估算大小,如果超过阈值就提交并新建一个 BulkRequest。太小则吞吐上不去,太大则可能把 ES 的写入线程池打满,导致 reject。
5. 搜索查询体系:从简单到复杂
搜索是 ES 的核心能力,也是 RestHighLevelClient 使用中最复杂的部分。我按从简单到复杂的顺序,把常用的查询方式过一遍。
5.1 查询骨架:SearchRequest 与 SearchSourceBuilder
所有查询最终都落在 SearchRequest 上:
java复制SearchRequest searchRequest = new SearchRequest("user");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery());
sourceBuilder.from(0);
sourceBuilder.size(10);
searchRequest.source(sourceBuilder);
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
SearchHits hits = response.getHits();
for (SearchHit hit : hits.getHits()) {
String sourceAsString = hit.getSourceAsString();
// 解析
}
SearchSourceBuilder 是一个大综合器,query、from/size、sort、聚合、高亮都往里面塞。
这里有个调试技巧:SearchSourceBuilder 的 toString() 方法会输出完整的 JSON 查询体,排查问题时把它打出来,直接复制到 Kibana Dev Tools 里跑一遍,非常方便。
java复制log.info("查询DSL: {}", sourceBuilder.toString());
5.2 常用查询类型与组合逻辑
实际业务中,单个查询条件很少,通常都是组合条件。我列个表格,把常用查询和适用场景说明白:
| 查询类型 | 关键方法 | 适用场景 |
|---|---|---|
| term / terms | QueryBuilders.termQuery | 精确匹配,keyword 字段 |
| match | QueryBuilders.matchQuery | 全文检索,text 字段,分词匹配 |
| range | QueryBuilders.rangeQuery | 范围查询,数值/日期 |
| bool | QueryBuilders.boolQuery() | 组合多个查询条件 |
| exists | QueryBuilders.existsQuery | 判断字段是否存在 |
| wildcard / prefix | QueryBuilders.wildcardQuery | 模糊匹配(注意性能) |
其中 bool 查询是最常用的组合容器:
java复制BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
boolQuery.must(QueryBuilders.matchQuery("userName", "张"));
boolQuery.filter(QueryBuilders.rangeQuery("age").gte(18).lte(30));
boolQuery.mustNot(QueryBuilders.termQuery("status", "blocked"));
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(boolQuery);
注意 must 和 filter 的区别:must 会影响相关性评分,filter 只做过滤不参与评分。如果业务上不需要评分,比如范围过滤、状态过滤,尽量用 filter,ES 会缓存过滤结果,性能更好。
5.3 排序、分页与深分页问题
排序很简单:
java复制sourceBuilder.sort("createTime", SortOrder.DESC);
分页常用两种方式。from + size 是最直观的:
java复制sourceBuilder.from(0);
sourceBuilder.size(20);
但 ES 默认限制 from + size 不能超过 10000(index.max_result_window)。超过这个值会报错。业务上要翻很多页怎么办?
- scroll:适合一次性全量导出,不适合实时搜索。scroll 会保留一个快照,数据量大时很消耗内存。
- search_after:适合实时深分页。原理是拿上一页最后一条数据的排序值作为下一页的起点。你需要一个唯一值的排序字段,通常配合
_id或 timestamp。
search_after 代码:
java复制sourceBuilder.sort("_id", SortOrder.ASC);
// 第一页查到后,取最后一条的 sort 值
searchRequest.source(sourceBuilder);
// 第二页
sourceBuilder.searchAfter(new Object[]{"last_doc_id"});
注意,search_after 必须搭配 sort,且排序字段要有唯一性,否则翻页时可能出现数据重复或丢失。
5.4 聚合查询:从 Terms 到 Avg
聚合是 ES 的另一大能力。RestHighLevelClient 里写聚合也不复杂,麻烦的是解析结果。
先写一个简单的 terms 聚合,统计用户年龄分布:
java复制SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery());
sourceBuilder.aggregation(
AggregationBuilders.terms("age_group").field("age").size(10)
);
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
Terms ageGroup = response.getAggregations().get("age_group");
for (Terms.Bucket bucket : ageGroup.getBuckets()) {
String key = bucket.getKeyAsString();
long docCount = bucket.getDocCount();
}
再叠加一个平均值聚合:
java复制sourceBuilder.aggregation(
AggregationBuilders.avg("avg_age").field("age")
);
Avg avgAge = response.getAggregations().get("avg_age");
double value = avgAge.getValue();
聚合解析的坑在于类型转换。你写的是 terms 聚合,解析时如果用了 ParsedStringTerms 或 ParsedLongTerms,类名不同,但 Terms 接口是通用的。建议统一用父接口 Terms 接收,减少类型判断。
还有一个实用技巧:支持多个子聚合。比如按年龄分组后,再看每组的平均消费金额,可以用子聚合 subAggregation。
5.5 高亮查询
高亮在搜索系统里几乎是标配。RestHighLevelClient 用法:
java复制HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("userName");
highlightBuilder.preTags("<em>");
highlightBuilder.postTags("</em>");
sourceBuilder.highlighter(highlightBuilder);
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
for (SearchHit hit : response.getHits().getHits()) {
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
HighlightField highlightField = highlightFields.get("userName");
if (highlightField != null) {
for (Text fragment : highlightField.getFragments()) {
System.out.println(fragment.string());
}
}
}
高亮字段必须是 text 类型,keyword 字段默认不支持高亮。如果对 keyword 做了高亮,多半是没生效,别慌,先查字段类型。
6. 踩坑记录:常见问题与排查速查
这个部分整理我实际开发中遇到的典型问题,每一个都是真金白银换来的经验。
6.1 版本不一致导致的序列化异常
现象:调用某个 API 后抛 org.elasticsearch.ElasticsearchStatusException,或者 Failed to parse response。
原因:客户端和服务端版本不一致。ES 不同小版本的响应结构偶有调整,老客户端解析不了新响应。
处理:先查两端版本,客户端依赖版本对齐服务端大版本。还有一种隐蔽情况:项目中存在多个 ES 相关依赖,比如 transport 客户端和 high-level 客户端共存,导致类冲突。用 mvn dependency:tree 排查依赖冲突。
6.2 连接池耗尽与连接泄漏
现象:日志里频繁出现 ConnectionPoolTimeoutException 或 Timeout waiting for connection from pool。
原因:大概率是每个请求都新建客户端,或者客户端没有正确关闭,连接没有归还。
处理:客户端改为单例;排查所有 client.close() 的调用时机;确认异步请求里有没有正确处理回调。如果服务并发实在太大,可以调大 maxConnTotal。
6.3 SearchSourceBuilder 实例复用问题
现象:多个线程共用同一个 SearchSourceBuilder,查询结果出现串数据。
原因:SearchSourceBuilder 不是线程安全的,它内部有 builder 状态。
处理:每个请求 new 一个 SearchSourceBuilder,不要抽成静态共享变量。这个错我踩过一次,压测时才暴露,排查了很久。
6.4 Bulk 失败重试导致的数据重复
现象:批量写入部分成功,业务重试后出现重复文档。
原因:没有指定文档 id,ES 自动生成了随机 id,重试等于新增,每条变成重复数据。
处理:批量写入必须自己维护业务 id 并作为文档 id。如果业务数据本身没有唯一 id,也要用业务字段生成一个确定性 id。
6.5 查询解析封装:别返回 Map
现象:从 SearchHit 里拿 getSourceAsMap() 后到处转 JSON,代码又长又容易出错。
原因:缺少统一的文档反序列化封装。
处理:直接用 RestHighLevelClient 内置的 parseEntity 或者 Gson/Jackson 把 getSourceAsString() 转成自定义类。我习惯在 DAO 层统一做转换,Service 层永远拿到的是强类型对象。
java复制User user = JSON.parseObject(hit.getSourceAsString(), User.class);
6.6 关于迁移到新版 Java API Client
最后提一个方向性的问题。RestHighLevelClient 从 7.15 开始被官方标记为废弃,Elasticsearch 8.x 推出了 Elasticsearch Java API Client。新项目建议直接用新版客户端,但存量项目短期没动力迁移也完全正常。迁移的核心工作是替换三类对象:RestHighLevelClient 换成 ElasticsearchClient,XContentType 的 JSON 字符串换成 ObjectMapper 模式的 builder,SearchSourceBuilder 换成 Query 相关的 builder。如果代码里大量使用了 RestHighLevelClient 的封装,迁移工作量不小,建议先用门面模式包一层,为后续切换留出空间。
我在实际项目中确实吃过不少亏,特别是版本兼容和连接管理这两块,基本每个新人都要踩一遍。在这里我最大的体会是:操作 ES 的代码一定不要藏着掖着,把超时时间、重试策略、批量大小这些参数显式写出来,方便后来者理解和排查。ES 的 API 并不复杂,真正拉开差距的是对参数细节和数据模型的理解。希望这份手册能让你少走几步弯路。
