简介:这份PDF文档面向需要集成Elasticsearch 7.2.0的Spring Boot开发者,系统讲解整合实现的关键步骤。文档从版本兼容问题切入,说明Spring Boot 2.1.x自带的spring-boot-starter-data-elasticsearch仍停留在ES 2.x,因此改用Spring-data-elasticsearch以适配新版;并对比transport与rest两种连接方式,指出官方建议使用rest方式及原因。正文包含完整的Maven依赖引入说明、application.yml中elasticsearch.ip配置示例,以及基于RestHighLevelClient的客户端配置类写法,覆盖HttpHost构建、连接与Socket超时时间设置等开发中容易遗漏的细节,可帮助读者快速搭建可运行的搜索环境。资源包为单一PDF,压缩后大小58KB,篇幅精炼,适合已具备基础Java知识、正在接入ES 7.x的工程师参考。目前已有6769人学习下载,实际使用价值已获得一定验证。
1. SpringBoot整合Elasticsearch7.2.0:先锁版本再谈实现
很多团队第一次做 SpringBoot 整合 Elasticsearch 时,习惯直接引入 spring-boot-starter-data-elasticsearch 依赖,结果启动时要么报节点找不到,要么查询时序列化疯狂翻车。原因往往不是代码写错,而是 Spring Boot 自动管理的 ES 客户端版本和服务端 7.2.0 对不上。本文讲的这套实现方法,核心是用 RestHighLevelClient 手动接管连接与数据访问,把版本控制权握在自己手里。适合刚接手搜索业务、想快速在 SpringBoot 工程里跑通 ES 增删改查的 Java 工程师,也适合被 starter 里的版本黑匣子坑过、想换成显式客户端的老手。
2. 版本兼容与依赖引入:SpringBoot 的版本管理为什么在这里会失灵
2.1 Spring Boot 管理的 ES 版本:哪个版本对应哪个依赖
Spring Boot 从 2.0 开始就把 Elasticsearch 客户端纳入依赖管理,但这里有个容易被忽略的事实:spring-boot-dependencies 里管的只是客户端和 transport 的版本,它不会管你服务端到底部署的是什么版本。如果你用的是 Spring Boot 2.3.x,它默认管理的 ES 版本大约是 7.6.2;如果你用 Spring Boot 2.1.x,对应的是 6.8.x。直接把 starter 拉进来而不覆盖版本,客户端 7.6 去连服务端 7.2,虽然多数基础 API 能跑,但某些语义细节(比如聚合返回结构、日期格式)会出现诡异差异。
常见做法是在 pom.xml 的 properties 里显式声明版本覆盖:
<properties> <java.version>1.8</java.version> <elasticsearch.version>7.2.0</elasticsearch.version> </properties> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency>这段配置的逻辑是:利用 Spring Boot 已有的 starter 依赖名,但通过 properties 里同名属性强行把 Elasticsearch 相关组件改为 7.2.0。这是官方支持的覆盖方式,比手动逐个排除旧版本再引入新版本要省事得多。
另一个更直接的做法是绕过 starter,只引入 REST 客户端:
<dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>7.2.0</version> </dependency>我一般会选第二种。spring-boot-starter-data-elasticsearch 里的 Spring Data Elasticsearch 抽象层在 7.2.0 时代和 RestHighLevelClient 的 API 存在一定重叠,有些方法(比如自定义查询注解)在版本不一致时行为不可控。直接用 high-level client,代码路径短,排查问题也直接。
需要留意的是,直接引 high-level client 时,它传递依赖里的 elasticsearch 核心包和 lucene 版本也会锁定到 7.2.0。如果你的工程里恰好有别的模块引了低版本 Lucene,启动时会看到 NoClassDefFoundError,那种错误几乎必需要看依赖树。
2.2 最小可运行的 application.yml:连接信息放配置里而不是写死在代码中
连接信息放在配置文件是常规做法,方便本地、测试、生产三个环境切换。一个最小的配置如下:
spring: elasticsearch: rest: uris: http://192.168.1.10:9200 connection-timeout: 3s read-timeout: 30s注意这里有个细节:Spring Boot 2.2 之前的配置项是spring.data.elasticsearch.cluster-nodes,2.2 之后改为spring.elasticsearch.rest.uris。如果你搜到老博客用 cluster-nodes,在 Spring Boot 2.3+ 上会静默失效,连接对象拿到的还是默认 localhost:9200,启动不报错,但一查询就连接拒绝。
connection-timeout 我习惯设 3 秒。ES 集群如果在大流量下 GC 停顿,连接超时设得太短会频繁触发重试,反而给集群加压力;read-timeout 要按业务来,普通查询 30 秒足够,聚合大桶或 scroll 导出时建议单独调大。
到这里,pom 和 yml 就绪,下一步是写配置类把 RestHighLevelClient 注入 Spring 容器。
3. RestHighLevelClient 的 SpringBoot 封装:从连接注入到第一段 CRUD
3.1 配置类注入:为什么不要每次 new 一个 Client
RestHighLevelClient 是一个重量级对象,它内部维护了 HTTP 连接池、线程池和多节点故障转移逻辑。每次查询前 new 一个客户端再 close,代价非常高,而且在高并发下会出现大量 TIME_WAIT 连接,这是生产环境最常见的连接泄漏隐患之一。
用配置类做单例注入:
@Configuration public class ElasticsearchConfig { @Bean(destroyMethod = "close") public RestHighLevelClient restHighLevelClient() { RestClientBuilder builder = RestClient.builder( new HttpHost("192.168.1.10", 9200, "http") ); builder.setMaxRetryTimeoutMillis(5000); builder.setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder .setConnectTimeout(3000) .setSocketTimeout(30000) ); return new RestHighLevelClient(builder); } }这里的逻辑是:通过@Bean(destroyMethod = "close")让 Spring 容器关闭时代理客户端 close,避免连接泄漏。如果服务端是 HTTPS 或启用了账号密码,可以在 HttpHost 里调整协议,并额外在构建前设置setDefaultHeaders带上认证头。
参数说明:setMaxRetryTimeoutMillis(5000)控制单次请求的重试总超时,默认值其实是 30 秒,适合批量场景但不适合在线查询,5 秒是我在业务里的常用值。setConnectTimeout是 TCP 建连超时,setSocketTimeout是等待响应超时,这两个值会被setRequestConfigCallback覆盖到每个请求上。
如果工程里同时存在spring.data.elasticsearch.cluster-nodes和老式 TransportClient 配置,建议全部删掉。7.2.0 服务端仍然接受 TransportClient 连接,但那个客户端在 8.0 被彻底移除,而且走的是 9300 端口的私有协议,排查问题比 HTTP 麻烦,不值得在 7.2.0 这个时间点上继续用。
3.2 索引创建与文档增删改查:第一段能跑通的代码
依赖和注入就绪后,先写一段最简单的 CRUD 验证链路。以下代码是一个完整的 Service 片段,包含索引创建、文档写入、按 ID 查询三个操作:
@Service @RequiredArgsConstructor public class UserSearchService { private final RestHighLevelClient client; private final ObjectMapper objectMapper = new ObjectMapper(); public boolean createIndex(String indexName) throws IOException { CreateIndexRequest request = new CreateIndexRequest(indexName); request.settings(Settings.builder() .put("index.number_of_shards", 3) .put("index.number_of_replicas", 1)); request.mapping( "{\n" + " \"properties\": {\n" + " \"name\": {\"type\": \"keyword\"},\n" + " \"age\": {\"type\": \"integer\"},\n" + " \"remark\": {\"type\": \"text\", \"analyzer\": \"ik_max_word\"}\n" + " }\n" + "}", XContentType.JSON); try { CreateIndexResponse response = client.indices().create(request, RequestOptions.DEFAULT); return response.isAcknowledged(); } catch (ElasticsearchStatusException e) { if (e.status() == RestStatus.CONFLICT) { return false; // 索引已存在 } throw e; } } public String saveUser(String id, User user) throws IOException { IndexRequest request = new IndexRequest("user"); request.id(id); request.source(objectMapper.writeValueAsString(user), XContentType.JSON); IndexResponse response = client.index(request, RequestOptions.DEFAULT); return response.getId(); } public User getUserById(String id) throws IOException { GetRequest request = new GetRequest("user", id); GetResponse response = client.get(request, RequestOptions.DEFAULT); if (!response.isExists()) { return null; } return objectMapper.readValue(response.getSourceAsString(), User.class); } }这段代码的逻辑链路:创建索引时,settings 控制分片和副本数,mapping 里把精确值字段设为 keyword、需要分词的字段设为 text。写入文档时通过 ObjectMapper 把 User 对象序列化成 JSON。查询时用 GetResponse 拿回源数据再反序列化。
这里有两个参数值得说明。第一,index.number_of_shards为 3 意味着该索引最多被拆到 3 个主分片,这个值在索引创建后无法修改,一旦数据量预估超过单节点容量,就得提前规划。第二,mapping 里的analyzer: "ik_max_word"依赖 IK 分词插件,如果服务端没装这个插件,写入文档不会报错,但查询时分词结果会不符合中文预期,这是非常隐蔽的一个坑。
关于请求选项,RequestOptions.DEFAULT是多数场景的安全选择。ES 7.2.0 支持通过 RequestOptions 传递自定义 headers,比如携带 token 的认证信息。如果多个请求都需要同一个认证 header,可以定义一个常量,在配置类里通过setDefaultHeaders统一注入,而不是在每个请求上重复设置。
4. 查询能力落地:Bool 查询、分页排序与高亮,往业务上靠
4.1 Bool 查询与 Term/Match 的区别:精确匹配与全文搜索不能混用
ES 查询里最容易出错的地方,是把 Term 当 Match 用,或者反过来。Term 查询是针对 keyword 字段的精确匹配,不会分词;Match 查询会先对查询词做分词,再对倒排索引做全文匹配。在 SpringBoot 整合场景下,这两个查询类的使用方式差异直接决定了查询结果是否正确。
下面是一个组合查询示例,实现了「姓名精确匹配 + 年龄范围 + 备注模糊匹配」三个条件同时生效:
public SearchResponse searchUser(String name, Integer minAge, Integer maxAge, String remark) throws IOException { SearchRequest searchRequest = new SearchRequest("user"); BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); if (StringUtils.hasText(name)) { boolQuery.filter(QueryBuilders.termQuery("name", name)); } if (minAge != null || maxAge != null) { boolQuery.filter(QueryBuilders.rangeQuery("age") .gte(minAge == null ? 0 : minAge) .lte(maxAge == null ? 200 : maxAge)); } if (StringUtils.hasText(remark)) { boolQuery.must(QueryBuilders.matchQuery("remark", remark)); } SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query(boolQuery); sourceBuilder.from(0); sourceBuilder.size(20); sourceBuilder.sort("age", SortOrder.DESC); searchRequest.source(sourceBuilder); return client.search(searchRequest, RequestOptions.DEFAULT); }逻辑说明:boolQuery.filter 里的子句只做过滤不参与打分,这里对 term 和 range 用 filter 是合理的,因为精确匹配本来就不需要算相关度分数。matchQuery 放在 must 里,让备注字段的文本相关性参与打分排序。
这里要重点强调 Term 的字段类型匹配。如果 user 索引里没有名字的 keyword 子字段,而 mapping 里 name 是 text 类型,termQuery 会查不出任何数据。旧版本 ES 字符串默认同时建有 text 和 keyword 子字段,但从 7.x 开始默认行为变化了,所以代码里必须确认 mapping 里确实存在name.keyword或直接的 keyword 字段。
分页排序参数里,from和size是浅分页的经典组合。ES 的默认限制是from + size不能超过 10000,业务上翻页超过这个阈值时,深度分页会导致协调节点内存压力陡增。如果真有超过 1 万页的场景,更合适的方式是 scroll 或 search_after,而不是调大index.max_result_window。
4.2 高亮与聚合:代码怎么写才能直接用在列表页
业务里最常用的两个能力是搜索结果高亮和按类目聚合统计。高亮在 ES 7.2.0 的实现方式是从响应对象里取高亮片段,SpringBoot 这边不需要额外依赖。
public SearchResponse searchWithHighlight(String keyword, int page, int size) throws IOException { SearchRequest searchRequest = new SearchRequest("product"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query(QueryBuilders.matchQuery("title", keyword)); sourceBuilder.from((page - 1) * size); sourceBuilder.size(size); HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("title"); highlightBuilder.preTags("<span class='highlight'>"); highlightBuilder.postTags("</span>"); highlightBuilder.fragmentSize(80); sourceBuilder.highlighter(highlightBuilder); searchRequest.source(sourceBuilder); return client.search(searchRequest, RequestOptions.DEFAULT); }取结果的代码片段:
for (SearchHit hit : response.getHits().getHits()) { Map<String, HighlightField> highlightFields = hit.getHighlightFields(); HighlightField titleHighlight = highlightFields.get("title"); if (titleHighlight != null) { String highlightText = Arrays.stream(titleHighlight.getFragments()) .map(Text::string) .collect(Collectors.joining()); // 用 highlightText 替换前端展示的 title } }参数说明:preTags和postTags设置高亮前缀后缀,这里用了 span 标签加 class,前端可以直接吃样式。fragmentSize(80)控制高亮片段长度,单位是字符数。如果业务不需要片段只想要整字段高亮,可以把 fragmentSize 设大一些,但过大会导致高亮性能下降。
聚合查询通常用于侧边栏筛选。以下是按品牌聚合的实现方式:
public SearchResponse aggregateByBrand(String keyword) throws IOException { SearchRequest searchRequest = new SearchRequest("product"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.query(QueryBuilders.matchQuery("title", keyword)); sourceBuilder.size(0); TermsAggregationBuilder agg = AggregationBuilders.terms("brand_agg") .field("brand.keyword") .size(10); sourceBuilder.aggregation(agg); searchRequest.source(sourceBuilder); return client.search(searchRequest, RequestOptions.DEFAULT); }siz(0)的作用是让查询结果只返回聚合结果,不返回具体文档,这是搜索页侧边栏聚合的标准写法。brand.keyword要求 mapping 里存在 keyword 子字段或直接字段,否则 terms 聚合会报错。聚合结果的解析需要从 response.getAggregations() 里取出 ParsedStringTerms 对象,再遍历 bucket。
要注意的是,7.2.0 对 text 字段的聚合默认是禁止的,必须用 keyword 子字段。如果建索引时没有给 brand 建 keyword 子字段,唯一的后悔药是重建索引做 reindex,这个问题在开发环境就要想清楚。
5. 避坑:SpringBoot 整合 ES 7.2.0 的 5 个高频问题排查
5.1 启动不报错,查询时提示 Connection refused
现象:应用启动正常,第一个查询请求抛 ConnectException,连接 localhost:9200 拒绝。
原因:Spring Boot 2.2+ 里旧配置项spring.data.elasticsearch.cluster-nodes不再生效,它不会报错,只会让客户端拿到默认 localhost 地址。
解决:检查 application.yml 里是否用的是spring.elasticsearch.rest.uris。如果服务端不在本机,配置为http://<ES节点IP>:9200。如果多个节点,uris 支持逗号分隔多个地址。
5.2 依赖冲突导致 NoSuchMethodError
现象:项目一启动就报java.lang.NoSuchMethodError: org.elasticsearch.common.xcontent.XContentType或 Lucene 相关类找不到。
原因:工程里还有其他模块引入了低版本 elasticsearch 核心包,Maven 依赖仲裁把 7.2.0 覆盖成了旧版,或者把 Lucene 版本拉低了。
解决:执行mvn dependency:tree查看 elasticsearch 相关依赖,把低版本传递依赖用 exclusion 排除。我一般直接在 high-level-client 的依赖上把elasticsearch和lucene-core一并排除,再显式引入 7.2.0 的elasticsearch核心包,这样版本路径最清晰。
5.3 LocalDateTime 序列化导致文档写入失败
现象:通过 ObjectMapper 序列化 User 对象写入 ES 时,抛日期序列化异常或写入后时间字段是数组结构。
原因:ES 7.2.0 服务端默认把不带格式的日期字符串解析为 date 类型时按 ISO 标准处理,而 Java 侧 LocalDateTime 默认序列化格式不是 ISO,导致两边解析不一致。另外如果 ObjectMapper 没注册 JavaTimeModule,LocalDateTime 根本无法序列化。
解决:在 ObjectMapper 上注册 JavaTimeModule,并禁用WRITE_DATES_AS_TIMESTAMPS:
ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);同时建议在索引 mapping 里把日期字段显式声明格式,比如"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis",避免服务端猜测解析。
5.4 重复创建 RestHighLevelClient 导致连接数不可控
现象:应用运行一段时间后,ES 节点上 ESTABLISHED 连接数持续增长,甚至触发 max_clause_count 或节点 file descriptor 超限。
原因:代码里每次查询都new RestHighLevelClient,用完又没 close。客户端内部连接池连接没有被回收,最终耗尽节点连接资源。
解决:严格使用配置类单例注入。如果用了 Spring Boot 的@Autowired注入 RestHighLevelClient,确认只有一个注入点,不要为某个工具类单独 new 一个。
5.5 中文分词不生效
现象:matchQuery 查询中文关键词,单字匹配能查到,词组完全查不到;或英文匹配正常,中文结果为空。
原因:索引 mapping 里 text 字段没有配置 IK 分词器,ES 默认 standard analyzer 对中文按单字切分。服务端可能也没有安装 IK 插件。
解决:先确认服务端已安装 IK 插件(elasticsearch-plugin list能看到),然后重建索引,mapping 里设置"analyzer": "ik_max_word"和"search_analyzer": "ik_smart"。这里必须重建索引才能生效,IK 插件的加载发生在索引创建时,改 mapping 不能热更新到已创建的字段。
6. 验证与进阶:连接池参数、健康检查和索引别名
整套整合跑通后,生产环境还需要补三道工序。第一是连接池参数调优。RestHighLevelClient 默认连接池对单路由 maxConnPerRoute 是 10,总连接是 30,这个数值对于只做普通业务查询够用,但如果你的服务同时承担写入和聚合任务,很容易出现等待连接超时。常见调整方式是在构建 builder 时增加:
builder.setHttpClientConfigCallback(httpClientBuilder -> { httpClientBuilder.setMaxConnTotal(100); httpClientBuilder.setMaxConnPerRoute(50); return httpClientBuilder; });setMaxConnTotal是总连接数,setMaxConnPerRoute是到单个 ES 节点的连接数。如果集群有 3 个节点,perRoute 设 50 意味着协调节点能接受足够并发;如果集群节点数多,total 要按 perRoute 乘节点数再加余量。
第二是健康检查。SpringBoot 工程里加一个定时任务,对 ES 做轻量 ping:
@Component public class EsHealthTask { private final RestHighLevelClient client; public EsHealthTask(RestHighLevelClient client) { this.client = client; } @Scheduled(fixedDelay = 60000) public void ping() { try { boolean isConnected = client.ping(RequestOptions.DEFAULT); if (!isConnected) { // 上报告警或切换备用节点 } } catch (IOException e) { // 记录日志并触发告警 } } }注意 ping 只是检查 TCP 层可达,不代表集群是 green 状态。要真正判断索引是否可用,还得定时执行一次 exists 查询或 cluster health 请求,看返回的状态字段。
第三是索引别名。7.2.0 时期最稳妥的索引演进方式是别名 + 重建索引,而不是原地 update mapping。给索引建好别名,业务代码里所有读写都用别名,未来升级分词器或加字段时创建新索引,用 reindex 迁移数据,再原子地把别名指向新索引。这套玩法配合 SpringBoot 的定时任务可以做成自动化,是生产环境必备的手段。
我在项目里踩过最深的坑就是升级 IK 分词版本时直接改了原索引 mapping,结果线上搜索全挂,最后只能创建新索引、改代码里索引名,临时灰度切换才恢复。从那以后,所有索引都默认带别名,新业务上线前先跑一遍 reindex 演练。希望帮到你。
本文还有配套的精品资源,点击获取