1. 先弄清楚:什么样的项目真的需要把 ES 请进来
前段时间帮朋友排查一个 SpringBoot 项目的线上问题:商品数据每天凌晨通过定时任务同步到 Elasticsearch,但前端按商品名搜索时经常搜不到东西。日志、同步任务、索引状态看了一圈都没毛病,最后发现是实体类里有个字段没加映射注解,ES 那边把驼峰字段名下划线处理后,查询条件和实际 mapping 对不上。这类问题在 ES 集成 SpringBoot 的项目里太常见了,也不算什么高深技术,但排查起来就是要浪费大半天。
借这个机会,我把 ES 在 SpringBoot 里的集成使用,从决策、选型、落地到排坑完整梳理一遍。这篇文章适合两类人:一类是项目准备接入 ES、但不确定该从哪里入手的开发者;另一类是已经在用 Spring Data ES、却被各种诡异问题折磨过的朋友。看完你能知道每一步为什么要这么做,而不只是照抄代码。
先说结论性质的话:ES 不是数据库,它本质上是基于 Lucene 的分布式检索和分析引擎,在 SpringBoot 项目里的定位是"搜索专用通道",而不是 MySQL 的替代品。这个定位如果搞错了,后面所有设计都会走偏。
1.1 哪些场景是真的需要 ES
判断一个 SpringBoot 项目要不要引入 ES,可以从业务查询特征来反推。如果你面对以下其中一类问题,ES 大概率是合适的选择。
全文检索场景。用户输入"苹果手机",期望能匹配到标题含"Apple iPhone"、"苹果 手机"等不同写法、不同分词粒度的文档。传统数据库的 LIKE 做不到"智能分词召回",而 ES 通过倒排索引和分词器设计,天生就是干这个的。
多变条件组合查询。商品、订单、日志这类数据,查询条件是动态拼接的:价格区间、品牌筛选、上架状态、库存、地域、时间范围。用 MySQL 写这种动态 SQL 会越写越复杂,索引策略也难设计,而 ES 的 bool 查询天然适合动态条件组合。
聚合统计场景。比如运营后台要看"每个品牌下有多少商品、价格分布如何、最近 30 天每个类目销量排行"。ES 的 agg 聚合能做到秒级响应,MySQL 在这种多维度统计上往往要写很长很长的 SQL,效果还不好。
数据量到了一定规模。单表几百万行、千万行以上,分页越来越慢,慢查询优化到头了,这时候把检索能力拆到 ES 是很自然的架构演进。
我实际见过的健康架构是:MySQL 作为唯一事实来源,负责事务和基础查询;ES 作为读侧扩展,专门伺候搜索、筛选、聚合这些"读多写少但条件复杂"的场景。两边通过同步链路保持最终一致。
1.2 不接入 ES 的 N 种情况
和"该不该用"同样重要的是"不该用的时候别硬上"。这几种情况我都不建议引入 ES:
数据量很小。几万条甚至几十万条数据,用户查询模式也简单,MySQL 一个普通索引加上 LIKE 或用好覆盖索引就能解决。引入 ES 等于给自己增加数据同步、一致性、集群运维三份工作,收益几乎为零。
查询模式固定且简单。如果查询就是"按用户 ID 查最近订单"这种固定场景,MySQL 主键或普通二级索引足够,没必要为了检索而检索。
团队没有 ES 运维经验。ES 集群和 MySQL 不一样,内存、磁盘、JVM、分片数、副本策略都要有人懂。团队完全没经验就上生产,遇到一次集群脑裂或者写入堆积就够受的。
对数据一致性要求极高。ES 是近实时系统,写入成功后默认 1 秒左右才能被搜索到(refresh interval)。如果业务要求写入后立即查询必须拿到最新数据,又不想接受这个延迟,那要么别用 ES,要么就得引入额外的强一致设计,复杂度会上升一个台阶。
我做技术选型时经常问团队一句:这个功能是"搜索需求"还是"查询需求"?查询需求交给数据库,搜索需求才考虑 ES。这个判断能省掉大量的无谓复杂度。
1.3 接入 ES 的决策清单
如果你的项目确认满足上面说的"需要 ES"的场景,接下来动手前先过一遍这五件事:
- 数据的总量级和期望的响应时间:决定了分片数、节点规模、查询方式。
- 主要查询类型:全文搜索、筛选统计、精确匹配,决定 mapping 设计。
- 数据来源和同步方式:binlog 监听、定时任务、消息队列消费,决定写入链路。
- 版本范围:SpringBoot 版本、ES 服务端版本,这一步最容易被忽视也最致命。
- 索引的升级策略:索引名带不带版本号,mapping 可不可变,决定了未来能不能平滑演进。
第一个问题解决之后,来到真正的"第一道坎"——版本。这一块翻车的概率极高,我在下一章详细拆。
2. 版本兼容是第一道坎:客户端选型决定你后面顺不顺
有人觉得集成 ES 就是把依赖加进去、配置写上去、代码一跑就完事。实际上版本兼容问题在我接手过的项目里出现频率最高,而且一旦出事就是全局性的:启动报错、查询报错、字段映射对不上,全都和版本有关。
2.1 Spring Boot 与 ES 服务端的版本对应关系
Spring Data Elasticsearch 是 Spring 家族对 ES 的封装,但它的发布节奏不完全跟 ES 服务端同步。Spring Boot 版本定了,Spring Data ES 的版本就被绑定了,而 Spring Data ES 版本又决定了默认的客户端 API 和支持的 ES 服务端版本,这是一条完整的版本链。
我整理了一张常用对照表,基于我所经历的实践版本,供参考:
| Spring Boot 版本 | Spring Data ES 版本 | 默认客户端体系 | 匹配的 ES 服务端 |
|---|---|---|---|
| 2.6.x | 4.3.x | RestHighLevelClient | 7.15.x |
| 2.7.x | 4.4.x | RestHighLevelClient | 7.17.x |
| 3.0.x | 5.0.x | 官方 Java Client | 8.5.x |
| 3.1.x | 5.1.x | 官方 Java Client | 8.7.x |
| 3.2.x | 5.2.x | 官方 Java Client | 8.10.x |
| 3.3.x | 5.3.x | 官方 Java Client | 8.12.x |
这里面有两个核心规则:
大版本必须匹配。ES 服务端 7.x 和 8.x 的 Java 客户端协议不互通,你拿 8.x 客户端连 7.x 服务端,握手阶段就会出错。反过来也是,用 7.x 客户端连 8.x 服务端也会有兼容性异常。
小版本尽量贴近。虽然 8.10 客户端连 8.12 服务端基本没什么问题,但 ES 官方只保证同一主版本内的兼容性,不同小版本的 REST API 偶有差异,尤其是新增参数和返回字段。保险做法是客户端小版本不低于服务端小版本,且差距不要太大。
2.2 三种主流客户端 API 怎么选
SpringBoot 集成 ES 主要有三种客户端方式,很多老教程还在教第二种,但实际上已经过时了。
Spring Data Elasticsearch:最省心的方式,通过 Repository 接口继承和注解实体类就能完成大部分 CRUD。优点是开发效率高,和 Spring Data JPA 的思维模式一致,团队成员学习成本低。缺点也很明显:更新滞后,对 ES 新特性的封装不及时,复杂查询写起来反而别扭。
RestHighLevelClient:这是 ES 7.x 时代官方主推的客户端,也是 Spring Boot 2.x 项目里最常见的。但 ES 官方从 7.15 开始逐渐边缘化它,8.0 正式废弃,7.17 是它最后的绝唱。你现在新起项目用它,等于给自己埋了一个未来重构的雷。如果老项目还在上面,短期不慌,但要尽快规划迁移。
官方 Java Client(co.elastic.clients:elasticsearch-java):ES 8.x 时代官方主推的新客户端。API 用了大量流畅的函数式写法,表达能力很强,和原生 DSL 结构高度一致,适合复杂查询场景。Spring Boot 3.x 的 spring-boot-starter-data-elasticsearch 底层已经切换到这个客户端上了,也就是说即使你用的是 Spring Data ES,实际操作底层的仍然是 ElasticsearchClient。
选型建议我直接给结论:
- 项目以简单 CRUD 为主、追求快速交付:用 Spring Data ES。
- 查询条件复杂、深度依赖 ES 特性:直接用官方 Java Client。
- 两者也可以共存:Spring Data ES 管实体映射和基础 CRUD,官方 Java Client 处理复杂查询。我很多项目就是这么干的,各自发挥优势。
- 新项目不要再用 RestHighLevelClient,除非你明确知道自己永远不升级 Spring Boot。
2.3 从 RestHighLevelClient 迁移的路径参考
说一个我亲手折腾过的真实案例。之前接手一个电商后台项目,Spring Boot 2.7 + Spring Data ES 4.4 + RestHighLevelClient,服务端 ES 7.17。功能本身不复杂,但后来安全扫描和依赖版本要求把 Spring Boot 升到 3.x,这时候问题是连锁的:Spring Boot 3 自带的数据权限模块、安全模块都要求升级,Spring Data ES 从 4.4 跳到 5.x,接口大批量废弃,原来依赖的 RestHighLevelClient 相关类直接被移除。
最后我的处理方式是:不硬扛,把查询层全部重写为官方 Java Client。原来的 Repository 简单查询保留,复杂查询改成 ElasticsearchClient + lambda DSL。虽然花了两三天重构,但换来的是后面的顺畅升级体验。
这里送你一个避坑认知:你选的客户端 API 决定了你的代码能活多久。依赖版本升级是迟早要面对的,选一个和 SpringBoot 生命周期同步的客户端,能少踩很多坑。
3. 从空项目到第一个索引:依赖、配置与实体映射
这一章直接上干货。我会用一个最标准的 Spring Boot 3.2.x 项目为例,一步一步搭起集成骨架。
3.1 依赖引入和 spring.elasticsearch 配置
先加依赖。在 pom.xml 里加上:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency>这个 starter 会传递引入 Spring Data ES 以及底层官方 Java Client。如果你不需要 Spring Data 的 Repository 封装,只想直接用 ElasticsearchClient,也可以只加官方 Java Client 依赖:
<dependency> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> </dependency>Spring Boot 的自动装配会帮你把连接相关的 bean 创建好,包括 ElasticsearchClient、ElasticsearchOperations(旧版本里叫 ElasticsearchRestTemplate)。这地方第一次用的人容易懵:怎么一个 @Autowired 出来好几个客户端对象?其实它们底层用同一个 RestClient,只是封装层次不同。
application.yml 配置:
spring: elasticsearch: uris: - http://192.168.1.100:9200 username: elastic password: your-password connection-timeout: 5s socket-timeout: 60s注意一个非常容易踩的版本差异:Spring Boot 2.7 及之前的配置前缀是spring.elasticsearch.rest.uris,到了 Spring Boot 3.x 变成了spring.elasticsearch.uris。网上大量教程还在写旧的rest.uris,你照抄到 3.x 项目里,会发现配置根本没生效,连接的是 localhost:9200。
connection-timeout是建立 TCP 连接的超时,socket-timeout是等待响应的超时。我建议 socket-timeout 至少给到 30 到 60 秒,因为 ES 上有些聚合查询和深分页操作确实很慢,默认 10 秒经常不够用。
3.2 实体类映射:@Document、@Id、@Field 的正确姿势
接下来是实体类和索引的映射关系。以一个商品索引为例:
@Data @Document(indexName = "product_v1", createIndex = false) public class Product { @Id private String id; @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart") private String name; @Field(type = FieldType.Keyword) private String brandName; @Field(type = FieldType.Double) private BigDecimal price; @Field(type = FieldType.Keyword) private Integer status; @Field(type = FieldType.Date, format = DateFormat.date_time) private LocalDateTime createdAt; }几个关键点:
indexName强烈建议带版本号。product_v1、product_v2这样的命名方式,为后续 mapping 升级留了退路。直接叫product的话,将来想改字段类型只能删了重建,线上数据全没。带版本号配合别名切换,能做到零停机升级。
createIndex = false是生产环境必备。开发环境可以让 Spring Data 自动建索引图省事,但生产环境我从来都是关掉的。为什么?因为自动建索引的参数往往是默认的,分片数、副本数、分词器、analyzer 可能都不符合预期,而且一旦让你自动建了索引,mapping 就被"冻结"了,后面哪怕只是加个字段都要小心翼翼。
Text 和 Keyword 的区别要刻在脑子里。Text 类型会走分词器,适合全文搜索;Keyword 类型不分词,整体作为一个词项,适合精确匹配、排序、聚合。一个常见的需求是既要分词搜索又要精确匹配,那就建双字段:name用 Text,再加上一个name.keyword用 Keyword。很多项目偷懒只建一个 Text 字段,结果排序、聚合、精确过滤全部出问题。
分词器的名字只是个字符串。代码里写analyzer = "ik_max_word"不代表 ES 服务端就真的装了 IK 分词插件。如果服务端没有这个 analyzer,建索引时直接报错 "analyzer [ik_max_word] not found"。所以要么服务端装好 IK 插件,要么记住:应用中配的分析器名称,必须和 ES 服务端实际安装的插件对应。
还有一个我实战中踩过的大坑,就是驼峰命名会自动转换。Spring Data ES 老版本(4.x)默认会把 Java 字段brandName自动转成 ES 里的brand_name,而新版本(5.x)默认保持brandName不变。如果你用一个老版本生成的索引,升级到新版本后查询条件里的字段名对不上,就是"同步成功了但搜索全空"的诡异现象。解决方式有两种:统一用@Field(name = "brandName")显式指定字段名,或者干脆所有实体属性都用下划线风格命名,不要隐式依赖框架的转换规则。
3.3 启动后先做这四件事
骨架搭好、项目启动后,不要急着写查询接口,先用下面四步确认基础环境是通的:
第一步,看索引是否创建成功。在 Kibana Dev Tools 或者 curl 里执行:
GET /_cat/indices?v如果索引不存在,去查是不是createIndex = false但自己忘了手动建索引。
第二步,看 mapping 是否符合预期:
GET /product_v1/_mapping重点检查字段类型、分词器、有没有多余的下划线字段。
第三步,写一条简单测试数据验证写入链路。用 Repository 的save方法插入一条,然后立刻查询,确认返回结果字段完整、类型转换没有异常。
第四步,在代码里也做一次启动自检。注入 ElasticsearchOperations,用indexOps(Product.class).exists()判断索引是否存在,配合项目的健康检查接口,避免线上部署后才发现索引缺失。
如果你是在开发环境,也建议至少在配置里保留createIndex = true一次,让框架自动建好索引后,手动把 mapping 导出来保存到项目里,再把自动创建关掉。这样既方便开发,又能保证生产环境的 mapping 是"被审查过的",而不是"默认的"。
4. 查询由浅入深:派生查询、@Query 与原生 DSL 的边界
查询是 ES 使用频率最高的能力,也最容易写得乱七八糟。这一章我把查询从简单到复杂完整梳理一遍,帮你划清楚每种方式的边界。
4.1 Repository 派生查询能解决多少问题
Spring Data ES 的 Repository 写法和 Spring Data JPA 高度相似。最简单的 CRUD 场景,一个接口搞定:
public interface ProductRepository extends ElasticsearchRepository<Product, String> { List<Product> findByBrandName(String brandName); Page<Product> findByPriceLessThanEqual(Double maxPrice, Pageable pageable); List<Product> findByStatusAndBrandName(Integer status, String brandName); Page<Product> findByNameContaining(String keyword, Pageable pageable); }方法名派生查询适合 term 精确匹配、范围查询、简单字段条件组合。它对开发效率的提升非常明显,团队里没人需要学 ES 的查询 DSL 就能上手写检索代码。
但有两个边界你要清楚:
派生的方法名最终是映射到 ES 查询上的,不是所有方法名都有对应实现。Containing、Like这类关键词在不同版本里映射的行为可能不一样,比如它是生成 match 还是 wildcard,依赖 Spring Data 内部的解析规则。遇到意外时别死磕方法名,换 @Query 或者原生查询更直接。
复杂组合条件千万不要试图用方法名硬凑。我曾经见过有人写了一个 80 多位的方法名来表达四个条件的组合查询,可读性完全崩溃。方法名不是 DSL,超过三个条件就应该换别的方案。
4.2 @Query 内嵌 JSON:从简单到复杂的过渡
方法名搞不定的,可以用@Query注解直接写 ES 查询 JSON。比如在 Repository 接口里加一个自定义搜索:
public interface ProductRepository extends ElasticsearchRepository<Product, String> { @Query(""" { "bool": { "must": [ { "match": { "name": "?0" } } ], "filter": [ { "term": { "brandName": "?1" } } ] } } """) Page<Product> searchByNameAndBrand(String keyword, String brandName, Pageable pageable); }?0、?1是参数占位符,按方法入参数顺序排列。分页参数 Pageable 不用占位符,框架会帮你拼上 from 和 size。
@Query 的优点是直观,一眼就能看出 ES 实际执行什么查询;缺点是 JSON 模板是静态的,想动态拼条件就得用 SpEL 或者干脆走原生 API。另外一个麻烦是:JSON 字符串里如果参数包含特殊字符,比如用户输入了引号,可能把整个查询结构搞坏,所以复杂参数场景要特别小心注入问题。
我的经验是:@Query 适合固定结构、参数数量不大、且经常需要看真实查询语的场景。动态条件多了就升级到下一节的原生查询。
4.3 复杂查询用 ElasticsearchOperations 的 NativeQuery
动态组合条件、聚合、脚本查询、嵌套查询这类高级用法,直接在 Service 层用 ElasticsearchOperations 构建原生查询。
Spring Data ES 5.x 里核心入口是 ElasticsearchOperations,用 NativeQueryBuilder 构建查询。示例:
@Service @RequiredArgsConstructor public class ProductSearchService { private final ElasticsearchOperations operations; public Page<Product> search(String keyword, Double minPrice, Integer status, Pageable pageable) { NativeQueryBuilder builder = new NativeQueryBuilder(); builder.withQuery(q -> q .bool(b -> b .must(m -> m .match(t -> t.field("name").query(keyword)) ) .filter(f -> f .range(r -> r.field("price").gte(JsonData.of(minPrice))) ) .filter(f -> f .term(t -> t.field("status").value(status)) ) ) ); builder.withPageable(pageable); SearchHits<Product> hits = operations.search(builder.build(), Product.class); List<Product> products = hits.getSearchHits().stream() .map(SearchHit::getContent) .toList(); return new PageImpl<>(products, pageable, hits.getTotalHits()); } }这套 API 的写法和官方 Java Client 的 lambda DSL 风格完全一致,读起来就是"构建一个 bool 查询,must 里加一个 match,filter 里加一个 range 和一个 term"。如果你熟悉 ES 原生查询 DSL,这个结构几乎是零学习成本的。
两个核心习惯:
filter 和 must 别混用。filter 只是过滤,不参与相关度打分,查询结果还带缓存,性能更好。凡是"条件筛选"性质的字段,比如价格区间、状态、类目,都应该放 filter。而 must 是参与打分的,适合真正影响相关性的关键词匹配。很多人不管三七二十一全塞 must,等查询变慢又到处找原因,实际上就是没用好 filter 的缓存能力。
聚合查询也走这条路。比如按品牌聚合同一价格区间内的商品数量:
builder.withAggregation("brandCount", a -> a .terms(t -> t.field("brandName.keyword").size(20)) ); SearchHits<Product> hits = operations.search(builder.build(), Product.class); Aggregation agg = hits.getAggregation("brandCount"); // 从 agg 中解析 bucket,得到品牌名和数量拿到聚合结果后,从hits.getAggregation("brandCount")解析 bucket 列表,遍历取出 key(品牌名)和 docCount(文档数)即可。这里有个容易踩的坑:聚合字段必须是 keyword 类型。你如果拿 Text 字段去 terms 聚合,ES 会直接报 fielddata 相关的错误,因为 Text 字段默认不能用于聚合和排序。所以前面强调的双字段设计在这里就体现价值了。
4.4 深分页:search_after 才是全量取数的正解
默认情况下,ES 用 from + size 分页,但index.max_result_window默认是 10000,超过这个深度直接报错。这不是 ES 故意为难你,而是 from + size 在深分页时的确会消耗巨大的内存:每次查询都要先把 from + size 条记录全部取出来排序,再丢弃前面的部分。所以对于全量导出、后台列表翻到几千页这种场景,必须换方案。
我的做法是优先用 search_after。核心思路是:不再指定"跳过多少条",而是告诉 ES"上次查到的最后一条在哪,往下继续取"。用 NativeQueryBuilder 的话,关键就是两个点:
builder.withSort(s -> s.field(f -> f.field("_shard_doc").order(SortOrder.Asc))); builder.withSearchAfter(searchAfterValues);searchAfterValues是上一次查询结果最后一条文档的排序值列表,类型是List<String>,需要从 SearchHits 里获取:
List<String> sortValues = hits.getSearchHits() .get(hits.getSearchHits().size() - 1) .getSortValues() .stream() .map(String::valueOf) .toList();然后把这一组值作为下一次查询的searchAfterValues传入,循环往复,直到拿不到数据为止。
使用 search_after 要注意两个细节:
- 必须配合排序字段。没有 sort 就没有稳定的分页游标。排序字段里最好包含一个唯一值(比如商品 ID),否则同分值的数据可能重复或丢失。
- 它只适合"顺序翻页"场景,不支持"跳转到第 N 页"。如果你做的是分页组件那种"点击页码跳转"的交互,997 页以后还得靠 from + size,那就得在业务层面限制最大页数,或者改用其他策略。
scroll API 也能做深分页,但它会在 ES 服务端保留一个上下文快照,数据量大时对内存和 GC 压力都很大,而且不适合实时性要求高的场景。新项目我建议直接用 search_after,它没有额外的服务端状态,性能也更好。
5. 写入链路别只调 save:bulk、异步与索引升级
很多项目在 ES 写入这块特别随意:业务里每次操作都直接调 Repository 的 save。数据量小没问题,量一大就是灾难。这一章专门讲写入链路的设计。
5.1 单条写入和 bulk 的正确姿势
单条写入接口简单,但每写一条数据就是一次完整的 HTTP 请求 + 刷新循环。写入量每天几万条的时候问题不明显,一旦同步任务涉及几十万上百万条,你会发现同步任务跑一晚上都跑不完。
正确的做法是批量写入。借助 ElasticsearchClient 的 bulk API:
@Autowired private ElasticsearchClient esClient; public void bulkWrite(List<Product> products) { BulkRequest.Builder br = new BulkRequest.Builder(); for (Product p : products) { br.operations(op -> op .index(idx -> idx .index("product_v1") .id(p.getId()) .document(p) ) ); } BulkResponse response = esClient.bulk(br.build()); if (response.errors()) { for (BulkResponseItem item : response.items()) { if (item.error() != null) { log.error("写入失败 id={}, error={}", item.id(), item.error().reason()); } } } }这里有两个关键优化点:
批量大小要控制。我一般以"1000 到 5000 条"或者"单批 5MB 到 15MB"作为经验值,两个条件先到先触发。为什么不是越多越好?因为 ES 服务端处理 bulk 请求时会把整个批量数据放在内存里执行索引,批量太大,JVM 堆直接被撑爆,触发频繁 GC,集群性能反而下降。批量太小又得不偿失,网络往返开销占比太高。具体最优值可以压测确定,但从经验数据来看 1000-5000 条是安全区间。
写失败必须看 error 信息。很多人 bulk 完只看response.errors()是 false 就认为成功了,实际上只要有一条失败整个 response 都会标记 errors() 为 true。必须遍历 items,把失败的文档 ID 和原因打出来,否则数据丢了都不知道。
还有一个隐蔽的问题:批量写入和检索是共享线程池的。如果写入任务长期占用大量线程,普通搜索请求会被拖慢。所以批量任务最好走独立的线程池,或者控制并发度,留一部分线程给查询。
5.2 异步写入与背压:一个没人提的线程池细节
"异步写入"是很多项目的需求:业务请求进来,不想让 ES 写入拖慢主流程,就想把写入丢到后台。这里最容易犯的错是在业务线程里直接new Thread()或者用无界队列的线程池。我见过一个项目就是在这种写法下,突发流量时线程数直接飙到几千,把整个应用打挂。
正确的做法是定义一个有边界、有拒绝策略的线程池:
@Bean("esWriteExecutor") public Executor esWriteExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(5000); executor.setThreadNamePrefix("es-write-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }然后在写入方法上标注 @Async:
@Async("esWriteExecutor") public void asyncBulkWrite(List<Product> products) { bulkWrite(products); }这个配置里最关键的是CallerRunsPolicy拒绝策略。它表示当队列满了之后,新的写入任务不丢弃,而是由调用方线程直接执行。这样做的好处是天然形成背压:写入太快时,调用方(通常是业务线程或消费线程)会被写入拖慢,从而自动限流。很多人理解背压需要什么复杂的框架,其实一个拒绝策略就能解决大部分问题。
还有一个小技巧:异步批量写的时候,不要让单条数据独立提交,而是把"攒一批再提交"。比如从 Kafka 消费消息,每次 poll 一批消息,聚合成 List ,再调一次 asyncBulkWrite。这样既减少了请求次数,又降低了 ES 的写入压力。
5.3 数据同步链路:Canal -> Kafka -> SpringBoot 消费者
大多数项目里,ES 的数据源头是 MySQL,所以数据同步是所有 ES 方案里绕不开的话题。同步链路我强烈推荐一种:MySQL binlog -> Canal -> Kafka -> SpringBoot 消费者 -> bulk 写入 ES。
这条链路的好处很明显:
- Canal 监听 MySQL binlog,实时性高,不会像定时任务那样有分钟级延迟。
- Kafka 作为缓冲层,可以扛住瞬时写入高峰,而且消费者挂了消息不丢。
- SpringBoot 消费者只负责把消息转换成 ES 文档,然后用上一节的 bulk 方式批量写入。
消费端代码的核心逻辑笔者简化成一个伪代码流程:
@KafkaListener(topics = "product-change", groupId = "es-sync") public void onProductChange(List<ConsumerRecord<String, String>> records) { List<Product> products = records.stream() .map(record -> JSON.parseObject(record.value(), Product.class)) .toList(); bulkWrite(products); }这块有四个关键注意点:
消费端必须做幂等。Kafka 消费有 at-least-once 特性,意味着同一条 binlog 消息可能被投递多次。你的 Product 主键必须映射到 ES 文档的 _id,这样重复写入只是覆盖,不会产生重复文档。永远不要依靠逻辑上去重一条消息,要依靠 ES 的 _id 覆盖机制。
消费失败不要无限重试。同一批数据如果 bulk 写失败,直接重新消费可能会导致消息积压或者消费阻塞。我的做法是:重试 2 到 3 次,仍然失败的写入到死信队列或者本地重试表,人工介入处理。ES 集群短暂不可用应该触发的是暂停消费,而不是疯狂重试把集群打死。
Kafka 消费者线程数和 ES 写入线程不要混为一谈。消费者负责拉消息,写入任务交给 ES 写入线程池,两者之间的队列就是上一节说的queueCapacity。这样即使 ES 变慢,也只是队列堆积,不会阻塞 Kafka 消费。
全量同步和增量同步分开。Canal 只是增量同步,项目第一次上线时 ES 里可能一条数据都没有。这时候需要一个全量同步工具,把 MySQL 里的存量数据一次性刷到 ES。全量同步可以用定时任务扫描主键分批拉取,然后走 bulk 写入。做完全量再做增量的衔接时,注意先启动增量 Canal,再做全量,避免全量过程中产生的增量数据被覆盖。
5.4 索引生命周期:为什么索引名要带版本号
既然前面已经提到索引名带版本号,这里展开说透。
ES 的索引 mapping 一经创建,大部分字段类型都不可修改。比如你想把price从 Double 改成 Keyword,或者把某个 Text 字段的分析器从 standard 改成 ik_max_word,ES 直接拒绝。这时候如果你的索引叫product,就非常尴尬:不能直接改,又不能删了重建(线上数据怎么办)。
正确姿势是索引别名配合版本号:
- 索引名叫
product_v1,别名叫product,应用代码里只通过product别名读写。 - 需要改 mapping 时,新建
product_v2,mapping 和 settings 都是新设计。 - 做一次 reindex(ES 内置的索引重建接口),把
product_v1里的数据搬到product_v2。 - 核对文档数量一致后,把别名
product从product_v1切换到product_v2。 - 确认无问题后,删除
product_v1。
reindex 接口在 Kibana 里大概长这样:
POST /_reindex { "source": { "index": "product_v1" }, "dest": { "index": "product_v2" } }应用代码里只要实体类注解的 indexName 改为product_v1或通过配置读取,别名保持不变,升级过程业务代码几乎零改动。这就是版本号命名的核心价值。
另外,如果你的场景是日志、事件这种按时间递增的数据,直接考虑 ILM(索引生命周期管理):按天或按月自动生成索引,然后根据时间自动 rollover、冷热分层、删除。比如日志保留 30 天,ILM 会在第 31 天自动把最老的索引删掉,不需要写任何定时任务代码。
6. 生产环境最常踩的四个坑:映射冲突、超时、通配符与内核参数
最后这一章,我把多年实战中踩过的坑和帮别人排查过的问题集中起来。每一个都是"看起来正常但生产环境必炸"的类型。
6.1 内核参数和 JVM 堆内存:ES 服务端的隐藏门槛
先看一个最容易被漏掉的启动问题。ES 服务端在 Linux 上启动时经常报这个错:
bootstrap checks failed max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]这是 Linux 内核参数配置不够,ES 索引时需要大量内存映射区域。解决办法:
sysctl -w vm.max_map_count=262144 echo "vm.max_map_count=262144" >> /etc/sysctl.conf除非权限不足,否则别想用ulimit -n之类的方法绕过去。生产集群这几个参数是硬性要求,不是建议。
还有一个和 JVM 相关的经验。ES 服务端的 JVM 堆内存设置有两个约束:不超过物理内存的 50%,且不要超过 32GB。超过 32GB 之后 JVM 的压缩指针失效,内存使用效率反而下降。同时,Lucene 的 off-heap 缓存也需要内存,所以你只给 ES 分 50% 以内是为了给 OS 页缓存留下空间。这个参数在jvm.options里配:
-Xms16g -Xmx16g启动参数里 Xms 和 Xmx 必须设为相同值,避免运行期动态扩容导致 GC 波动。这个原则同样适用于 SpringBoot 侧访问 ES 时设置给客户端 JVM 的堆内存,不过客户端通常不需要太大,重点在服务端。
6.2 客户端超时与重试的权衡
客户端配置里最值得细究的是超时和重试。
connection-timeout设置太短,ES 节点稍微忙一点,建立 TCP 连接就会超时;设置太长,连接池被占满后新请求无限等待,最终整个应用被拖死。我的生产配置固定是 5s。
socket-timeout则要看业务场景。普通查询给 30s 基本够,但如果你做深分页、大聚合或者跨大范围数据 reindex,60s 也未必够。我给 bulk 写入任务单独配置一个更长的 socket-timeout 是常见做法,不要让"查询超时"和"写入超时"互相拖累。
重试机制这里提醒一句:ES 官方 Java Client 默认对请求失败有一定重试能力,但写操作重试时要考虑幂等。如果你的 bulk 请求超时了,客户端自动重试,而上一个请求实际上已经在服务端执行成功了,那么同一条文档会被写两次。解决方式就是前面反复强调的:任何写入文档必须有稳定的业务 _id,同 ID 重复写入只是覆盖。
6.3 映射冲突的完整排查链路
这个报错应该是 ES 集成中最常见、也最让人头疼的:
ElasticsearchStatusException: ... mapper [brandName] cannot be changed from type [text] to [keyword]问题根源很简单:索引已经存在,mapping 里字段类型和实体类里 @Field 定义不一致,ES 拒绝修改。
我从一次凌晨两点被拉起来排查的经历里总结了一个标准链路,分享给你:
第一步,先看实体类。找到报错涉及的字段,看它 @Field 里定义的类型、分词器有没有最近改动过。如果代码一直是这个类型,再看下一步。
第二步,查实际 mapping:
GET /product_v1/_mapping在返回结果里找到对应字段,对比实际类型。经常出现的情况是:索引真正创建时的实体字段类型和现在的实体类不一样,比如项目早期brandName没有加 @Field,默认映射成 text;后来有人改了实体类加上 Keyword 注解,就冲突了。
第三步,判断冲突字段有没有存量数据依赖。如果索引里数据不多,直接删了重建最省事:
DELETE /product_v1然后启动应用重新创建索引。
第四步,如果数据量大不能删,走正式的升级流程:建product_v2-> reindex -> 切别名 -> 删旧索引。这个过程在 5.4 已经详细说过,操作起来并不复杂,但注意 reindex 期间如果正好有增量写入,要保证写入走的是别名,否则新数据进旧索引,切换后丢了数据。
第五步(重要),排查为什么会有人改了实体类但没同步 mapping。这种冲突根本原因是索引创建和代码变更脱节。团队的规范应该是:所有索引 mapping 变更都要走评审 + 脚本,而不是靠 Spring Data 的自动创建机制悄悄改掉。开发环境自动创建图方便,生产环境必须有人管。
6.4 wildcard 模糊查询为什么是性能核弹
这是我压箱底的一个案例。有一回上线一个搜索联想功能,开发图省事直接用 wildcard 做了模糊查询,前端一输入关键词就请求*关键词*匹配。当时测试环境两万条数据,响应 50ms,看起来没问题。上线之后数据涨到 2000 万,单节点 CPU 直接飙到 80% 以上,一个搜索请求耗时 2 秒多,几乎把整个集群拖垮。
wildcard 查询性能差的原因是本质性的。它不是走倒排索引的常规词项匹配,而是需要遍历大量文档的字段值去做通配符展开匹配。*手机*这种写法相当于把每个文档的对应字段值都拉出来做一遍正则判断,数据量一大,CPU 和 IO 必然爆炸。
替代方案有三条路,按场景选择:
最推荐:ngram 分词器。把智能手机切分成智能、智手、手机、能手、智能手机等组合索引到倒排索引里,搜索时用普通 match 就能命中,性能是倒排索引级别的。我的 mapper 里一般这样配:
{ "settings": { "analysis": { "analyzer": { "ngram_analyzer": { "tokenizer": "ngram_tokenizer" } }, "tokenizer": { "ngram_tokenizer": { "type": "ngram", "min_gram": 2, "max_gram": 10, "token_chars": ["letter", "digit"] } } } }, "mappings": { "properties": { "name": { "type": "text", "analyzer": "ngram_analyzer", "search_analyzer": "standard" } } } }搜索时用match配合search_analyzer: standard,索引侧用 ngram,查询侧用标准分词。注意 ngram 会显著增加索引体积,这是你必须接受的 trade-off,换来的是查询性能数量级的提升。
前缀模糊场景用 match_phrase_prefix。适用于搜索联想 "iph" -> "iphone" 这种,性能远好于 wildcard,而且结果质量也更好,因为它仍然基于倒排索引的 prefix 匹配。
精确匹配或固定前缀用 keyword + prefix 查询。如果搜索的是品牌名(Apple->Apple iPhone),完全可以用 keyword 字段 + prefix 查询,性能极好。前提是你知道用户只会从完整词的开头去匹配。
最后再多说一句:任何"模糊搜索"需求,都要先问清楚业务到底要什么。是真的是全文检索、前缀补全,还是就是懒图省事?大部分情况是后者,而用 wildcard 写在代码里一时爽,上线就是火葬场。
接入 ES 这件事,最怕的往往不是不会写代码,而是在不该引的时候引了,在版本上拖了一整年之后被迫重构。我现在的做法是,任何新项目接入 ES 之前,先在设计文档里把这几条拉一遍:数据量级、查询类型、版本策略、写入链路、索引升级方案。全部答得上来再动工。集成本身两小时能搞定,后面这些,才是真正决定项目长期健康的东西。