简介:这是一份面向Spring Boot开发者的技术笔记,重点解决Spring Boot与Elasticsearch 7.2.0整合时版本不匹配的常见问题;资源以单个PDF文件提供,压缩包大小仅58KB,内容紧凑,方便快速查阅。文档首先指出Spring Boot 2.1.X自带的spring-boot-starter-data-elasticsearch仅支持Elasticsearch 2.X,因此需要改而使用Spring Data Elasticsearch才能兼容7.2.X;随后对比transport与rest两种连接方式,说明官方建议采用rest方式。核心部分给出完整的Maven依赖列表、application.yml中elasticsearch.ip配置项,以及基于RestHighLevelClient的客户端连接配置类,包含连接超时、读取超时等关键参数(均设为5分钟),可帮助读者直接套用搭建最小可用环境。对于需要升级ES版本或快速整合搜索能力的初中级Java工程师,这份笔记提供了清晰的依赖选型与配置思路,能减少踩坑。已有6769人学习下载,适合作为项目起步的参考资料。
1. 先把话说清楚:SpringBoot 和 Elasticsearch 7.2.0 到底该怎么对齐
如果你接手一个老项目,突然要在 SpringBoot 服务里把文档搜索、日志检索或业务数据聚合接进 Elasticsearch 7.2.0,第一反应往往是去 Maven 上搜"spring-boot-starter-data-elasticsearch"。结果就是,SpringBoot 版本稍微高一点,启动直接给你抛 NoSuchMethodError;低版本呢,又会发现它自动帮你创建的客户端和服务端版本对不上,请求发出去就报version mismatch。Elasticsearch 7.2.0 本身是个非常稳的版本,但它真正的友善之处在于官方的elasticsearch-rest-high-level-client可以直接和 SpringBoot 解耦,版本自己锁定,配置自己注入,不依赖那套容易被版本绑架的自动配置。这篇文章是给正在做搜索、日志采集、后台统计方案的人看的,我把依赖、索引、查询、批量写入一条线给你走通,最后列几个我真实环境里翻车过的坑。
2. 搭建最小可运行工程:依赖、配置和第一个连接
2.1 依赖坐标怎么选:放弃 spring-boot-starter-data-elasticsearch?
如果你只是想快速把数据写进去、查出来,我建议第一步就放弃spring-boot-starter-data-elasticsearch。不是说它不能用,而是它的版本映射关系太曲折了:SpringBoot 2.2 对应 Spring Data Elasticsearch 3.2.x,这个 3.2.x 才能匹配 ES 7.2;SpringBoot 2.7 直接带的是 4.4.x,它面对 ES 7.17 是合适的,但拿到 7.2.0 的节点上就会出兼容问题。自动装配原理再完美,也架不住服务端和客户端版本差异太大。
我一般用最直接的一套依赖,把 ES 的版本锁死在 POM 里:
<properties> <java.version>1.8</java.version> <elasticsearch.version>7.2.0</elasticsearch.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>${elasticsearch.version}</version> </dependency> <dependency> <groupId>org.elasticsearch</groupId> <artifactId>elasticsearch</artifactId> <version>${elasticsearch.version}</version> </dependency> </dependencies>注意,elasticsearch-rest-high-level-client本身会传递依赖elasticsearch-rest-client,但elasticsearch核心库并不保证一定被正确带起来,所以我习惯把org.elasticsearch:elasticsearch也显式加上。为什么不用 starter?因为 starter 会触发 Spring Data Elasticsearch 的自动配置,它会在容器里帮你再建一个RestHighLevelClient,而那个客户端的版本号是由 Spring Data 决定的,不是由你pom.xml里的elasticsearch.version决定的。等你连上一个 7.2.0 的集群,客户端却是 7.17,很容易出现"请求头里 version 不对"的诡异错误。直接引入官方 client,整个对象的创建权都在你手里,升级时只需要改一个 version 属性。
2.2 application.yml 里的关键配置与第一个连接自检
工程里的配置文件很简单,不需要让 SpringBoot 去自动识别 ES 前缀,我们打算用@Value手动绑定。这样做的另一个好处是:当你的 SpringBoot 版本高到不认某些配置路径时,这个 Bean 还是能自己独立建起来。
spring: application: name: es-demo jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 elasticsearch: hosts: 192.168.1.10:9200,192.168.1.11:9200 max-conn-total: 100 max-conn-per-route: 50接下来写一个配置类,把 hosts 串拆开,构造出HttpHost数组,再交给RestClientBuilder:
@Configuration public class EsConfig { @Value("${elasticsearch.hosts}") private String hosts; @Value("${elasticsearch.max-conn-total:100}") private Integer maxConnTotal; @Value("${elasticsearch.max-conn-per-route:50}") private Integer maxConnPerRoute; @Bean public RestHighLevelClient restHighLevelClient() { String[] hostArr = hosts.split(","); HttpHost[] httpHosts = new HttpHost[hostArr.length]; for (int i = 0; i < hostArr.length; i++) { String host = hostArr[i].split(":")[0]; int port = Integer.parseInt(hostArr[i].split(":")[1]); httpHosts[i] = new HttpHost(host, port, "http"); } RestClientBuilder builder = RestClient.builder(httpHosts); builder.setMaxRetryTimeoutMillis(30000); builder.setRequestConfigCallback(config -> config .setConnectTimeout(5000) .setSocketTimeout(10000) .setConnectionRequestTimeout(5000)); builder.setHttpClientConfigCallback(clientBuilder -> clientBuilder .setMaxConnTotal(maxConnTotal) .setMaxConnPerRoute(maxConnPerRoute)); return new RestHighLevelClient(builder); } }这里有几个参数一定要讲清楚。setMaxRetryTimeoutMillis不是单个请求的超时时间,而是客户端在同一个请求上允许重试的累计时间上限,ES 节点暂时不可用时它很有用。requestConfigCallback里那三个超时分别控制连接建立、Socket 读等待、从连接池获取连接的时间。后两个在高并发下特别关键,如果连接池被打满,connectionRequestTimeout太短会误报超时,太长又会让线程堆积。maxConnTotal和maxConnPerRoute就是 Apache HttpClient 的链接池大小,前者是所有节点的总连接数,后者是单个节点的并发连接上限。
连接一建好,先写一个启动自检是很稳的做法。我通常在ApplicationRunner里调用client.ping()和client.info():
@Component public class EsHealthCheck implements ApplicationRunner { private final RestHighLevelClient client; public EsHealthCheck(RestHighLevelClient client) { this.client = client; } @Override public void run(ApplicationArguments args) throws Exception { boolean ping = client.ping(RequestOptions.DEFAULT); if (!ping) { throw new RuntimeException("Elasticsearch is not reachable"); } MainResponse response = client.info(RequestOptions.DEFAULT); System.out.println("cluster: " + response.getClusterName()); System.out.println("version: " + response.getVersion()); } }ping()实际上就是请求了一下/路径,开销极小,适合启动时确认基础网络通不通。client.info()能拿到集群名和节点版本号,连的是不是 7.2.0 一眼就能看出来。如果你不想因为 ES 故障把整个 SpringBoot 应用启动卡死,可以把检查逻辑从run()里拿掉,改成监听ApplicationReadyEvent,只打印 WARN 日志而不是抛异常。这个选择没有对错,完全看你的运维习惯。
3. 把索引和文档操作写成代码:RestHighLevelClient 的标准姿势
3.1 索引的创建、判断和删除
索引在 ES 里就是数据的容器,7.x 开始一个索引下不再建议分多个 type,直接用_doc就够。创建索引时,最关键的其实是 mapping,它决定了哪些字段能被精确匹配、哪些字段能分词、哪些字段能参与排序。
public void createIndex(String indexName) throws IOException { CreateIndexRequest request = new CreateIndexRequest(indexName); request.settings(Settings.builder() .put("index.number_of_shards", 5) .put("index.number_of_replicas", 1) .put("index.refresh_interval", "10s")); String mappingJson = "{\"properties\":{" + "\"id\":{\"type\":\"keyword\"}," + "\"title\":{\"type\":\"text\",\"analyzer\":\"ik_max_word\"}," + "\"createTime\":{\"type\":\"date\",\"format\":\"yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis\"}," + "\"status\":{\"type\":\"integer\"}" + "}}"; request.mapping("_doc", mappingJson, XContentType.JSON); CreateIndexResponse response = client.indices().create(request, RequestOptions.DEFAULT); if (!response.isAcknowledged()) { throw new RuntimeException("create index failed: " + indexName); } }参数说明里有三个点要特别注意。number_of_shards是主分片数,创建后不能改,数据量超过几个 GB 前最好先规划好;number_of_replicas是副本数,这个以后用UpdateSettingsRequest还能动态调。refresh_interval是刷新间隔,默认 1 秒,写成 10s 可以明显降低写入时的段合并压力,但代价是数据写入后要等 10 秒才可查询,适合日志类场景。mapping 里用ik_max_word是 IK 分词器的最大切分方式,如果你没装 IK 插件,这里会报失败,没插件就改成standard。
判断索引是否存在和删除索引,用.indices()下的方法就行:
GetIndexRequest getRequest = new GetIndexRequest(indexName); boolean exists = client.indices().exists(getRequest, RequestOptions.DEFAULT); if (exists) { DeleteIndexRequest deleteRequest = new DeleteIndexRequest(indexName); AcknowledgedResponse deleteResponse = client.indices().delete(deleteRequest, RequestOptions.DEFAULT); System.out.println("delete acknowledged: " + deleteResponse.isAcknowledged()); }exists()和get()不同,exists只关心返回状态码,不会把全部 index 元的描述拉回来,所以在分支逻辑里优先用它。删除索引等于删除底下所有数据,生产环境我一般会对索引名做白名单校验,比如只允许删除以tmp_开头的索引,避免手滑把线上索引删掉。
3.2 文档的增删改与批量写入
有了索引,就可以往里写文档了。单条写入直接用IndexRequest,把业务对象转成Map或 JSON 字符串都行:
public void indexDoc(String indexName, String id, Map<String, Object> source) throws IOException { IndexRequest request = new IndexRequest(indexName) .id(id) .source(source, XContentType.JSON); IndexResponse response = client.index(request, RequestOptions.DEFAULT); System.out.println(response.getResult()); }一定要传业务 id,别让 ES 自动生成。自动生成 id 在数据重放、幂等更新时非常痛苦,你没法用同一条记录去覆盖之前的数据。index操作在 id 已存在时默认是覆盖整条文档。
部分更新用UpdateRequest:
UpdateRequest request = new UpdateRequest(indexName, id); Map<String, Object> doc = new HashMap<>(); doc.put("status", 2); doc.put("updateTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); request.doc(doc); UpdateResponse response = client.update(request, RequestOptions.DEFAULT); System.out.println(response.getResult());这里要提一个被坑过很多次的行为:request.doc(doc)是合并字段,不是覆盖。如果 doc 里只写了status,那这条文档里其他字段都还在。setUpsert(doc)可以让文档不存在时自动创建,存在时按 doc 更新,这个在增量同步场景里很实用。
删除单条文档就更简单了:
DeleteRequest request = new DeleteRequest(indexName, id); DeleteResponse response = client.delete(request, RequestOptions.DEFAULT);真正到了生产,写入基本不会一条条来,而是用BulkRequest。比如从数据库批次同步 10 万条记录,绝对不能写一个 for 循环挨个index(),那样集群的连接和线程会被瞬间打满。正确姿势是分批批量写:
public void bulkIndex(String indexName, List<Map<String, Object>> rows) throws IOException { BulkRequest bulkRequest = new BulkRequest(); int batchSize = 5000; for (int i = 0; i < rows.size(); i++) { Map<String, Object> row = rows.get(i); IndexRequest request = new IndexRequest(indexName) .id(String.valueOf(row.get("id"))) .source(row, XContentType.JSON); bulkRequest.add(request); if ((i + 1) % batchSize == 0 || i == rows.size() - 1) { BulkResponse response = client.bulk(bulkRequest, RequestOptions.DEFAULT); handleBulkResponse(response, rows, i); bulkRequest = new BulkRequest(); } } }batchSize一般取 3000~5000 比较稳。太小了,HTTP 请求往返次数多;太大,单次请求体可能超过 ES 默认的 100MB http.max_content_length,会直接收到Content is too large。每次bulk()之后要检查hasFailures(),不能只看总耗时。失败项里有item.getFailure().getCause(),通常能拿到明确的异常信息。我习惯把失败的数据捞出来单独记录到日志,后续补推,而不是让它静默丢失。
4. 查询才是重头戏:从 term 到 aggregations 的落地写法
4.1 组合查询:bool + term + match + range
查询是最能体现 ES 设计思想的部分。如果只是按 id 主键查询,那和关系型数据库没区别;真正厉害的是把分词匹配、精确过滤、范围筛选组合在一个请求里。
假设你要查文章索引,条件是这样的:标题包含"springboot",状态必须为 1,创建时间在 2024 年,按点击量倒序。用 Java 客户端这么写:
SearchRequest searchRequest = new SearchRequest("article"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); boolQuery.must(QueryBuilders.matchQuery("title", "springboot")); boolQuery.filter(QueryBuilders.termQuery("status", 1)); boolQuery.filter(QueryBuilders.rangeQuery("createTime") .gte("2024-01-01 00:00:00") .lte("2024-12-31 23:59:59")); sourceBuilder.query(boolQuery); sourceBuilder.from(0).size(20); sourceBuilder.sort("clickCount", SortOrder.DESC); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);matchQuery会对查询文本分词,然后从倒排索引里找匹配;termQuery是精确匹配,不做任何分词,适合 status、id 这类字段。rangeQuery只能用在 Date、Integer 或 Keyword 类型上,如果字段是 text,即便内容是日期也没法比较。注意filter里的条件不参与相关度评分,所以它的执行性能比must好很多:能过滤掉的记录越早,后面算分的代价就越小。from和size不能超过 index.max_result_window,默认 10000,超过就得换search_after。
查询结果解析也不复杂:
SearchHits hits = response.getHits(); long total = hits.getTotalHits().value; for (SearchHit hit : hits.getHits()) { Map<String, Object> source = hit.getSourceAsMap(); System.out.println(source.get("id") + " -> " + source.get("title")); }getSourceAsMap()返回的是整个_source字段的 Map。如果你在 mapping 里设置了"_source": {"enabled": false},这一步就拿不到原始文档了,只能从hit.getFields()里取。7.2 里getTotalHits()返回的TotalHits对象有个relation属性,当总数超过 10000 时可能是GREATER_THAN_OR_EQUAL_TO,注意别把它当精确值展示给用户。
想做标题高亮,在SearchSourceBuilder上加高亮器:
HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("title") .preTags("<span class='highlight'>") .postTags("</span>"); sourceBuilder.highlighter(highlightBuilder);解析时可以从hit.getHighlightFields()里拿HighlightField,再取 fragments 拼接。这在搜索系统里基本是标配功能,但很多人漏了高亮字段也需要单独申请开发量。
4.2 聚合统计:按时间、按分类的 bucket 写法
聚合是 ES 比普通数据库更有价值的地方,尤其是时间序列统计。先看一个最常用的:按天统计文档数量。
DateHistogramAggregationBuilder dateHist = AggregationBuilders .dateHistogram("by_date") .field("createTime") .calendarInterval(DateHistogramInterval.DAY) .format("yyyy-MM-dd"); sourceBuilder.aggregation(dateHist); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); Aggregations aggregations = response.getAggregations(); ParsedDateHistogram parsed = aggregations.get("by_date"); for (Histogram.Bucket bucket : parsed.getBuckets()) { System.out.println(bucket.getKeyAsString() + " / " + bucket.getDocCount()); }这里必须提醒:在 7.2.0 里,dateHistogram的.interval("1d")已经废弃,继续用会抛异常或不生效。正确做法是用.calendarInterval()或.fixedInterval()。calendarInterval会按自然日、自然月来对齐,比如DAY就是从零点开始;fixedInterval不做日历对齐,适合做固定时间窗的监控统计。解析时ParsedDateHistogram是Histogram.Aggregation的解析类,拿到buckets后逐个取keyAsString和docCount就好。
按状态分类统计数量,用termsAggregation:
TermsAggregationBuilder termsAgg = AggregationBuilders .terms("by_status") .field("status") .size(10); sourceBuilder.aggregation(termsAgg);如果你要对一个 text 字段做 terms 聚合,直接field("title")会报Fielddata is disabled on text fields。常见做法是改成title.keyword,前提是你映射里开了 keyword 子字段。这个关键字子字段在 7.2 的动态映射里默认就有,但如果你手工只定义了 text 类型,那就没有 keyword,聚合时只能另想办法。
聚合可以嵌套,而且这才是真正的统计价值。比如按天分桶后,再统计每天点击量总和:
DateHistogramAggregationBuilder dateHist = AggregationBuilders .dateHistogram("by_date") .field("createTime") .calendarInterval(DateHistogramInterval.DAY); dateHist.subAggregation(AggregationBuilders.sum("total_clicks").field("clickCount")); sourceBuilder.aggregation(dateHist);解析子聚合时,先拿到ParsedDateHistogram,再在每一个 bucket 里取子聚合:
for (Histogram.Bucket bucket : parsed.getBuckets()) { ParsedSum sum = bucket.getAggregations().get("total_clicks"); System.out.println(bucket.getKeyAsString() + " -> " + sum.getValue()); }子聚合的类型有很多,sum、avg、cardinality、percentiles都行。cardinality可以统计去重数量,但默认精度有限,精确去重需要换composite或做二次查询。聚合这类东西,写出来容易,跑得慢也容易,关键是看你的字段类型和数据量是否匹配。
5. 这些坑我替你踩过了:版本冲突、连接泄漏、类型转换和其它玄学
5.1 SpringBoot 版本太高导致自动配置客户端不对
现象:项目用的是 SpringBoot 2.7.x,引入spring-boot-starter-data-elasticsearch后,应用能启动,但一旦调用 repository 查询,就报NoNodeAvailableException或者ElasticsearchStatusException,看底层日志发现客户端版本和服务端 7.2.0 对不上。
原因:SpringBoot 的自动装配原理决定了它会为ElasticsearchRestTemplate和RestHighLevelClient注入一套版本固定的依赖。SpringBoot 2.7 对应 Spring Data Elasticsearch 4.4,这个版本内部用的是 ES 7.17 的 client。虽然它也能通过 REST 协议访问 7.2 的节点,但序列化、mapping 处理上有差异,某些 API 会直接失败。
解决:最省心的是放弃这个 starter,回到第 2 章那种手动创建RestHighLevelClient的方式。如果你只是因为项目历史包袱必须保留 Spring Data 的 Repository 抽象,那就手动改依赖版本,并排除 Boot 自动配置:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> <exclusions> <exclusion> <groupId>org.springframework.data</groupId> <artifactId>spring-data-elasticsearch</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-elasticsearch</artifactId> <version>3.2.0</version> </dependency>就算这样,还是建议在启动自检里把 ES 版本打出来确认一遍。版本玄学这东西,光靠猜不靠谱。
5.2 连接不关闭导致连接池耗尽
现象:服务运行一段时间后,所有 ES 请求一直卡住,日志里出现Timeout waiting for connection from pool或Connection pool shut down。
原因:代码里每次调用都new RestHighLevelClient(...),用完不close()。RestHighLevelClient底层的RestClient持有连接池,不关闭就是不可回收的泄漏。更隐蔽的是,有人把它写成了方法局部变量,却想当然地放在try-with-resources外面释放,结果连接没有被归还。
解决:把它声明为 Spring 的@Bean单例,注入到各个 Service 里。如果确实要在方法级别创建,必须在finally里client.close(),但高频调用这样做连接反复建立销毁,性能会很难看。生产上我还会在连接池回调里设置空闲 keep-alive 策略,避免 ES 端空闲连接被回收之后,客户端还拿着已经失效的连接继续发请求:
builder.setHttpClientConfigCallback(clientBuilder -> { clientBuilder.setMaxConnTotal(maxConnTotal) .setMaxConnPerRoute(maxConnPerRoute) .setKeepAliveStrategy((response, context) -> 5 * 1000); return clientBuilder; });5.3 返回结果转 Java Bean 时 LocalDateTime 反序列化报错
现象:ES 里存的是date类型,getSourceAsMap()之后字段值是一个String,比如"2024-05-20 08:00:00"。用它转LocalDateTime,直接抛InvalidFormatException,或者得到的值是Timestamp格式,和预期不一致。
原因:ES 返回的 JSON 里日期就是字符串,Java 侧需要自己控制解析格式。SpringBoot 的ObjectMapper如果不注册JavaTimeModule,就无法把字符串转成LocalDateTime。
解决:在配置类里定义一个自定义ObjectMapper:
@Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); return mapper; }如果你不从 source 转为 Bean,而是直接getSourceAsMap()自己取值,那就要手动LocalDateTime.parse(value, FORMATTER)。别依赖 ES 自动转成 Java 时间对象,REST 接口没这么智能。
5.4 Windows 下启动 Elasticsearch 总是自动退出
现象:Win11 上解压了 elasticsearch-7.2.0,双击elasticsearch.bat或直接执行命令,CMD 窗口闪一下就没,服务起不来。
原因:最常见的是环境变量里没有配JAVA_HOME,ES 脚本找不到 JDK;其次是jvm.options里的-Xms1g -Xmx1g,对开发机内存不足的情况直接触发启动失败;还有一个容易被忽略的,就是下载的压缩包路径里带了空格或中文,脚本解析路径出错。
解决:先确认 JDK 版本是 1.8,设好JAVA_HOME。然后打开config/jvm.options,把堆内存改成 512m:
-Xms512m -Xmx512m在命令行里手动执行.\bin\elasticsearch.bat,不要双击,这样能看到真正的报错日志。如果报max virtual memory areas vm.max_map_count之类,那是 Linux Kernel 参数的问题,Windows 一般不会遇到;真遇到了,去config/elasticsearch.yml里把bootstrap.memory_lock: false确认一下。还有一次我遇到的情况是之前有个后台进程已经占用了 9200 端口,新实例起不来,用netstat -ano | findstr 9200查一下就能定位。
5.5 索引已有数据后修改 mapping 不生效
现象:给一个已经写入了三个月数据的索引新增一个 keyword 子字段,执行 PUT mapping 返回成功,但查询新字段时发现类型还是老的,甚至有些字段查出来是 null。
原因:ES 的 mapping 建立之后,已有字段的类型不能改,只能新增字段。即便只是把text改成text加一个fields.keyword,老字段的映射也不会自动补上。
解决:正确做法是重建索引。先创建新索引并写好完整 mapping,然后用_reindex把老索引数据搬过去:
ReindexRequest reindexRequest = new ReindexRequest(); reindexRequest.setSourceIndices("article_v1"); reindexRequest.setDestIndex("article_v2"); BulkByScrollResponse response = client.reindex(reindexRequest, RequestOptions.DEFAULT); System.out.println("reindex total: " + response.getTotal());数据量大时,reindex是个长任务,client.reindex()默认会同步等待,超时时间一定要给足;更好的方式是用setConflicts("proceed"),避免文档版本冲突导致任务中断。老索引确认没问题后,再用 alias 把业务读请求切到新索引上,这种切换对上游服务基本无感。这也是"ES 恢复数据"或者迁移索引字段时最稳妥的一条路。
6. 收尾的硬功夫:连接池调优与写入性能验证
6.1 集群连接池三个参数怎么调
第 2 章里设置了maxConnTotal和maxConnPerRoute,这里再补一个容易被忽略的策略。ES 服务端自己也有 keep-alive,默认是 30 秒左右。如果 HttpClient 这边不告诉它这个连接能活多久,客户端会一直握着,结果服务端已关闭,下一次请求才知道连接坏了。所以我在setHttpClientConfigCallback里设置一个 5 秒的 keep-alive,比服务端短,确保每次请求拿到的都是健康连接。
builder.setHttpClientConfigCallback(clientBuilder -> { clientBuilder.setKeepAliveStrategy((response, context) -> 5 * 1000); return clientBuilder; });maxConnTotal和maxConnPerRoute不是越大越好,节点的 CPU 和内存是有限的,开太大反而让 ES 线程池排队。常见起步值是总连接 100,单路由 50;如果你的业务同时只有少量线程在访问,那么 30/10 就够。
6.2 写入性能的快速验证方法
我每接一个和 ES 相关的项目,第一件事不是写业务代码,而是先做一次 10 分钟的写入压测。原因很简单:我吃过一次亏,凌晨日志量翻倍,结果一次性 bulk 太大,ES 集群直接 CPU 拉满,业务查询全被拖慢。那种"看着没问题,一上线就炸"的情况,基本都是没在真实压力下验证过。
压测方法很简单,准备 1 万条 Map 数据,按 5000 条一批次 bulk 写入,分别测默认刷新和refresh=wait_for两种策略。默认情况 ES 每 1 秒刷新一次,写入吞吐高;wait_for会等 refresh 完成再返回,写入变慢,但数据写入后立刻可见。代码里设置:
bulkRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.NONE);压测时要关注的不是单次 bulk 耗时,而是整体耗时是否线性增长。如果第二批比第一批明显慢,可能是段合并或者磁盘 IO 成了瓶颈,这时候不是调客户端,而是要调 ES 的分片数或refresh_interval。数据量小的时候,5 个分片和 1 个分片区别不大;数据量上来,分片少会拖慢并发。
做完这些验证,我才敢说这个 SpringBoot 整合 Elasticsearch 7.2.0 的方案能扛住业务压力。连接池的每个参数、批量大小、刷新策略,都要拿到真实数据后再定,不能靠感觉。希望这篇文章能帮你少走几圈弯路,也欢迎你在自己的环境里先把最小例子跑通,再往里填业务逻辑。
本文还有配套的精品资源,点击获取