ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot集成Elasticsearch实战:从版本选型到MySQL同步踩坑全记录

SpringBoot集成Elasticsearch实战:从版本选型到MySQL同步踩坑全记录 接手订单中台项目时最让人头疼的一件事就是所有列表页的模糊查询全打到 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.x7.xRestHighLevelClient稳定资料多2.7.x7.xSpring Data Elasticsearch适合简单 CRUD3.0.x 及以上8.xElasticsearch Java API Client新项目推荐1.5.x 等老版本2.x / 5.xTransportClient 或 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_count262144 # 确认 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 服务端版本走。这里再细化一下常见的四个选择RestHighLevelClientES 7.x 时代的官方客户端API 面向对象风格功能覆盖完整社区资料多。缺点是官方已废弃新项目慎选。Elasticsearch Java API ClientES 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); IndexRequestOrder request IndexRequest.of(r - r .index(order_index) .id(order.getOrderId()) .document(order)); IndexResponse response esClient.index(request);更新UpdateRequestOrder 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(UpdateRequestObject, Object, ClassT)如果你只传一个 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 ActionListenerBulkResponse() { 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?vhindex,shard,prirep,state,unassigned.reason # 查看磁盘水位配置 GET _cluster/settings?include_defaultstruefilter_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 里集成本身并不复杂复杂的是数据一致性、查询模型、容量规划这些工程问题。你不需要一次把所有方案都堆上去但至少要先保证索引设计合理、同步链路有监控、分页方案符合用户场景剩下的大部分坑都可以在后续迭代中慢慢填平。
返回列表