MySQL 到 Elasticsearch 数据同步策略完全指南

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 &lt; #{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 异步同步为主线,定时增量同步作为补偿层,每日全量重建作为兜底层,三层保障数据最终一致。