接手订单中台项目时,最让人头疼的一件事就是所有列表页的模糊查询全打到 MySQL 上,LIKE '%关键词%'一多,接口直接超时。当时领导把 ES 排进迭代计划,我一方面觉得终于可以好好做搜索了,另一方面又知道 SpringBoot 集成 ES 是个大坑,版本兼容、客户端选型、索引设计、数据同步,随便哪个环节没弄好,后面线上全是雷。
这篇不是把官方文档复述一遍,而是结合我实际把 Elasticsearch 接入 SpringBoot 服务的全过程,把版本选型、部署检查、CRUD 写法、复合查询、MySQL 同步方案,还有线上排查经验一次讲清楚。如果你正准备在项目里引入 ES,或者已经接了一版但总觉得不稳,这篇应该能帮你省掉不少试错时间。
1. 为什么 SpringBoot 接 ES 总在版本上翻车
1.1 版本兼容是第一个隐藏关口
先说一个很多人都会踩的坑:spring-boot-starter-data-elasticsearch和RestHighLevelClient的版本兼容问题。
SpringBoot 2.x 时代,官方推荐用的是RestHighLevelClient,配合 Elasticsearch 7.x 使用;到了 SpringBoot 3.x,官方把RestHighLevelClient标记为废弃,转而推荐新的Elasticsearch Java API Client,也就是 ES 8.x 时代的客户端。很多人在网上复制一段旧代码,结果RestHighLevelClient类直接编译不过,或者构建连接时报java.lang.NoSuchMethodError,大概率就是版本混了。
我的建议是:先确认你们公司 ES 集群的版本,再倒推客户端的依赖。比如集群是 7.10.2,那 SpringBoot 用 2.3.x 到 2.7.x 都行,客户端用elasticsearch-rest-high-level-client:7.10.2,依赖对应版本,不要自己脑补兼容性。
| SpringBoot 版本 | ES 服务端版本 | 客户端方案 | 备注 |
|---|---|---|---|
| 2.3.x - 2.7.x | 7.x | RestHighLevelClient | 稳定,资料多 |
| 2.7.x | 7.x | Spring Data Elasticsearch | 适合简单 CRUD |
| 3.0.x 及以上 | 8.x | Elasticsearch Java API Client | 新项目推荐 |
| 1.5.x 等老版本 | 2.x / 5.x | TransportClient 或 Jest | 不建议再用了 |
如果你是新项目,ES 版本也还没定,那我建议直接上 8.x + ES Java API Client,别再用老客户端了。因为官方已经停止维护RestHighLevelClient,后续新特性不会同步,虽然目前还能用,但属于存量技术债。
1.2 Spring Data Elasticsearch 看着方便,但限制不少
有必要专门聊一下spring-boot-starter-data-elasticsearch,因为它确实让 CRUD 变简单了——定义实体类,写个 Repository 接口,跟 JPA 一样用方法名查。但用了一段时间你会发现,稍微复杂一点的搜索场景就很别扭:
- 动态查询需要大量拼接
NativeSearchQueryBuilder,代码可读性很差; - 聚合操作用封装的 API 写起来很绕;
- 版本升级时注解和内部 API 变动频繁,维护成本高。
所以在实际项目中,我倾向于用 ES Java API Client 或 RestHighLevelClient 直接操作,Spring Data 那个 starter 更像是快速演示工具,不是复杂业务的最佳选择。当然,如果你的查询确实很简单,团队也没精力写原生查询,用 starter 没问题,但心里要有数,别等业务复杂了再回头重构。
2. 接入前的必要准备:Linux 部署和客户端选型
2.1 部署环节容易被忽略的系统参数
上一篇踩坑记录里提到过,ES 不能用 root 用户启动,很多人第一次部署就卡在这。另外还有几个影响运行的系统参数,一定要提前检查:
# 打开文件数限制 ulimit -n 65535 # mmap 内存映射数量 sysctl -w vm.max_map_count=262144 # 确认 jvm 堆内存配置 # config/jvm.options 里 -Xms4g -Xmx4g,调试环境可以调小vm.max_map_count这个参数特别容易漏,如果没设置,ES 启动时可能报max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144],我当时第一次部署就被这个报错卡了半小时,以为是配置问题,搜了才发现是系统限制。
另外单机演示时,节点配置也要处理一下:
# elasticsearch.yml cluster.name: my-es node.name: node-1 network.host: 0.0.0.0 http.port: 9200 discovery.type: single-node生产环境不要用discovery.type: single-node,至少起三个节点组成集群,这里只是开发环境快速跑起来。
2.2 客户端选型背后的逻辑
前面说过,客户端要跟着 ES 服务端版本走。这里再细化一下常见的四个选择:
RestHighLevelClient:ES 7.x 时代的官方客户端,API 面向对象风格,功能覆盖完整,社区资料多。缺点是官方已废弃,新项目慎选。Elasticsearch Java API Client:ES 8.x 官方客户端,基于elasticsearch-java模块,API 重建过,用起来比 7.x 顺手,查询和聚合构建方式更符合现代 Java 风格,推荐新项目使用。Spring Data Elasticsearch:适合简单场景,但深入使用受限,前面说过了。- 自己封装 HTTP 调用:直接用
RestTemplate或HttpClient请求 ES 的 RESTful API。虽然原始,但完全不受客户端限制,在一些极端场景下反而灵活,比如临时排查问题。
选型时不要只看官网推荐,要结合团队现有技术栈、ES 集群版本、查询复杂度一起评估。我见过有人为了追新把 ES 集群从 7.x 升到 8.x,结果一堆历史代码要改,非常痛苦。
2.3 在 SpringBoot 里配置 ES 连接的两种方式
配置连接不外乎两种方式:直接写在application.yml,或者用配置类装配 Bean。我习惯用配置类,因为可以在创建连接的时候做更多控制,比如超时时间。
先看 yml 里的写法:
elasticsearch: uris: http://192.168.1.10:9200 username: elastic password: changeit socket-timeout: 30s connect-timeout: 10s然后是配置类,以新客户端为例:
@Configuration public class ElasticsearchConfig { @Bean public ElasticsearchClient elasticsearchClient( @Value("${elasticsearch.uris}") String uris, @Value("${elasticsearch.username}") String username, @Value("${elasticsearch.password}") String password) { RestClient restClient = RestClient.builder( HttpHost.create(uris)) .setDefaultHeaders(new Header[]{ new BasicHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes())) }) .setRequestConfigCallback(builder -> builder .setConnectTimeout(10000) .setSocketTimeout(30000)) .build(); return new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper())); } }注意新客户端里传输层需要封装一个JacksonJsonpMapper,这样返回的 JSON 可以直接映射到 Java 对象。如果用旧版 RestHighLevelClient,写法类似,但包路径完全不同。
3. 从创建索引到增删改查:ES 客户端 API 的正确打开方式
3.1 索引设计:Mapping 里的字段类型决定了后续能不能查对
接 ES 之前,我在 MySQL 里只需要建表,到了 ES 就要先设计索引和 Mapping。这里的核心认知是:ES 里一个索引约等于 MySQL 的一张表,但也有很大差异,特别是字段类型的设计,直接关系查询的准确性。
下面是一个典型的订单索引 Mapping:
{ "settings": { "number_of_shards": 3, "number_of_replicas": 1 }, "mappings": { "properties": { "orderId": { "type": "keyword" }, "customerName": { "type": "text", "analyzer": "ik_max_word" }, "status": { "type": "keyword" }, "createTime": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }, "totalAmount": { "type": "double" }, "goodsList": { "type": "nested", "properties": { "goodsId": { "type": "keyword" }, "goodsName": { "type": "text", "analyzer": "ik_max_word" }, "quantity": { "type": "integer" } } } } } }几个容易踩坑的点:
keyword和text要分清。keyword 做精确匹配、排序和聚合;text 做全文检索。同一字段经常既要精确匹配又要全文搜索,这时候可以用fields多字段设计。- 中文分词必须用 IK 分词插件,默认的 standard 分词器对中文几乎不可用,搜"牛肉面"会把每个字拆开。
- 数组对象一定要用
nested类型,否则你会遇到object类型无法正确匹配嵌套对象的坑。
3.2 增删改查的 Java 写法
用新客户端,核心 API 逻辑比较清晰。插入一条数据:
Order order = new Order(); order.setOrderId("202501010001"); order.setCustomerName("张三"); order.setStatus("PAID"); IndexRequest<Order> request = IndexRequest.of(r -> r .index("order_index") .id(order.getOrderId()) .document(order)); IndexResponse response = esClient.index(request);更新:
UpdateRequest<Order> updateRequest = UpdateRequest.of(r -> r .index("order_index") .id(order.getOrderId()) .doc(Map.of("status", "SHIPPED"))); esClient.update(updateRequest, Order.class);删除:
DeleteRequest deleteRequest = DeleteRequest.of(r -> r .index("order_index") .id("202501010001")); esClient.delete(deleteRequest);一个容易踩的坑是:新客户端index和update方法签名不太一样,update方法在ElasticsearchClient接口中定义是update(UpdateRequest<Object, Object>, Class<T>),如果你只传一个 Request 不带 Class,编译就过不去。这是我第一次迁移到新客户端时最头疼的地方,IDE 里看着方法签名以为自己记错了,实际上是对泛型的处理更严格了。
3.3 异步写入:批量请求的正确姿势
前面热搜词里有个"ES 异步写入 java",显然大家关注的都是同一个场景:业务数据量大,一条条写 ES 性能太差。ES 客户端提供了异步 API,也可以自己用线程池加批量提交。
官方异步写法:
// 异步批量写入 BulkRequest bulkRequest = BulkRequest.of(r -> r .operations(List.of( BulkOperation.of(o -> o.index(d -> d.index("order_index").document(order1))), BulkOperation.of(o -> o.index(d -> d.index("order_index").document(order2))) )) ); esClient.bulkAsync(bulkRequest, new ActionListener<BulkResponse>() { @Override public void onResponse(BulkResponse response) { if (response.errors()) { // 批量中部分失败,遍历 items 定位失败项 response.items().forEach(item -> { if (item.error() != null) { log.error("写入失败: {}", item.error().reason()); } }); } } @Override public void onFailure(Exception e) { log.error("批量请求异常", e); } });批量大小不要贪多,我测下来每批次 500 到 1000 条比较合适,具体看单条文档大小和机器配置。超过 1000 条反而容易触发 ES 的queue capacity报错,EsRejectedExecutionException就是这么来的。
4. 查询不是拼 SQL:复合搜索与聚合的实战写法
4.1 bool 查询:must、should、filter 怎么组合
ES 的查询模型和 SQL 差异很大,刚开始写的时候最容易不理解的就是 bool 查询。简单类比:
must相当于 SQL 里的ANDshould相当于ORfilter也是AND,但不参与相关性打分,性能更好must_not相当于NOT
实际项目里,一个典型的订单搜索可能是这样:状态等于已支付、客户名模糊匹配"张"、金额大于 100、时间范围在最近 7 天。
SearchRequest searchRequest = SearchRequest.of(r -> r .index("order_index") .query(Query.of(q -> q.bool(b -> b .filter(f -> f.term(t -> t.field("status").value("PAID"))) .filter(f -> f.range(rg -> rg.field("totalAmount").gt(JsonData.of(100)))) .filter(f -> f.range(rg -> rg.field("createTime").gte(JsonData.of("2025-01-01 00:00:00")))) .must(m -> m.match(mt -> mt.field("customerName").query("张"))) ))) );留意我这里的用法:能用filter就不放must,因为 filter 不计算打分,性能开销更小。对于状态、金额、时间这类不需要相关性的条件,都应该放 filter。
4.2 高亮和分页的配合
搜索接口经常要返回高亮片段,ES 的高亮逻辑和 MySQL 的LIKE不同,它是在查询阶段对匹配部分加标签,所以你得在查询里专门声明高亮字段和标签:
SearchRequest request = SearchRequest.of(r -> r .index("order_index") .query(Query.of(q -> q.match(m -> m.field("customerName").query("张")))) .highlight(h -> h.fields("customerName", f -> f.preTags("<em>").postTags("</em>"))) );分页默认从 0 开始,from + size最大不能超过 10000,这是index.max_result_window控制的。如果业务确实要翻到很后面,不要试图调大这个参数,那是自欺欺人。正确方案是后面讲到的search_after。
4.3 聚合查询:分组统计的实战
聚合是 ES 的一大优势,比如统计不同状态的订单数、月度销售趋势:
SearchRequest aggRequest = SearchRequest.of(r -> r .index("order_index") .size(0) .aggregations("status_count", agg -> agg.terms(t -> t.field("status.keyword"))) .aggregations("monthly_amount", agg -> agg.dateHistogram(dh -> dh .field("createTime") .calendarInterval(CalendarInterval.Month) .format("yyyy-MM")) );size(0)表示只聚合不返回具体文档,可以提高性能。terms 聚合如果字段是text类型会报错,所以要么字段本身是 keyword,要么用.keyword子字段。
另外,values 聚合建议限制返回数量,比如.size(20),否则默认按词频取 10 个桶,有时不是你想看的。
5. MySQL 与 ES 的同步方案:Canal + Kafka 异步写入的思路
5.1 为什么不建议业务代码里双写
很多项目一开始都是业务代码里操作完 MySQL 再手动去写 ES,也就是双写。这个方案在数据量小的 demo 里看着挺顺,上了生产就麻烦了:
- 业务逻辑里多一次网络 IO,请求链路变长;
- 万一写 ES 失败,不会跟着回滚 MySQL 事务,两边数据不一致;
- 如果 MySQL 表结构有历史数据,双写只能解决增量,历史数据还得写脚本刷。
所以我的建议是,别在业务代码里做双写,尤其订单这种核心业务,数据一致性容不得瑕疵。
5.2 Canal 监听 binlog 的同步链路
Canal 是阿里的开源中间件,原理是伪装成 MySQL 的从库去订阅 binlog,然后把数据变更事件推送出来。配合 Kafka 更好用,因为 Canal 默认把数据发到 Kafka Topic,消费端用 SpringBoot 服务消费,解耦又可靠。
链路大致是:
MySQL -> binlog -> Canal -> Kafka Topic -> SpringBoot 消费者 -> ES消费者核心逻辑很简单,监听 TOPIC,拿到的数据解析成 JSON,然后写进 ES。有个关键点:这个消费者对接的是多个 MySQL 表还是单张表?如果单表,topic 可以按表建;如果是库级别的订阅,消费端要根据表名分发到不同的索引。我见过一个项目用 Canal 订阅了一个库所有表,然后消费者里写了一大串 if-else 判断表名,基本没法维护,后面改成了按表建 Topic。
5.3 写 ES 的失败场景怎么补偿
用 MQ 不等于数据就一定安全,消费端写 ES 失败了怎么办?我常用的方案是配合 Kafka 的重试机制,同时做一层兜底:
- 消费者处理失败时,不手动 ack,让
spring-kafka的重试策略生效; - 重试达到最大次数后,进入死信 Topic,由独立脚本定期扫描重放;
- 同时,每天凌晨跑一个批处理任务,从 MySQL 增量扫描最近 24 小时变更数据,做一次对账补偿。
这个方案不是最优解,但胜在简单可靠,适合大多数中小型团队。如果你数据变更特别频繁,也可以考虑直接上 Flink CDC 同步到 ES,不过那是另一个复杂话题了。
6. 线上踩坑记录:分页、连接池、集群状态与常见故障排查
6.1 深度分页与 search_after
前面提过from + size最多 10000 条。如果业务需要深翻页,比如后台管理系统按条件搜订单,翻到第 50 页,怎么办?
- 方案一:用
scrollAPI,适合导出一批数据,但它的快照语义不太适合用户交互场景; - 方案二:用
search_after,它把上一页最后一条记录的排序值作为下一页的起点,很稳; - 方案三:如果你的业务场景是"看第几页",而不是"无限往下滚",那确实可以限制最多 10000 条,超过就提示用户缩小筛选条件。
我的实际经验和很多大佬的观点一致:搜索引擎场景本身就不适合"绝对页码"这种用户体验,search_after是更合理的翻页方式。搜索引擎场景本身就不适合“绝对页码”这种用户体验,用户想看的是"下一页",不是"第 50 页"。
6.2 连接池耗尽的排查链路
有一次排查慢接口报警,发现大量线程卡在RestClient的连接获取上。当时第一反应是 ES 慢,但检查 ES 监控发现负载正常,后来才定位到是客户端连接池配置问题。
RestClient 的默认连接池是基于 Apache HttpClient 的,setMaxConnTotal和setMaxConnPerRoute不配置的话,默认值很小,高并发下很容易把连接占满。解决办法是显式调大连接池:
RestClient.builder(HttpHost.create(uris)) .setHttpClientConfigCallback(builder -> builder .setMaxConnTotal(200) .setMaxConnPerRoute(100) .setKeepAliveStrategy((response, context) -> 60000))另外要注意,调用完 ES 接口后,用新客户端的异步请求时一定要处理回调,救是有很多问题是由于忘了对响应做 close 或者对失败回调没有正确处理,导致线程长期占用连接。
6.3 集群状态 yellow 和红色的含义
ES 集群状态分为 green、yellow、red 三种,很多人一看到 yellow 就慌了,其实 yellow 只表示主分片都正常,只是存在副本分片没有分配。最常见的原因就是磁盘水位达到 85%,ES 出于保护机制优先保证主分片,不发副本。
排查只要三步:
# 查看各节点磁盘占用 GET _cat/allocation?v # 查看未分配分片原因 GET _cat/shards?v&h=index,shard,prirep,state,unassigned.reason # 查看磁盘水位配置 GET _cluster/settings?include_defaults=true&filter_path=*.watermark*如果是磁盘问题,清理空间或加节点就能恢复。如果是其他原因,比如索引配置了total_shards_per_node导致限制,那就调整配置。
红色代表有主分片丢失,这种情况下受影响索引的搜索和写入是会报错的,需要重点排查节点宕机后分片恢复情况。
6.4 fielddata 内存爆掉的坑
还有一个值得单独提醒的点,关于text字段排序和聚合。ES 的text字段默认不能用于排序、聚合和脚本,因为分词后的多值没有明确的单值含义。很多人不知道,直接把字段类型设置为text,然后分组统计时报错,然后又去开fielddata: true,结果内存直接爆掉。
正确做法:触发上面同样的报错时,不要开fielddata,而是使用keyword子字段。这个子字段在 mapping 里通过fields声明,排序聚合全部用xxx.keyword,既满足了需求又不会产生内存问题。
6.5 别在生产环境随意改 Mapping
还有一个很常见的坑,ES 的 Mapping 定义好了之后,字段类型不能随意更改。有人为了加一个字段直接删了索引重建,结果线上数据全没了。更安全的方式是使用索引别名,升级 mapping 时新建索引,同步数据,然后切换别名。这个操作看起来多了一步,但生产环境值得这么做。
7. 一些值得坚持的习惯
说了这么多踩坑,最后再分享几个我在维护 ES + SpringBoot 项目时养成的小习惯。
一个是给 ES 的每个索引都做好生命周期管理。日志类索引保留 30 天,业务索引做好索引模板,字段变更通过后台扫表去比对,别靠人肉记。另一个是监控上要覆盖 ES 的集群状态、查询耗时、拒绝率三块,在使用 Prometheus 的团队里装一个 ES exporter 就可以,不用自己造轮子。
另外就是代码层面,ES 查询语句建议都封装成独立参数对象,不要散落在业务代码里。有一次我们排查一个搜索延迟问题,发现有人在循环里调了三次 ES 查询,当时想着"就查一次",结果每次查询都要组装请求,拉长了接口 rt,后来把查询集中提出来之后,问题立刻缓解。
ES 在 SpringBoot 里集成本身并不复杂,复杂的是数据一致性、查询模型、容量规划这些工程问题。你不需要一次把所有方案都堆上去,但至少要先保证索引设计合理、同步链路有监控、分页方案符合用户场景,剩下的大部分坑都可以在后续迭代中慢慢填平。