MySQL 到 Elasticsearch 数据同步策略完全指南
一、为什么需要数据同步
Elasticsearch 擅长搜索和分析,但不适合做主数据库(不支持事务、不擅长频繁更新)。典型架构是:
MySQL(主库,保证数据一致性)→ 同步 → Elasticsearch(搜索引擎,提供检索能力)
核心挑战:如何让 ES 中的数据与 MySQL 保持一致。
二、四种方案总览
| 方案 | 实时性 | 一致性 | 侵入性 | 复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 双写(代码同步) | 高(毫秒级) | 弱(可能不一致) | 高 | 低 | 简单业务、早期项目 |
| MQ 异步同步 | 秒级 | 最终一致 | 中 | 中 | 推荐方案,适合大多数项目 |
| Binlog 监听 | 秒级 | 最终一致 | 低 | 高 | 大规模数据、不想改业务代码 |
| 定时全量/增量刷新 | 分钟级 | 弱 | 低 | 低 | 对实时性要求不高、报表场景 |
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
三、方案一:双写(代码同步)
原理
在业务代码中,写 MySQL 的同时也写 ES。
用户请求 → Service → 写 MySQL → 写 ES → 返回结果
实现示例
java
/**
* 商品服务 - 双写同步.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
private final ProductMapper productMapper;
private final ProductSearchRepository productSearchRepository;
@Override
@Transactional(rollbackFor = Exception.class)
public void createProduct(ProductCreateDto dto) {
// 1. 写入 MySQL
ProductEntity entity = convertToEntity(dto);
productMapper.insert(entity);
// 2. 同步写入 ES
try {
ProductDocument document = convertToDocument(entity);
productSearchRepository.save(document);
} catch (Exception e) {
// ES 写入失败不影响主流程,但需要记录并后续补偿
log.error("同步商品到ES失败, productId: {}", entity.getId(), e);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateProduct(String id, ProductUpdateDto dto) {
// 1. 更新 MySQL
ProductEntity entity = productMapper.selectById(id);
updateEntityFromDto(entity, dto);
productMapper.updateById(entity);
// 2. 同步更新 ES
try {
ProductDocument document = convertToDocument(entity);
productSearchRepository.save(document);
} catch (Exception e) {
log.error("更新ES商品失败, productId: {}", id, e);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteProduct(String id) {
// 1. 删除 MySQL
productMapper.deleteById(id);
// 2. 删除 ES
try {
productSearchRepository.deleteById(id);
} catch (Exception e) {
log.error("删除ES商品失败, productId: {}", id, e);
}
}
}
优点
- 实现简单,开发成本低
- 实时性最高(同步调用)
- 无需额外中间件
缺点
- 一致性问题:MySQL 成功但 ES 失败时,数据不一致
- 性能影响:每次写操作增加 ES 调用耗时
- 代码侵入性高:每个写操作都要加 ES 逻辑
- 事务问题:MySQL 回滚了但 ES 已经写入,无法回滚
一致性补偿方案
java
/**
* 同步失败补偿任务.
* 定期扫描同步失败记录,重试同步.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EsSyncCompensationJob {
private final SyncFailureRecordMapper syncFailureRecordMapper;
private final ProductMapper productMapper;
private final ProductSearchRepository productSearchRepository;
@Scheduled(fixedDelay = 60000) // 每分钟执行
public void compensate() {
List<SyncFailureRecord> failures = syncFailureRecordMapper.selectPending(100);
for (SyncFailureRecord record : failures) {
try {
ProductEntity entity = productMapper.selectById(record.getEntityId());
if (entity != null) {
productSearchRepository.save(convertToDocument(entity));
} else {
productSearchRepository.deleteById(record.getEntityId());
}
syncFailureRecordMapper.markSuccess(record.getId());
} catch (Exception e) {
syncFailureRecordMapper.incrementRetry(record.getId());
log.warn("补偿同步仍然失败, recordId: {}", record.getId(), e);
}
}
}
}
适用场景
- 项目初期,数据量小
- 写操作频率低
- 能容忍短暂不一致
- 团队没有 MQ/Binlog 基础设施
四、方案二:MQ 异步同步(推荐)
原理
业务代码写 MySQL 后发送消息到 MQ,消费者异步消费消息并写入 ES。
用户请求 → Service → 写 MySQL → 发 MQ 消息 → 返回结果
↓
MQ Consumer → 写 ES
实现示例
生产者(业务 Service)
java
/**
* 商品服务 - MQ异步同步ES.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
private final ProductMapper productMapper;
private final KafkaTemplate<String, String> kafkaTemplate;
private static final String TOPIC_PRODUCT_CHANGE = "product-change-topic";
@Override
@Transactional(rollbackFor = Exception.class)
public void createProduct(ProductCreateDto dto) {
// 1. 写入 MySQL
ProductEntity entity = convertToEntity(dto);
productMapper.insert(entity);
// 2. 事务提交后发送消息(避免事务未提交就发消息)
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
sendChangeEvent(entity.getId(), "CREATE");
}
});
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateProduct(String id, ProductUpdateDto dto) {
ProductEntity entity = productMapper.selectById(id);
updateEntityFromDto(entity, dto);
productMapper.updateById(entity);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
sendChangeEvent(id, "UPDATE");
}
});
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteProduct(String id) {
productMapper.deleteById(id);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
sendChangeEvent(id, "DELETE");
}
});
}
private void sendChangeEvent(String productId, String action) {
ProductChangeEvent event = new ProductChangeEvent();
event.setProductId(productId);
event.setAction(action);
event.setTimestamp(System.currentTimeMillis());
try {
kafkaTemplate.send(TOPIC_PRODUCT_CHANGE, productId, JSON.toJSONString(event));
log.info("发送商品变更事件, productId: {}, action: {}", productId, action);
} catch (Exception e) {
log.error("发送商品变更事件失败, productId: {}", productId, e);
// 记录到补偿表,后续重试
}
}
}
消费者(ES 同步)
java
/**
* 商品变更消息消费者 - 同步到ES.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductEsSyncConsumer {
private final ProductMapper productMapper;
private final ProductSearchRepository productSearchRepository;
private final StringRedisTemplate redisTemplate;
@KafkaListener(topics = "product-change-topic", groupId = "es-sync-group")
public void onMessage(ConsumerRecord<String, String> record) {
ProductChangeEvent event = JSON.parseObject(record.value(), ProductChangeEvent.class);
String productId = event.getProductId();
String action = event.getAction();
log.info("消费商品变更事件, productId: {}, action: {}", productId, action);
// 幂等性检查(防止重复消费)
String idempotentKey = "es:sync:" + productId + ":" + event.getTimestamp();
Boolean isNew = redisTemplate.opsForValue().setIfAbsent(idempotentKey, "1",
Duration.ofHours(1));
if (Boolean.FALSE.equals(isNew)) {
log.info("重复消息已跳过, productId: {}", productId);
return;
}
try {
switch (action) {
case "CREATE":
case "UPDATE":
syncToEs(productId);
break;
case "DELETE":
productSearchRepository.deleteById(productId);
log.info("ES文档删除完成, productId: {}", productId);
break;
default:
log.warn("未知变更动作: {}", action);
}
} catch (Exception e) {
log.error("同步ES失败, productId: {}, action: {}", productId, action, e);
// 抛出异常触发 Kafka 重试
throw new RuntimeException("ES同步失败", e);
}
}
private void syncToEs(String productId) {
ProductEntity entity = productMapper.selectById(productId);
if (entity == null) {
// 数据已删除,清理ES
productSearchRepository.deleteById(productId);
return;
}
ProductDocument document = convertToDocument(entity);
productSearchRepository.save(document);
log.info("ES文档同步完成, productId: {}", productId);
}
}
事件 DTO
java
/**
* 商品变更事件.
*/
@Data
public class ProductChangeEvent {
/** 商品ID */
private String productId;
/** 变更动作: CREATE / UPDATE / DELETE */
private String action;
/** 事件时间戳 */
private Long timestamp;
/** 变更字段列表(可选,用于增量更新) */
private List<String> changedFields;
}
优点
- 解耦:业务代码只负责发消息,不直接依赖 ES
- 最终一致:MQ 保证消息不丢,配合重试和幂等可实现最终一致
- 性能好:主流程不等待 ES 响应
- 可扩展:多个消费者可以消费同一消息(如同步到 ES、缓存、数仓等)
缺点
- 需要引入 MQ 中间件
- 有秒级延迟
- 消息丢失场景需要补偿机制
- 消费顺序问题(同一商品的多次变更)
消息顺序保证
java
// Kafka 中使用 productId 作为 key,保证同一商品的消息进入同一分区,有序消费
kafkaTemplate.send(TOPIC_PRODUCT_CHANGE, productId, JSON.toJSONString(event));
适用场景
- 大多数中大型项目的首选方案
- 对一致性有要求但可接受秒级延迟
- 团队已有 MQ 基础设施(Kafka/RocketMQ/RabbitMQ)
- 数据变更需要通知多个下游系统
五、方案三:Binlog 监听
原理
监听 MySQL 的 Binlog(二进制日志),捕获数据变更事件(INSERT/UPDATE/DELETE),解析后写入 ES。业务代码完全无需改动。
用户请求 → Service → 写 MySQL → MySQL 生成 Binlog
↓
Canal/Debezium 监听 Binlog
↓
解析变更 → 写 ES
Canal 方案架构
MySQL Master
│ (Binlog)
▼
Canal Server(伪装为 MySQL Slave)
│
▼
Canal Client / MQ (Kafka/RocketMQ)
│
▼
ES Sync Consumer → Elasticsearch
实现示例(Canal + Kafka)
Canal 配置
properties
# canal.properties
canal.serverMode = kafka
canal.mq.servers = localhost:9092
canal.mq.topic = canal-binlog-topic
# instance.properties
canal.instance.master.address = 127.0.0.1:3306
canal.instance.dbUsername = canal
canal.instance.dbPassword = canal_password
canal.instance.filter.regex = mydb\\.product
Binlog 消费者
java
/**
* Canal Binlog 消息消费者 - 同步到ES.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CanalBinlogConsumer {
private final ProductSearchRepository productSearchRepository;
private final ProductMapper productMapper;
@KafkaListener(topics = "canal-binlog-topic", groupId = "canal-es-sync")
public void onBinlogMessage(String message) {
CanalMessage canalMessage = JSON.parseObject(message, CanalMessage.class);
// 只处理 product 表的变更
if (!"product".equals(canalMessage.getTable())) {
return;
}
String eventType = canalMessage.getType();
List<Map<String, String>> dataList = canalMessage.getData();
log.info("收到Binlog事件, table: {}, type: {}, size: {}",
canalMessage.getTable(), eventType, dataList.size());
for (Map<String, String> row : dataList) {
String productId = row.get("id");
try {
switch (eventType) {
case "INSERT":
case "UPDATE":
syncFromBinlogData(row);
break;
case "DELETE":
productSearchRepository.deleteById(productId);
break;
default:
break;
}
} catch (Exception e) {
log.error("Binlog同步ES失败, productId: {}, type: {}", productId, eventType, e);
}
}
}
private void syncFromBinlogData(Map<String, String> row) {
// 方式1:直接从 Binlog 数据构建文档(性能更好)
ProductDocument document = new ProductDocument();
document.setId(row.get("id"));
document.setProductName(row.get("name"));
document.setDescription(row.get("description"));
document.setBrand(row.get("brand"));
document.setCategoryId(row.get("category_id"));
document.setPrice(Double.parseDouble(row.getOrDefault("price", "0")));
document.setSalesCount(Integer.parseInt(row.getOrDefault("sales_count", "0")));
document.setOnSale("1".equals(row.get("status")));
productSearchRepository.save(document);
// 方式2:从 MySQL 重新查询最新完整数据(数据更准确)
// ProductEntity entity = productMapper.selectById(row.get("id"));
// productSearchRepository.save(convertToDocument(entity));
}
}
/**
* Canal 消息格式.
*/
@Data
public class CanalMessage {
/** 数据库名 */
private String database;
/** 表名 */
private String table;
/** 事件类型: INSERT / UPDATE / DELETE */
private String type;
/** 变更后的数据 */
private List<Map<String, String>> data;
/** 变更前的数据(UPDATE时有值) */
private List<Map<String, String>> old;
/** Binlog 时间戳 */
private Long ts;
}
Debezium 方案(替代 Canal)
Debezium 是另一个流行的 CDC(Change Data Capture)工具,配合 Kafka Connect 使用:
json
{
"name": "mysql-product-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "localhost",
"database.port": "3306",
"database.user": "debezium",
"database.password": "dbz_password",
"database.server.id": "1001",
"topic.prefix": "dbserver1",
"table.include.list": "mydb.product",
"schema.history.internal.kafka.bootstrap.servers": "localhost:9092",
"schema.history.internal.kafka.topic": "schema-changes.mydb"
}
}
优点
- 零侵入:业务代码完全不需要修改
- 全覆盖:所有对 MySQL 的变更都会被捕获(包括直接 SQL、存储过程等)
- 实时性好:秒级同步
- 数据完整:不会遗漏任何变更
缺点
- 架构复杂:需要部署 Canal/Debezium、Kafka 等组件
- 运维成本高:需要维护 Canal 实例、处理位点同步等
- MySQL 配置要求:需要开启 Binlog(ROW 模式)
- 字段映射:Binlog 数据是原始字段名(snake_case),需要转换
- 关联数据:Binlog 只有单表变更,跨表数据需要额外查询
MySQL Binlog 配置要求
ini
# my.cnf
[mysqld]
log-bin=mysql-bin
binlog-format=ROW # 必须是 ROW 模式
binlog-row-image=FULL # 记录完整行数据
server-id=1
适用场景
- 大规模数据同步(百万级数据变更/天)
- 不想改动已有业务代码
- 需要同步到多个下游系统
- 存在直接操作数据库的场景(DBA 脚本、运维操作)
六、方案四:定时全量/增量刷新
原理
通过定时任务,定期从 MySQL 查询数据并全量或增量写入 ES。
定时任务(每分钟/每小时)→ 查询 MySQL 变更数据 → 批量写入 ES
全量刷新实现
java
/**
* ES全量数据刷新任务.
* 适用于数据量不大(<100万)且对实时性要求不高的场景.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EsFullSyncJob {
private final ProductMapper productMapper;
private final ProductSearchRepository productSearchRepository;
private final ElasticsearchOperations elasticsearchOperations;
private static final int BATCH_SIZE = 1000;
/**
* 每天凌晨2点全量刷新.
*/
@Scheduled(cron = "0 0 2 * * ?")
public void fullSync() {
log.info("开始ES全量同步...");
long startTime = System.currentTimeMillis();
// 1. 创建新索引(带版本号)
String newIndex = "product_v" + System.currentTimeMillis();
// createIndex(newIndex); // 创建索引并设置 mapping
// 2. 分批查询 MySQL 写入新索引
int page = 0;
int totalCount = 0;
List<ProductEntity> batch;
do {
batch = productMapper.selectPage(page * BATCH_SIZE, BATCH_SIZE);
if (!batch.isEmpty()) {
List<ProductDocument> documents = batch.stream()
.map(this::convertToDocument)
.collect(Collectors.toList());
productSearchRepository.saveAll(documents);
totalCount += documents.size();
}
page++;
log.info("全量同步进度: 已处理 {} 条", totalCount);
} while (batch != null && batch.size() == BATCH_SIZE);
// 3. 切换别名到新索引
// switchAlias("product", newIndex);
// 4. 删除旧索引
// deleteOldIndex();
long duration = System.currentTimeMillis() - startTime;
log.info("ES全量同步完成, 共同步 {} 条, 耗时 {}ms", totalCount, duration);
}
}
增量刷新实现
java
/**
* ES增量数据同步任务.
* 基于 update_time 字段捕获变更数据.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EsIncrementalSyncJob {
private final ProductMapper productMapper;
private final ProductSearchRepository productSearchRepository;
private final StringRedisTemplate redisTemplate;
private static final String LAST_SYNC_TIME_KEY = "es:sync:last_time";
private static final int BATCH_SIZE = 500;
/**
* 每分钟增量同步.
*/
@Scheduled(fixedDelay = 60000)
public void incrementalSync() {
// 1. 获取上次同步时间
String lastSyncTimeStr = redisTemplate.opsForValue().get(LAST_SYNC_TIME_KEY);
LocalDateTime lastSyncTime = lastSyncTimeStr != null
? LocalDateTime.parse(lastSyncTimeStr)
: LocalDateTime.now().minusMinutes(5);
LocalDateTime currentTime = LocalDateTime.now();
// 2. 查询变更数据
List<ProductEntity> changedList = productMapper.selectByUpdateTimeRange(
lastSyncTime, currentTime);
if (changedList.isEmpty()) {
return;
}
log.info("增量同步: 发现 {} 条变更数据, 时间范围 [{}, {}]",
changedList.size(), lastSyncTime, currentTime);
// 3. 批量同步到ES
List<ProductDocument> documents = changedList.stream()
.map(this::convertToDocument)
.collect(Collectors.toList());
productSearchRepository.saveAll(documents);
// 4. 处理已删除的数据(通过逻辑删除标记)
List<String> deletedIds = changedList.stream()
.filter(e -> e.getDeleted() == 1)
.map(ProductEntity::getId)
.collect(Collectors.toList());
if (!deletedIds.isEmpty()) {
deletedIds.forEach(productSearchRepository::deleteById);
}
// 5. 记录本次同步时间
redisTemplate.opsForValue().set(LAST_SYNC_TIME_KEY, currentTime.toString());
log.info("增量同步完成, 同步 {} 条, 删除 {} 条",
documents.size() - deletedIds.size(), deletedIds.size());
}
}
MySQL 增量查询(Mapper)
xml
<!-- ProductMapper.xml -->
<select id="selectByUpdateTimeRange" resultType="ProductEntity">
SELECT id, name, description, brand, category_id, price,
sales_count, status, deleted, create_time, update_time
FROM product
WHERE update_time >= #{startTime}
AND update_time < #{endTime}
ORDER BY update_time ASC
LIMIT 10000
</select>
优点
- 实现简单:只需要定时任务 + 批量查询
- 侵入性最低:业务代码完全不改
- 无需中间件:不需要 MQ、Canal 等组件
- 全量刷新可修复不一致:定期全量重建可纠正累计的偏差
缺点
- 实时性差:有分钟级甚至更长的延迟
- 依赖 update_time:增量方案要求表有准确的更新时间字段
- 物理删除捕获不到:如果是物理删除,增量方案无法感知
- 全量刷新资源消耗大:数据量大时对 MySQL 和 ES 都有压力
- 可能遗漏:时钟偏差或并发写入可能导致遗漏
适用场景
- 报表搜索、后台管理系统
- 数据量中等(<500万)
- 搜索延迟几分钟可接受
- 没有 MQ 和 Binlog 基础设施
- 作为其他方案的兜底补偿机制
七、混合方案(生产推荐)
实际生产中通常组合使用多种方案,取长补短:
┌─────────────────────────────────────────────────────┐
│ 数据同步架构 │
├─────────────────────────────────────────────────────┤
│ │
│ 主流程:MQ 异步同步(秒级,覆盖 99% 变更) │
│ ↓ │
│ 补偿层:定时增量同步(每5分钟,捕获遗漏) │
│ ↓ │
│ 兜底层:每日全量重建(凌晨,修复累计偏差) │
│ │
└─────────────────────────────────────────────────────┘
实现示例
java
/**
* 混合同步策略配置.
*/
@Configuration
public class EsSyncStrategyConfig {
/**
* 主流程:MQ 消费者(实时同步)
*/
@Bean
public ProductEsSyncConsumer mainSyncConsumer() {
return new ProductEsSyncConsumer();
}
/**
* 补偿层:每5分钟增量同步(捕获MQ丢消息的情况)
*/
@Bean
public EsIncrementalSyncJob compensationJob() {
return new EsIncrementalSyncJob();
}
/**
* 兜底层:每日凌晨全量重建索引
*/
@Bean
public EsFullSyncJob fullSyncJob() {
return new EsFullSyncJob();
}
}
八、数据一致性保障
8.1 消息丢失场景与对策
| 丢失场景 | 原因 | 对策 |
|---|---|---|
| 生产者丢失 | 发送 MQ 失败 | 事务提交后发送 + 失败记录补偿表 |
| MQ 丢失 | Broker 宕机 | 持久化 + 副本 + acks=all |
| 消费者丢失 | 消费成功但 ES 写入前宕机 | 手动 ACK + 重试 |
8.2 幂等性设计
java
/**
* 幂等性消费保障.
* 使用 Redis 记录已处理的消息,防止重复消费导致数据异常.
*/
@Component
@RequiredArgsConstructor
public class IdempotentHelper {
private final StringRedisTemplate redisTemplate;
/**
* 检查消息是否已处理,未处理则标记.
*
* @param messageId 消息唯一标识
* @return true=首次处理,false=重复消息
*/
public boolean checkAndMarkProcessed(String messageId) {
String key = "es:sync:processed:" + messageId;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofHours(24));
return Boolean.TRUE.equals(success);
}
/**
* 处理失败时移除标记,允许重试.
*/
public void removeProcessedMark(String messageId) {
String key = "es:sync:processed:" + messageId;
redisTemplate.delete(key);
}
}
使用方式:
java
@KafkaListener(topics = "product-change-topic", groupId = "es-sync-group")
public void onMessage(ConsumerRecord<String, String> record) {
ProductChangeEvent event = JSON.parseObject(record.value(), ProductChangeEvent.class);
// 幂等校验
String messageId = event.getProductId() + ":" + event.getTimestamp();
if (!idempotentHelper.checkAndMarkProcessed(messageId)) {
log.info("重复消息已跳过, messageId: {}", messageId);
return;
}
try {
doSync(event);
} catch (Exception e) {
// 失败时移除标记,允许下次重试
idempotentHelper.removeProcessedMark(messageId);
throw e;
}
}
8.3 顺序性保障
同一条数据的多次变更必须按顺序处理,否则可能出现旧数据覆盖新数据。
java
/**
* 基于版本号的乐观更新.
* 只有当 ES 中文档的 version 小于消息中的 version 时才执行更新.
*/
public void syncWithVersionCheck(ProductChangeEvent event) {
String productId = event.getProductId();
// 从 MySQL 查询最新数据(含 version 字段)
ProductEntity entity = productMapper.selectById(productId);
if (entity == null) {
productSearchRepository.deleteById(productId);
return;
}
// 查询 ES 中当前版本
ProductDocument existingDoc = productSearchRepository.findById(productId).orElse(null);
// 版本比较:只有新版本才更新
if (existingDoc != null && existingDoc.getDataVersion() >= entity.getDataVersion()) {
log.info("ES文档版本已是最新, 跳过更新, productId: {}, esVersion: {}, dbVersion: {}",
productId, existingDoc.getDataVersion(), entity.getDataVersion());
return;
}
ProductDocument document = convertToDocument(entity);
document.setDataVersion(entity.getDataVersion());
productSearchRepository.save(document);
}
8.4 一致性检测与告警
java
/**
* 数据一致性检测任务.
* 定期抽样对比 MySQL 和 ES 中的数据是否一致.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ConsistencyCheckJob {
private final ProductMapper productMapper;
private final ProductSearchRepository productSearchRepository;
/**
* 每小时抽样检测100条数据的一致性.
*/
@Scheduled(cron = "0 30 * * * ?")
public void check() {
// 随机抽取100条 MySQL 数据
List<ProductEntity> samples = productMapper.selectRandomSample(100);
int inconsistentCount = 0;
for (ProductEntity entity : samples) {
Optional<ProductDocument> esDoc = productSearchRepository.findById(entity.getId());
if (esDoc.isEmpty()) {
log.warn("一致性异常: MySQL存在但ES缺失, productId: {}", entity.getId());
inconsistentCount++;
continue;
}
// 比较关键字段
ProductDocument doc = esDoc.get();
if (!Objects.equals(entity.getName(), doc.getProductName())
|| !Objects.equals(entity.getPrice().doubleValue(), doc.getPrice())) {
log.warn("一致性异常: 数据不一致, productId: {}, mysqlName: {}, esName: {}",
entity.getId(), entity.getName(), doc.getProductName());
inconsistentCount++;
}
}
if (inconsistentCount > 0) {
log.error("一致性检测: 抽样100条中有 {} 条不一致, 不一致率: {}%",
inconsistentCount, inconsistentCount);
// 触发告警(钉钉/企微/邮件)
// alertService.send("ES数据一致性异常", ...);
} else {
log.info("一致性检测通过: 抽样100条全部一致");
}
}
}
九、容错与降级
9.1 ES 不可用时降级到 MySQL
java
/**
* 搜索服务 - 带降级逻辑.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductSearchServiceImpl implements ProductSearchService {
private final ElasticsearchOperations elasticsearchOperations;
private final ProductMapper productMapper;
@Override
public SearchResultDto<ProductDocument> search(ProductSearchParamDto param) {
try {
return doEsSearch(param);
} catch (Exception e) {
log.error("ES搜索异常, 降级到MySQL查询, keyword: {}", param.getKeyword(), e);
return fallbackToMysql(param);
}
}
/**
* 降级方案:使用 MySQL LIKE 查询兜底.
* 性能和体验不如 ES,但保证功能可用.
*/
private SearchResultDto<ProductDocument> fallbackToMysql(ProductSearchParamDto param) {
// 使用 MySQL LIKE 模糊查询(性能较差但能用)
PageHelper.startPage(param.getPageNo(), param.getPageSize());
List<ProductEntity> entities = productMapper.searchByKeyword(
param.getKeyword(), param.getBrand(), param.getCategoryId());
PageInfo<ProductEntity> pageInfo = new PageInfo<>(entities);
SearchResultDto<ProductDocument> result = new SearchResultDto<>();
result.setList(entities.stream()
.map(this::convertToDocument)
.collect(Collectors.toList()));
result.setTotal(pageInfo.getTotal());
result.setPageNo(param.getPageNo());
result.setPageSize(param.getPageSize());
return result;
}
}
9.2 熔断器配置(Resilience4j)
java
/**
* ES 搜索熔断配置.
* 当 ES 连续失败超过阈值时自动熔断,快速走降级逻辑.
*/
@Configuration
public class SearchCircuitBreakerConfig {
@Bean
public CircuitBreakerConfig esCircuitBreakerConfig() {
return CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率超过50%触发熔断
.waitDurationInOpenState(Duration.ofSeconds(30)) // 熔断30秒
.slidingWindowSize(10) // 统计窗口大小
.minimumNumberOfCalls(5) // 最少5次调用才开始统计
.build();
}
}
@CircuitBreaker(name = "esSearch", fallbackMethod = "fallbackToMysql")
public SearchResultDto<ProductDocument> search(ProductSearchParamDto param) {
return doEsSearch(param);
}
十、监控指标
需要关注的同步指标
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| 同步延迟 | 数据变更到 ES 可搜索的时间差 | > 10秒 |
| 同步失败率 | 同步失败次数 / 总同步次数 | > 1% |
| MQ 消费积压 | 未消费的消息数量 | > 10000 |
| 一致性偏差 | 抽样检测不一致的比例 | > 0.5% |
| ES 写入 QPS | 每秒写入 ES 的文档数 | 根据集群容量设定 |
| 补偿任务执行量 | 补偿任务每次修复的数据量 | > 100(说明主流程有问题) |
Prometheus 指标埋点示例
java
@Component
@RequiredArgsConstructor
public class EsSyncMetrics {
private final MeterRegistry meterRegistry;
private Counter syncSuccessCounter;
private Counter syncFailureCounter;
private Timer syncLatencyTimer;
@PostConstruct
public void init() {
syncSuccessCounter = meterRegistry.counter("es.sync.success");
syncFailureCounter = meterRegistry.counter("es.sync.failure");
syncLatencyTimer = meterRegistry.timer("es.sync.latency");
}
public void recordSuccess(long latencyMs) {
syncSuccessCounter.increment();
syncLatencyTimer.record(latencyMs, TimeUnit.MILLISECONDS);
}
public void recordFailure() {
syncFailureCounter.increment();
}
}
十一、方案选型决策树
开始
│
├─ 数据量 < 10万 且 实时性要求不高?
│ └─ 是 → 定时全量刷新(最简单)
│
├─ 能接受改动业务代码?
│ ├─ 是 → 有 MQ 基础设施?
│ │ ├─ 是 → MQ 异步同步(推荐)
│ │ └─ 否 → 双写 + 补偿任务
│ └─ 否 → Binlog 监听(零侵入)
│
└─ 生产环境最终方案:
MQ 异步(主) + 增量补偿(辅) + 每日全量(兜底)
十二、总结
| 维度 | 双写 | MQ 异步 | Binlog | 定时刷新 |
|---|---|---|---|---|
| 实时性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 一致性 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 侵入性 | 高 | 中 | 无 | 无 |
| 复杂度 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
| 运维成本 | 低 | 中 | 高 | 低 |
| 生产推荐度 | 不推荐 | 首选 | 大规模场景 | 仅做补偿 |
生产环境最佳实践:以 MQ 异步同步为主线,定时增量同步作为补偿层,每日全量重建作为兜底层,三层保障数据最终一致。