简介:本资源是一个基于Spring Boot整合Elasticsearch 8.3并借助RabbitMQ实现MySQL数据实时同步的完整可运行Demo,面向Java后端开发者、搜索架构学习者及微服务实践者,解决关系型数据库与搜索引擎间异步、可靠、低延迟的数据同步难题,适用于电商商品检索、日志分析、内容平台搜索等典型场景。压缩包共78个文件,含21个核心Java源码(涵盖ES Repository、RabbitMQ监听器、JPA实体与监听逻辑)、21个编译class、17个XML配置与依赖描述、2个YML配置文件(application.yml等),以及证书、Git忽略规则、启动脚本等辅助文件,整体仅90KB,轻量易导入。目前已有210人学习下载。读者可直接运行项目,获得从MySQL变更捕获、消息队列中转、到Elasticsearch索引更新的全链路代码实现,包含批量写入、事务事件监听、连接配置与基础错误处理等关键细节,目录结构清晰,模块职责分明,便于理解实时同步架构设计与落地要点。
1. 为什么这个 Demo 不是“又一个 Spring Boot 整合教程”,而是你毕设/课设里最值得抄的那套数据同步骨架?
很多同学做毕设或课设时,一写到“搜索功能”就卡在 Elasticsearch 入门:装完 ES 不知道怎么连,连上了不会写 DSL,写对了又发现 MySQL 数据改了 ES 没更新——最后硬编码curl -X POST手动刷数据,答辩时被问“线上怎么保证一致性”,当场哑火。这套 Spring Boot + Elasticsearch 8.3 + RabbitMQ 同步 MySQL 的 Demo,就是专治这种“搜得到但跟不上”的典型翻车场景。它不讲 ES 基础概念,不堆 REST API 列表,而是直接给你一套可运行、可调试、可扩展的双写兜底+异步解耦+失败重试闭环:MySQL 插入/更新/删除 → 自动发 MQ 消息 → ES 精准增删改 → 失败消息进死信队列 → 人工干预或自动补偿。适合需要交完整方案的学生(含文档、SQL、配置、测试用例),也适合想快速搭搜索中台的初级工程师。核心不是“能跑”,而是“出问题时你知道在哪加日志、在哪改重试策略、在哪查消息堆积”。
2. 技术选型与架构设计:为什么是 RabbitMQ 而不是 Kafka?为什么 ES 8.3 必须用 Java API Client?
2.1 三层解耦架构:从“MySQL 直连 ES”到“事件驱动同步”的关键跃迁
传统做法是监听 MySQL binlog(用 Canal 或 Debezium)或在 Service 层手动调 ES Client 更新索引。前者引入额外中间件,部署复杂;后者违反单一职责,业务代码混杂基础设施逻辑。本方案采用“应用内事件发布 + 消息中间件解耦 + 独立同步服务”三层结构:
- 数据源层(MySQL):仅负责业务数据持久化,不感知搜索;
- 事件发布层(Spring Boot Application):通过
@TransactionalEventListener在事务提交后发布DataChangeEvent事件,确保“数据库写成功,事件才发出”; - 同步消费层(RabbitMQ Consumer):独立线程池消费消息,失败自动入死信队列(DLX),支持人工重放或定时补偿。
提示:这里不用 Kafka 是因课设/毕设环境通常无 ZooKeeper 集群,RabbitMQ 单节点 Docker 启动 30 秒搞定,且 DLX + TTL 死信机制对“单条消息失败需重试”更直观可控。
2.2 Elasticsearch 8.3 客户端必须用elasticsearch-java:告别 TransportClient 的玄学报错
ES 7.x 起已废弃 TransportClient,8.x 彻底移除。若仍用spring-boot-starter-data-elasticsearch(底层基于 RestHighLevelClient),会遇到:
- SSL 配置繁琐(ES 8.x 默认启用 HTTPS);
- JSON 序列化冲突(Jackson 与 ES 内置 Jackson 版本打架);
IndexRequest构造反直觉(需手动setSource(jsonString, XContentType.JSON))。
本方案强制使用官方推荐的elasticsearch-java(v8.3.3),配合spring-boot-starter-webflux的WebClient:
<!-- pom.xml --> <dependency> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> <version>8.3.3</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>其优势在于:
- 类型安全:
IndexRequest.of(r -> r.index("user").id("1").document(new User())),IDE 自动补全字段; - 异步非阻塞:底层基于 Netty,高并发下线程数可控;
- 错误分类清晰:
ElasticsearchException包含具体 HTTP 状态码与错误原因(如index_not_found_exception可直接捕获建索引)。
2.3 RabbitMQ 交换机与队列设计:精准路由 + 可追溯性保障
为避免不同实体(User/Order/Product)消息互相干扰,采用Topic Exchange + 动态 Routing Key:
| 实体类型 | Routing Key 模式 | 对应队列 | 用途 |
|---|---|---|---|
| User | data.user.* | es-sync-user-queue | 用户数据同步 |
| Order | data.order.* | es-sync-order-queue | 订单数据同步 |
| Product | data.product.* | es-sync-product-queue | 商品数据同步 |
声明方式(RabbitMQ Config):
@Bean public TopicExchange esSyncExchange() { return new TopicExchange("es.sync.exchange", true, false); } @Bean public Queue userSyncQueue() { return QueueBuilder.durable("es-sync-user-queue") .withArgument("x-dead-letter-exchange", "dlx.exchange") .withArgument("x-message-ttl", 300000) // 5分钟未消费入死信 .build(); } @Bean public Binding userSyncBinding() { return BindingBuilder.bind(userSyncQueue()).to(esSyncExchange()).with("data.user.#"); }注意:
x-dead-letter-exchange必须提前声明,否则消息堆积后无法进入死信队列——这是学生最容易忽略的配置点。
3. 核心模块实现:从 MySQL 监听、消息发送到 ES 索引更新的完整链路
3.1 MySQL 数据变更事件发布:基于@TransactionalEventListener的可靠触发
关键不是“监听”,而是“事务一致性”。不能用@PostUpdate(事务未提交),也不能用@Scheduled(延迟不可控)。正确姿势是 Spring 事件 + 事务监听器:
// 事件载体(必须 Serializable) @Data @AllArgsConstructor public class DataChangeEvent implements Serializable { private String entityType; // "user", "order" private String operation; // "INSERT", "UPDATE", "DELETE" private String id; // 主键值 private String jsonPayload; // JSON 字符串,含完整对象 } // 在 Service 方法中发布事件(事务内) @Transactional public void createUser(User user) { userMapper.insert(user); // 事务提交后触发事件 applicationEventPublisher.publishEvent( new DataChangeEvent("user", "INSERT", user.getId(), JSON.toJSONString(user)) ); }监听器配置(确保在事务提交后执行):
@Component @Slf4j public class DataChangeEventListener { @Autowired private RabbitTemplate rabbitTemplate; @EventListener @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleDataChangeEvent(DataChangeEvent event) { try { // 发送至 Topic Exchange,Routing Key 格式:data.{entityType}.{operation} String routingKey = String.format("data.%s.%s", event.getEntityType(), event.getOperation()); rabbitTemplate.convertAndSend("es.sync.exchange", routingKey, event); log.info("Sent event to MQ: {} -> {}", routingKey, event.getId()); } catch (Exception e) { log.error("Failed to send event to MQ", e); // 此处不抛异常!避免回滚已提交事务 } } }3.2 RabbitMQ 消费者:幂等处理 + ES 批量操作 + 失败隔离
消费者不直接操作 ES,而是委托EsSyncService,该服务封装了重试、降级、监控逻辑:
@Component @RabbitListener(queues = "es-sync-user-queue") @Slf4j public class UserSyncConsumer { @Autowired private EsSyncService esSyncService; @RabbitHandler public void onMessage(@Payload DataChangeEvent event, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) { try { // 1. 幂等校验:检查 ES 中 version 是否匹配(需在 MySQL 表加 version 字段) boolean isDuplicate = esSyncService.isDuplicate(event); if (isDuplicate) { channel.basicAck(deliveryTag, false); log.warn("Duplicate event ignored: {}", event.getId()); return; } // 2. 执行同步(含重试) esSyncService.syncToEs(event); // 3. 成功确认 channel.basicAck(deliveryTag, false); log.info("ES sync success for {}: {}", event.getEntityType(), event.getId()); } catch (Exception e) { log.error("ES sync failed for {}: {}", event.getId(), e.getMessage()); try { // 4. 拒绝并重新入队(最多重试3次) if (isRetryExhausted(channel, deliveryTag)) { // 5. 入死信队列 channel.basicNack(deliveryTag, false, false); } else { channel.basicNack(deliveryTag, false, true); // 重回队列 } } catch (IOException ioException) { log.error("Failed to nack message", ioException); } } } private boolean isRetryExhausted(Channel channel, long deliveryTag) throws IOException { // 读取 x-death header 判断重试次数 GetResponse response = channel.basicGet("es-sync-user-queue", false); if (response != null && response.getProps().getHeaders() != null) { List<Map<String, Object>> deaths = (List<Map<String, Object>>) response.getProps().getHeaders().get("x-death"); if (deaths != null && deaths.size() > 0) { Long count = (Long) deaths.get(0).get("count"); return count != null && count >= 3; } } return false; } }3.3 ES 同步服务:Java API Client 的实战写法与错误兜底
EsSyncService是核心,它处理:
- 索引自动创建(检测
index_not_found_exception); INSERT/UPDATE/DELETE分支逻辑;- 批量操作(Bulk API)提升吞吐;
- 失败项单独记录(不因单条失败中断整批)。
@Service @Slf4j public class EsSyncService { @Autowired private ElasticsearchClient esClient; public void syncToEs(DataChangeEvent event) throws IOException { switch (event.getOperation()) { case "INSERT": case "UPDATE": upsertDocument(event); break; case "DELETE": deleteDocument(event); break; } } private void upsertDocument(DataChangeEvent event) throws IOException { // 解析 JSON 到 Map,避免强依赖实体类(便于后期扩展) Map<String, Object> source = JSON.parseObject(event.getJsonPayload(), Map.class); IndexRequest.Builder<Map<String, Object>> request = new IndexRequest.Builder<>(); request.index(getIndexName(event.getEntityType())) .id(event.getId()) .document(source); // 执行 IndexResponse response = esClient.index(request.build()); if (!response.result().name().equals("created") && !response.result().name().equals("updated")) { throw new RuntimeException("ES index failed: " + response.result()); } } private void deleteDocument(DataChangeEvent event) throws IOException { DeleteRequest request = DeleteRequest.of(d -> d .index(getIndexName(event.getEntityType())) .id(event.getId())); DeleteResponse response = esClient.delete(request); if (response.result() == Result.NotFound) { log.warn("ES document not found for delete: {}", event.getId()); } } private String getIndexName(String entityType) { return entityType + "-index-v1"; // 支持按版本滚动索引 } // 自动建索引(当首次写入时触发) public void ensureIndexExists(String indexName) throws IOException { try { esClient.indices().get(GetIndexRequest.of(g -> g.index(indexName))); } catch (ElasticsearchException e) { if (e.status() == 404) { createIndexWithMapping(indexName); } else { throw e; } } } private void createIndexWithMapping(String indexName) throws IOException { CreateIndexRequest request = CreateIndexRequest.of(c -> c .index(indexName) .mappings(m -> m .properties("id", p -> p.keyword(k -> k)) .properties("username", p -> p.text(t -> t.analyzer("ik_max_word"))) .properties("createdAt", p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) ) ); esClient.indices().create(request); } }注意:
ensureIndexExists被放在upsertDocument开头调用,而非启动时——因为课设环境可能先跑消费者再启 MySQL,避免启动失败。
4. 避坑指南:这 4 个点踩中任意一个,你的同步就变成“薛定谔的更新”
4.1 现象:ES 中数据偶尔丢失,日志显示bulk item failed,但找不到具体哪条失败
原因:批量操作(Bulk API)中某条文档格式错误(如日期字段传了字符串"2023"而非"2023-01-01"),整个 bulk 请求返回 200,但BulkResponse中item的error字段非空,而代码未遍历检查。
解决:必须显式遍历BulkResponse.items(),对每个BulkResponseItem判断error()是否为空:
BulkResponse bulkResponse = esClient.bulk(bulkRequest); for (BulkResponseItem item : bulkResponse.items()) { if (item.error() != null) { log.error("Bulk item failed: {}, error: {}", item.id(), item.error().reason()); // 记录到失败表或发告警 } }4.2 现象:RabbitMQ 消费者启动后不消费,管理界面显示Unacked为 0,队列有堆积
原因:@RabbitListener方法参数缺少Channel和@Header(AmqpHeaders.DELIVERY_TAG),导致 Spring AMQP 无法获取 deliveryTag,自动开启acknowledgeMode=NONE(即自动确认),消息一投递就丢,失败也不重试。
解决:所有@RabbitHandler方法必须包含Channel参数,并在成功/失败后显式调用basicAck()或basicNack()。检查application.yml中是否误配:
spring: rabbitmq: listener: simple: acknowledge-mode: manual # 必须为 manual!4.3 现象:MySQL 更新后,ES 中旧数据仍存在,新字段值为空
原因:ES 的index操作默认是“覆盖”,但若jsonPayload中未包含某些字段(如前端只传了username,没传email),ES 会将email设为null,而非保留原值。这不是 bug,是设计。
解决:改用updateAPI(需_source启用)或upsert,但更稳妥的是在业务层保证jsonPayload为全量字段。本方案采用“查询 MySQL 全量再同步”策略(仅限关键实体):
// 在 syncToEs 中,对 UPDATE 操作: if ("UPDATE".equals(event.getOperation())) { // 重新查库,确保 payload 是全量 User fullUser = userMapper.selectById(event.getId()); event.setJsonPayload(JSON.toJSONString(fullUser)); }4.4 现象:ES 8.3 启动报错Caused by: javax.net.ssl.SSLHandshakeException: No name matching localhost found
原因:ES 8.3 默认启用 HTTPS,但本地开发用https://localhost:9200时,证书 CN 是es01而非localhost,JVM SSL 校验失败。
解决:两种方式二选一(课设推荐方案 1):
- 禁用 SSL 验证(开发环境 OK):
// 创建 ElasticsearchClient 时 HttpAsyncTransportOptions options = HttpAsyncTransportOptions.builder() .setSslContext(SSLContextBuilder.create() .loadTrustMaterial((chain, authType) -> true) // 跳过证书验证 .build()) .build(); - 生成 localhost 证书并配置 ES(生产必须,课设太重,略)。
5. 方案验收与答辩准备:用三张表、两个命令、一次压测,让导师信服这不是“玩具项目”
5.1 验收清单:交付物必须包含这 5 类文件,缺一不可
| 文件类型 | 示例文件名 | 关键内容要求 | 为什么必须有 |
|---|---|---|---|
| 数据库脚本 | mysql-init.sql | 包含建表语句(含version字段)、初始化测试数据(≥5 条) | 导师可一键导入,验证同步起点 |
| ES 索引模板 | es-mapping.json | 完整PUT /user-index-v1的 mapping 定义,含 ik 分词器配置 | 证明你理解分词与字段类型映射 |
| RabbitMQ 配置快照 | rabbitmq-config.md | 截图或文字描述:Exchange 名、Queue 名、Binding Key、DLX 设置 | 证明你真正配置了消息路由,非硬编码 |
| 核心日志片段 | sync-log-sample.txt | 截取 3 段日志: 1. MySQL 插入后发布事件 2. MQ 消费者收到并 ACK 3. ES 返回 result=created | 用时间戳证明链路真实跑通 |
| Postman 测试集合 | es-search-test.json | 导出的 Postman Collection,含 3 个请求: 1. GET /user-index-v1/_search?q=username:张2. POST /user-index-v1/_refresh3. GET /_cat/indices?v | 导师可直接导入 Postman 运行,看到搜索结果 |
提示:答辩前务必用
docker ps截图展示 RabbitMQ 和 ES 容器正在运行,比任何 PPT 都有说服力。
5.2 答辩现场演示话术:3 分钟讲清“为什么我的方案比百度到的都靠谱”
不要说“我用了 Spring Boot”,要说清楚决策依据和故障应对:
“老师好,我这个同步方案的核心是‘可控的最终一致性’。比如 MySQL 插入用户,我用
@TransactionalEventListener确保事件只在事务提交后发出——这解决了‘写库成功但消息没发’的漏同步。如果 ES 临时挂了,消息会堆积在 RabbitMQ,我们设置了 5 分钟 TTL,超时自动进死信队列,这时候我可以:第一,用管理界面看死信内容定位是哪个字段格式错了;第二,把消息复制出来,用 Postman 手动调 ES 接口修复;第三,改完代码后,把死信队列里的消息重新发回原队列。这三步,我在README.md的‘故障恢复流程’章节都写了截图和命令。”
5.3 压测验证:用 100 条数据证明吞吐与一致性
学生常被问“性能怎么样”,别答“很快”,要给数字。用 JMeter 或简单 Shell 脚本模拟:
# 模拟 100 次用户创建(每秒 10 个) for i in {1..100}; do curl -X POST http://localhost:8080/api/user \ -H "Content-Type: application/json" \ -d "{\"username\":\"testuser$i\",\"email\":\"test$i@example.com\"}" & sleep 0.1 done wait echo "100 users created" # 等待同步完成(ES refresh) sleep 2 # 查 ES 总数 COUNT=$(curl -s "http://localhost:9200/user-index-v1/_count" | jq '.count') echo "ES doc count: $COUNT" # 应输出 100验收标准:
- ✅ 100 条全部写入 MySQL(
SELECT COUNT(*) FROM user); - ✅ ES 中
count为 100,且username字段可被match_phrase搜索到; - ✅ 手动停掉 RabbitMQ 容器,再发 10 条,重启后这 10 条自动同步成功;
- ✅ 故意让一条消息的
jsonPayload缺少username字段,观察其进入死信队列,其余 99 条正常同步。
5.4 答辩高频问题预判与应答要点
| 问题 | 应答要点(切忌背诵,用口语化技术语言) | 关联设计点 |
|---|---|---|
| “为什么不用 Canal 监听 binlog?” | “Canal 需要 MySQL 开启 binlog、配置 server-id,课设环境权限受限;而且 Canal 是另一套运维体系,增加复杂度。我们方案在应用层发事件,控制权完全在自己手里,日志、重试、降级都更透明。” | 2.1 节架构设计 |
| “ES 搜索慢怎么办?” | “我做了三点:第一,mapping 里 username 用了ik_max_word分词器,支持中文模糊搜;第二,查询时用bool.must+match_phrase组合,避免全文扫;第三,在application.yml里配了spring.elasticsearch.uris=https://localhost:9200,走 HTTPS 复用连接池。” | 3.3 节索引创建、5.1 节 ES 配置 |
| “消息重复消费怎么办?” | “我在 ES 同步前加了幂等校验:每次同步前,先用GET /user-index-v1/{id}拿当前 ES 文档的_version,和 MySQL 的version字段比。如果 MySQL version 更大才更新,否则跳过。这样即使 MQ 重发,也不会覆盖新数据。” | 3.2 节幂等校验、4.3 节字段丢失问题 |
从那以后我每次做数据同步类课设,都强制走一遍“MySQL 插入 → 查 MQ 队列长度 → 查 ES count → Postman 搜关键词”四步验证,哪怕多花 10 分钟,也比答辩时被问住强。希望帮到你。
本文还有配套的精品资源,点击获取