1. 集成方案选型:为什么非要把 Spring Boot 和 Elasticsearch 凑在一起
1.1 这套组合到底能解决什么实际问题
先把话说透。Elasticsearch 不是传统意义上的数据库,它本质是一个基于 Lucene 的分布式搜索和分析引擎。而 Spring Boot 是 Java 生态里最主流的微服务框架,两者凑在一起,最常见的业务场景就三类:站内搜索、日志检索、数据分析统计。
举个我实际经手过的例子。之前做一个电商后台的商品管理模块,数据库里几百万条商品记录,MySQL 的 LIKE '%关键词%' 查询根本没法扛,一个搜索请求能把 CPU 打到 80% 以上,而且查出来的相关性排序完全是玄学。后来就是把商品数据同步到 Elasticsearch,用它的全文检索能力做站内搜索,查询响应从秒级直接降到百毫秒以内,还顺手解决了拼音搜索、同义词替换这些 MySQL 根本做不了的需求。
所以这篇文章要解决的问题很明确:怎么在 Spring Boot 项目里把 Elasticsearch 集成进来,从环境准备到核心代码再到实战排坑,完整走一遍。适合正在做搜索、日志分析、数据统计这类功能的 Java 开发者参考,尤其是那种已经会用 Spring Boot 做 CRUD、但第一次接触 ES 的人。
1.2 选型中的几个关键决定
集成方式上,业界主流有三条路:Spring Data Elasticsearch、RestHighLevelClient、以及官方最新的 Elasticsearch Java API Client。我直接给结论。
如果你用的是 Spring Boot 2.7.x 及以下的版本,老老实实用 Spring Data Elasticsearch 就好,因为它在 spring-boot-starter-data-elasticsearch 里直接封装好了,通过 Repository 接口就能操作 ES,写起来最像 JPA,团队上手成本低。但要注意,Spring Data Elasticsearch 对复杂查询的支持比较弱,很多 DSL 还得靠原生 QueryBuilder 去拼,等于半个残废。
RestHighLevelClient 是 Elasticsearch 官方在 7.x 时代主推的 Java 客户端,功能全、API 直白,写复杂查询特别顺手,但它在 Spring Boot 里没有自动配置,需要自己 new 出来塞进容器里管理。我当时做电商搜索用的就是它,因为要写高亮、聚合、自定义排序这些复杂逻辑,Spring Data 那套封装反而碍手碍脚。
Elasticsearch Java API Client 是 8.x 之后的官方推荐,用 ElasticsearchClient 替代了 RestHighLevelClient,API 风格大变,各种 builder 嵌套写法,说实话上手曲线更陡一些。如果你是新项目、直接上 ES 8.x,那就用这个,反正旧的客户端在 ES 8 里已经标记为废弃了。
注意:这里有个非常容易踩的版本坑。Spring Data Elasticsearch 和 Elasticsearch 服务端之间的版本兼容关系特别严格,比如 Spring Boot 2.5.x 对应的 Spring Data Elasticsearch 4.2.x,只兼容 ES 7.12 左右,硬拿它去连 ES 8.x 的服务端,启动就报 version 兼容性错误。这个我在第五章节里详细说。
2. 环境准备:先把 Elasticsearch 跑起来再说
2.1 Windows / Docker 三种启动方式对比
咱们先从最让人头大的第一步说起——怎么把 Elasticsearch 服务端跑起来。很多人第一步就在这卡住了。
Windows 下的启动方式是:先去官网下载对应版本的 zip 压缩包,解压后进入 bin 目录,双击 elasticsearch.bat 或者命令行执行elasticsearch.bat。这里有个关键点,ES 默认不允许你用 root 用户跑,在 Windows 上倒是没这个限制,但要注意 JDK 版本。ES 7.x 自带捆绑了 JDK,你系统里就算没有装 JDK 也能跑,只是如果系统里有 JAVA_HOME,它会优先用系统的。
启动完验证一下,浏览器打开 http://localhost:9200,看到一段包含"cluster_name" : "elasticsearch"的 JSON 就算成功了。这里我建议第一次跑的时候在 elasticsearch.yml 里把xpack.security.enabled保持默认的 false,否则后面 Spring Boot 连接时还得配用户名密码,平白多一道坎。
Docker 启动是我现在最推荐的方式。一条命令跑完,永远没有环境问题:
docker run -d --name es-node -p 9200:9200 -p 9300:9300 \ -e "discovery.type=single-node" \ -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ docker.elastic.co/elasticsearch/elasticsearch:7.17.10这里几个参数值得解释一下。discovery.type=single-node表示以单节点模式运行,不然 ES 会以为自己在集群环境里,疯狂找其他节点,最终因找不到而启动失败。ES_JAVA_OPTS设置堆内存大小,默认是 1G,如果机器内存紧张就得调小一点,但别低于 256m,否则 ES 起不来。我最早在公司一台 4G 内存的测试机上跑 ES,没设置堆内存,结果整个机器直接卡死,后来才知道是内存不够。
Linux 下如果你没有 Docker,那就下载 tar.gz 包解压,创建一个es用户,因为 ES 不允许 root 账号直接跑,然后./bin/elasticsearch -d后台启动。这一步忘了创建用户的话,启动就报can not run elasticsearch as root,新手必踩。
2.2 pom.xml 依赖版本到底怎么配才不炸
这一段是整个集成过程中最需要关注的部分,版本配不对,后面全白搭。我用 Spring Boot 2.7.18 为例,pom.xml 里加这么一段:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> </dependencies>Spring Boot 2.7.18 对应的 Spring Data Elasticsearch 版本是 4.4.x,它兼容 Elasticsearch 服务端 7.17.x。所以你对应的服务端就应该装 7.17.x。不要乱升版本,Spring Boot 2.7.x 配 ES 7.17.x 是我压测过最稳的组合,两者在 Lucene 内部协议上完全兼容,各种 query 语法都能正常工作。
如果你非要在 Spring Boot 3.x 里集成 ES,那对应的 Spring Data Elasticsearch 是 5.x,ES 服务端推荐用 8.10+。这里有个重要区别,Spring Boot 3 要求 JDK 17,而且默认用的 Java API Client 的 API 风格跟之前完全不同,需要另外适应。
还有一个细节,很多人在 pom.xml 里直接引org.elasticsearch.client:elasticsearch-rest-high-level-client这个库,然后发现跟 Spring Boot 的自动配置冲突。其实不用引,spring-boot-starter-data-elasticsearch里面已经传递依赖了相关客户端。你自己引反而容易引入版本不一致的两个 jar。
2.3 application.yml 里几个容易被忽略的参数
依赖加好之后,配置文件是下一个重点。我在 application.yml 里通常这么写:
spring: elasticsearch: uris: http://localhost:9200 connection-timeout: 5s read-timeout: 30s username: password:有人习惯用spring.data.elasticsearch.cluster-nodes这个旧配置,那是在 Spring Boot 2.0 之前的写法,现在已经废弃了。Spring Boot 2.2+ 之后统一用spring.elasticsearch.uris。而且不需要配置 cluster-name 了,新版客户端直接通过 HTTP 协议连接,不走 transport 协议的 9300 端口。
另外一个容易忽略的参数是connection-timeout和read-timeout。默认值其实比较短,如果 ES 写入高峰期有积压,查询时间长一点就报超时,所以我把 read-timeout 调到 30 秒。不过也别调太大,否则接口层面容易拖死。生产环境里我一般还会加一个spring.elasticsearch.socket-timeout,优先级更高,但很多版本里这个参数名不生效,得在客户端 Bean 里手动设置。
3. 核心代码实现:从一个商品搜索说起
3.1 实体类映射:Java 字段和 ES 索引如何对应
环境搭好了,接下来就是写代码了。先别急着写 Repository,先搞清楚实体映射。ES 里没有表的概念,只有索引(Index),一个索引相当于 MySQL 里的一张表,一条文档(Document)相当于一行记录。
我拿商品搜索举例,定义一个商品实体类:
@Data @Document(indexName = "product") public class Product { @Id private Long id; @Field(type = FieldType.Text, analyzer = "ik_max_word") private String name; @Field(type = FieldType.Text, analyzer = "ik_max_word") private String description; @Field(type = FieldType.Keyword) private String brand; @Field(type = FieldType.Double) private Double price; @Field(type = FieldType.Integer) private Integer stock; @Field(type = FieldType.Date, format = DateFormat.date_time) private LocalDateTime createTime; }关键点在于@Field注解的 type 选择。Text类型表示这个字段要全文索引,会被分词器拆成一个个词项,用于搜索时做相关性匹配。Keyword类型表示不分词,整个字符串作为一个词项,用于精确匹配、排序、聚合。比如品牌字段,如果你设成 Text,做聚合统计时你会发现结果被拆得很碎,完全不是想要的。
analyzer = "ik_max_word"是中文分词器。ES 默认的标准分词器对中文不友好,会把“连衣裙”拆成“连”、“衣”、“裙”三个单字,搜索“连衣裙”反而匹配不到。这个时候需要安装 IK 分词器插件,在 ES 安装目录执行./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.17.10/elasticsearch-analysis-ik-7.17.10.zip,版本必须和 ES 完全一致,不然装不上。
3.2 ElasticsearchClient 的配置与封装
如果你决定用官方客户端(RestHighLevelClient),那还得手动配一个配置类。这一步在 Spring Boot 里没有自动配置,但也不复杂:
@Configuration public class ElasticsearchConfig { @Bean public RestHighLevelClient restHighLevelClient() { return new RestHighLevelClient( RestClient.builder( new HttpHost("localhost", 9200, "http") ) .setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder.setConnectTimeout(5000) .setSocketTimeout(30000) ) .setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setMaxConnTotal(100) .setMaxConnPerRoute(50) ) ); } }setRequestConfigCallback设置的是连接超时和 socket 超时,对应我前面说的read-timeout。setHttpClientConfigCallback设置的是 HttpClient 的连接池参数,在并发量大的时候特别重要,不调这个的话默认连接数只有 20,压测一下就会发现大量请求在等着拿连接。我们生产环境调整到 maxConnTotal 100 后,接口 P99 延迟从 800ms 降到了 180ms,效果非常明显。
3.3 增删改查:基础 CRUD 完整实现
CRUD 这块我不走 Spring Data Repository 的路线,直接展示 RestHighLevelClient 的方式,因为这套写法更接近 ES 底层,排查问题的时候不容易被框架的黑盒逻辑绕晕。
新增和更新文档:
@Service public class ProductService { @Resource private RestHighLevelClient client; private static final String INDEX = "product"; public void saveProduct(Product product) throws IOException { IndexRequest request = new IndexRequest(INDEX) .id(String.valueOf(product.getId())) .source(JSONUtil.toJsonStr(product), XContentType.JSON); client.index(request, RequestOptions.DEFAULT); } }删除文档:
public void deleteProduct(Long id) throws IOException { DeleteRequest request = new DeleteRequest(INDEX) .id(String.valueOf(id)); client.delete(request, RequestOptions.DEFAULT); }按 id 查询:
public Product getProduct(Long id) throws IOException { GetRequest request = new GetRequest(INDEX, String.valueOf(id)); GetResponse response = client.get(request, RequestOptions.DEFAULT); if (response.isExists()) { return JSONUtil.toBean(response.getSourceAsString(), Product.class); } return null; }用IndexRequest配合id()这个方法,如果 id 已存在则覆盖更新,不存在则插入。这是 ES 的覆盖式更新,简单粗暴。但我这里提醒一句,ES 底层的更新其实是“先删除旧文档再写入新文档”,这在高频更新场景下存在性能隐患,后面讲到写入慢的问题时我还会提。
注意:JSON 序列化我用的 Hutool 的
JSONUtil,你也可以用 Fastjson2 或 Jackson,原理都一样,只要把对象转成字符串就行。但别用 Gson,它在处理 LocalDateTime 这类 Java 8 时间类型时容易出乱子。
4. 查询进阶:搜索功能不是只有 match
4.1 常用查询 DSL 在 Java 里怎么写
做电商搜索,最基础的场景就是关键词搜索加品牌筛选。我用 SearchSourceBuilder 来组装查询条件:
public List<Product> searchProducts(String keyword, String brand) throws IOException { SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 关键词搜索,匹配商品名称和描述 if (StrUtil.isNotBlank(keyword)) { boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "name", "description")); } // 品牌精确筛选 if (StrUtil.isNotBlank(brand)) { boolQuery.filter(QueryBuilders.termQuery("brand", brand)); } sourceBuilder.query(boolQuery); SearchRequest searchRequest = new SearchRequest(INDEX); searchRequest.source(sourceBuilder); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); return Arrays.stream(response.getHits().getHits()) .map(hit -> JSONUtil.toBean(hit.getSourceAsString(), Product.class)) .collect(Collectors.toList()); }这里面两个点值得解释。multiMatchQuery同时匹配多个字段,ES 内部会给每个字段算一个相关度分数,取综合值,比分开两个 match 再拼 bool 要更合理。termQuery是精确匹配,注意它针对的是 Keyword 类型的字段,如果 brand 被定义成 Text,这个查询就匹配不到。我见过太多人在这上面踩坑,排查到半夜才发现是字段类型定义错了。
filter和must的区别也很关键。filter只做过滤,不参与相关度评分,性能更好,结果可以被缓存。而must会计算相关性分数,影响排序。凡是“筛选”性质的查询条件,比如品牌、价格区间、库存状态,都应该用 filter 而不是 must,这也是很多刚接触 ES 的人容易忽略的性能优化点。
4.2 分页、排序、高亮一个都不能少
搜索接口要是没有分页和排序,那基本没法用。ES 的分页用from和size参数:
sourceBuilder.from((pageNum - 1) * pageSize); sourceBuilder.size(pageSize); sourceBuilder.sort("price", SortOrder.ASC);从第from条开始取size条,这是最基础的深度分页。但如果页码很深(比如搜到第 100 页),ES 会在每个分片上先取 from+size 条数据再合并排序,这个操作在from很大的时候性能开销巨大。这也是为什么 ES 官方建议用search_after代替深分页的原因,它通过上一页最后一个结果的排序值来定位下一页,不跳页,但没法直接跳转到任意页码。做电商搜索场景如果翻页超过 10 页,我一般直接限制不让翻,改成“加载更多”按钮,底层用 search_after。
高亮是搜索功能里提升体验的重要一环。实现也不复杂:
HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("name").preTags("<em class='highlight'>").postTags("</em>"); sourceBuilder.highlighter(highlightBuilder); // 响应里取高亮片段 SearchHit hit = hits[i]; Map<String, HighlightField> highlightFields = hit.getHighlightFields(); if (highlightFields.get("name") != null) { String highlightName = highlightFields.get("name").fragments()[0].string(); // 用这个高亮后的 name 渲染页面 }高亮里有个坑,如果查询条件用了multiMatchQuery同时搜 name 和 description,那这两个字段都要在HighlightBuilder里加一遍,不然只有一个字段有高亮。而且被高亮的字段必须是 Text 类型,Keyword 类型的高亮不会生效,因为不分词内部直接匹配整个值。
4.3 聚合查询做统计报表
ES 的聚合功能强大到很多时候可以替代一部分 BI 报表工作。我做个实际例子:按品牌统计商品数量、平均价格。这在电商后台是常规需求:
sourceBuilder.size(0); // 不返回明细,只返回聚合结果 sourceBuilder.aggregation( AggregationBuilders.terms("brandAgg").field("brand") .subAggregation(AggregationBuilders.avg("priceAvg").field("price")) ); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); Terms brandAgg = response.getAggregations().get("brandAgg"); for (Terms.Bucket bucket : brandAgg.getBuckets()) { String brandName = bucket.getKeyAsString(); long count = bucket.getDocCount(); Avg priceAvg = bucket.getAggregations().get("priceAvg"); double avg = priceAvg.getValue(); }注意size(0),这是聚合查询的标准操作,因为聚合结果只关心统计值,不关心具体文档,把 size 设为 0 能大幅减少传输数据量。terms聚合基于字段的所有唯一值分组,要求字段必须是 Keyword 类型,否则结果里的 bucket 会被拆成无数个词的组合,完全没法看。
5. 实战排坑:写入慢、版本冲突、数据一致性
5.1 写入慢不一定是 ES 的锅,先看这几个指标
有个很典型的问题场景:系统每天从 MySQL 同步几十万条商品数据到 ES,发现写入速度越来越慢,一开始每秒能写 2000 条,后来降到每秒 300 条。这个时候不要急着调代码,先看几个关键指标。
磁盘 I/O 和段合并(merge)。ES 写入数据时先把文档写到内存 buffer 和 translog 日志,然后定期 refresh 生成新的 Lucene 段。段多了之后后台会做段合并,这个操作特别消耗磁盘 I/O。如果写入慢发生在大量更新、删除操作之后,十有八九是 merge 在拖后腿。你可以在 ES 的监控页面看indices.merge相关的指标,如果 merge 耗时很高,试试调大index.merge.scheduler.max_thread_count,但别乱调,它跟 CPU 核数有关。
refresh_interval 设置。ES 默认每秒刷新一次,刷新就会生成新段、触发 I/O。对导入数据的批处理场景,这个频率太高了。我通常在做批量导入之前,把刷新间隔临时调大,导完再改回来:
PUT /product/_settings { "index": { "refresh_interval": "30s" } }这个操作能把写入吞吐翻倍都不止。但要清楚代价,刷新间隔越大,新写入的数据可以被搜到的时间就越晚,实时性变差。业务能接受就调,不能就别动。
bulk 批次大小。客户端一次批量写入的条数对性能影响很大。很多人一次性把 10 万条数据全塞进一个 bulk 请求,结果内存爆了、请求超时。也见过一批就塞 100 条,网络往返开销太大,写不快。我实测下来的经验值是每批 5000 到 10000 条,或者单批数据量控制在 10MB 到 15MB 之间,超过这个阈值响应时间会明显变长。
还有一个常被忽略的点——shard 数量。如果一个索引分了 20 个分片,每个分片都是独立写入的,理论上并行度高。但分片太多也会导致每个分片的数据量太少、merge 频繁。标准建议是单个分片控制在 30GB 到 50GB 数据量。我见过一个团队给数据量只有几百万条的索引分了 15 个分片,结果每次 merge 都在空转,写入性能反而比 5 个分片差。
5.2 版本不兼容的两个经典报错
版本兼容问题在 Spring Boot 集成 ES 里太容易碰到了。第一个经典报错长这样:
Caused by: java.lang.IllegalStateException: This version of the Elasticsearch client (7.10.2) is not compatible with Elasticsearch server (8.1.0).这个报错本身说得很明白。客户端 7.10.2 连了 8.1.0 的服务端,不兼容。但实际场景往往是:你 pom.xml 里引入的elasticsearch-rest-high-level-client版本,是 Spring Boot 父级依赖里的 BOM 决定的,而你自己又单独引了一个其他版本的 ES 客户端 jar,导致 jar 版本和自动配置的版本不一致。解决办法是把 Spring Boot 的版本和 ES 服务端版本对拍好,不要手动去盖 BOM 里的版本号。
第二个报错是初始化连接的时候版本不对,表现为NoNodeAvailableException,看着像是网络不通,让人容易去查防火墙,实际上就是客户端和服务端版本协商失败。排查思路:先curl http://localhost:9200看服务端版本号,再去 jar 包管理里看实际引入的客户端版本号,两者大版本必须一致(小版本差一点点通常问题不大)。
5.3 数据库和 ES 的数据一致性方案
数据库是 MySQL(或任何关系型库),ES 是搜索引擎,双写一致性是绕不开的话题。写代码的时候如果直接在业务代码里先更新 MySQL 再操作 ES,时间长了必然出现两边数据不一致。
我按从简单到可靠的顺序说三种方案。
第一种,同步双写。业务事务里,先写 MySQL,成功后立即写 ES。这个方案实现最简单,但 ES 写入失败时只能靠日志人工补偿,而且会拖慢主业务的响应时间。只适合数据量小、对一致性要求不高的场景。
第二种,MQ 异步同步。更新 MySQL 成功后发一个消息到 RocketMQ / RabbitMQ,消费者拿到消息后操作 ES。好处是 ES 写入失败可以重试,主业务响应时间不受影响。但要注意消息顺序问题,如果同一商品的更新消息发了两次,先到的是新数据后到的是旧数据,ES 最终就会变成旧值。解决办法是按业务 id 做顺序消息,或者消息里带版本号,消费时做比对。
第三种,基于 binlog 的实时同步。用 Canal 监听 MySQL binlog,把数据变更推给 ES。这个方案对业务代码零侵入,不依赖开发人员在业务逻辑里写双写代码,只要 MySQL 开启了 binlog,任何一条更新语句都会同步过去。这也是我目前最推荐的生产级方案,但部署 Canal 需要额外成本,对团队运维能力有一定要求。
我在实际项目里的做法是,CRUD 接口双写(简单直接),同时在后台挂了一个定时任务,每五分钟扫一次最近更新的数据做增量补偿。这套组合拳保持了最终一致性,排查数据问题时也有后手。
6. 常见问题速查表(直接照着排查)
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
启动报can not run elasticsearch as root | 用 root 用户执行 ES | 换成非 root 用户启动 |
NoNodeAvailableException | 服务端没启动、端口不对、版本不兼容 | curl 验证 9200 端口,对比客户端服务端大版本 |
| 中文分词不生效,搜“连衣裙”搜不到 | 没装 IK 分词插件,或字段 type 不是 Text | 安装对应版本的 IK 插件,重新建索引 |
| 写入速度越来越慢 | 磁盘 I/O 瓶颈、refresh 太频繁、分片规划不合理 | 看 merge 指标,调整 refresh_interval,重设分片数 |
| Spring Boot 连接 ES 超时 | connection-timeout 太短 | 在客户端 Bean 里调大 socketTimeout |
| 查询高亮不显示 | 高亮字段是 Keyword 类型 | 改成 Text 类型并重建索引 |
| 聚合结果碎成多个 bucket | 字段是 Text 类型(被分词了) | 改用 Keyword 字段做聚合 |
最后分享两个小经验。第一,ES 的索引别动不动就删了重建,生产环境的索引一旦建立,mapping 就不能随意改字段类型。必须在初期设计阶段把字段类型定清楚,不然只能重新导入数据。第二,Spring Boot 集成 ES 的项目里,把 ES 的地址配置放到 Nacos 或 Apollo 配置中心,不要写死在 application.yml 里,不然换环境就要改代码重新发版。
我个人是在踩过版本兼容的大坑之后,才养成了每次集成前先查官方版本兼容矩阵的习惯。Spring Boot 版本、Spring Data 版本、ES 服务端版本、Java 客户端版本,这四个东西必须放在一张表格里对好,少一个环节出问题,排查起来就是噩梦。这套集成方案按上面的步骤走下来,只要版本对齐、配置正确,基本可以一路绿灯跑通。