第09篇:ES 数据同步方案——Canal + Logstash + Flink 全方案对比与实战

🔄 引言:ES 的数据从哪里来?

前面几篇我们讲了怎么在 ES 里建索引、怎么查询、怎么调优------但有一个根本问题一直没有正面回答:ES 里的数据是怎么进来的?

最直接的答案是"应用写入",但在生产环境里,事情远没这么简单。

大多数 SaaS 系统的数据主要存在 MySQL(或其他关系型数据库)里,ES 是搜索层,是 MySQL 的"搜索视图"。每当 MySQL 里的商品被修改、订单状态更新、用户信息变更,ES 里对应的文档也必须同步更新------而且是实时的,可靠的

这个看似简单的"同步",是 ES 落地实践中工程复杂度最高的环节之一。本篇把所有主流方案摆出来,逐一分析利弊,给出适合不同场景的完整代码。


一、六种同步方案全景对比

先建立整体认知,再深入实战。

方案 实时性 复杂度 数据一致性 入侵性 适用规模
双写(应用层) 实时 弱(无事务) 高(改代码)
定时 ETL 分钟级
Logstash JDBC 分钟级
Canal + MQ 秒级 中高 中大
Flink CDC 秒级
Debezium 秒级 中大

选型决策树

bash 复制代码
Q1: 数据量大吗?(>1000万条,或每天增量 >100万)
  是 → Q2: 有大数据团队吗?
          是 → Flink CDC(强大,但需要大数据技术栈)
          否 → Canal + RocketMQ(主流,Java 生态友好)
  否 → Q3: 允许分钟级延迟吗?
          是 → Logstash JDBC(简单,零代码)
          否 → Canal + RocketMQ 或 Debezium

二、方案一:双写(应用层)------最简单但最脆弱

2.1 实现

java 复制代码
// ProductService.java
@Service
@RequiredArgsConstructor
@Transactional
public class ProductService {

    private final ProductRepository mysqlRepo;
    private final ElasticsearchClient esClient;

    /**
     * 双写:先写 MySQL,再写 ES
     *
     * 这种方案最直观,但有一个根本问题:
     * MySQL 和 ES 不在同一个事务里。
     * 如果 MySQL 写入成功,ES 写入失败,数据就不一致了。
     * 如果用重试机制,需要保证幂等,还要处理 ES 宕机的情况。
     */
    public Product createProduct(ProductCreateRequest req) {
        // Step 1: 写入 MySQL(在事务里)
        Product product = productMapper.toEntity(req);
        product = mysqlRepo.save(product);

        // Step 2: 写入 ES(事务外,失败不会回滚 MySQL)
        try {
            esClient.index(i -> i
                .index("products")
                .id(product.getId())
                .document(product)
            );
        } catch (Exception e) {
            // ES 写入失败时,把失败记录写入"补偿表",由后台任务异步重试
            // 这是双写方案必须配套的"兜底机制"
            compensationService.saveFailedSync(product.getId(), "CREATE", e.getMessage());
            log.error("ES 同步失败,已记录补偿任务: productId={}", product.getId(), e);
        }

        return product;
    }
}

2.2 双写的补偿机制

java 复制代码
// SyncCompensationService.java ------ 双写失败的异步补偿
@Service
@RequiredArgsConstructor
@Slf4j
public class SyncCompensationService {

    private final SyncFailureRepository failureRepo;
    private final ProductRepository productRepo;
    private final ElasticsearchClient esClient;

    @Scheduled(fixedDelay = 30000)  // 每 30 秒扫描一次
    public void retryFailedSyncs() {
        List<SyncFailure> failures = failureRepo.findPendingRetries(
            LocalDateTime.now().minusHours(24),  // 只重试 24 小时内的失败
            100                                   // 每次最多处理 100 条
        );

        for (SyncFailure failure : failures) {
            try {
                Product product = productRepo.findById(failure.getEntityId())
                        .orElse(null);

                if (product == null) {
                    // 商品已删除,ES 里也删除
                    esClient.delete(d -> d.index("products").id(failure.getEntityId()));
                } else {
                    esClient.index(i -> i
                        .index("products")
                        .id(product.getId())
                        .document(product)
                    );
                }

                failure.setStatus(SyncStatus.SUCCESS);
                failureRepo.save(failure);
                log.info("补偿同步成功: {}", failure.getEntityId());

            } catch (Exception e) {
                failure.setRetryCount(failure.getRetryCount() + 1);
                if (failure.getRetryCount() >= 5) {
                    failure.setStatus(SyncStatus.DEAD_LETTER);  // 放弃重试,人工处理
                    log.error("补偿同步超过最大重试次数,转入死信: {}", failure.getEntityId());
                }
                failureRepo.save(failure);
            }
        }
    }
}

⚠️ 双写方案的本质局限:双写无法完全保证一致性,只能通过补偿机制降低不一致的时间窗口。如果你的业务对搜索数据的一致性要求很高(如法律文书检索、财务报表),不建议用双写。对于普通电商搜索,短暂的不一致(秒级到分钟级)通常是可以接受的。


三、方案二:Logstash JDBC------零代码,快速起步

Logstash 是 ELK 栈的数据管道组件,内置 JDBC 插件可以直接从数据库拉取数据同步到 ES,不需要改应用代码。

3.1 增量同步配置

ruby 复制代码
# logstash-product-sync.conf
# 核心思路:利用 updated_at 字段做增量,定时拉取最近更新的记录

input {
  jdbc {
    # 数据库连接(MySQL)
    jdbc_driver_library => "/opt/logstash/lib/mysql-connector-java-8.0.33.jar"
    jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
    jdbc_connection_string => "jdbc:mysql://mysql:3306/saas_db?useSSL=false&serverTimezone=Asia/Shanghai"
    jdbc_user => "logstash_user"
    jdbc_password => "${MYSQL_PASSWORD}"  # 从环境变量读取,不要硬编码

    # SQL:增量查询(只拉取上次同步时间之后更新的记录)
    # :sql_last_value 是 Logstash 自动维护的游标值(上次处理的最大 updated_at)
    statement => "
      SELECT
        p.id,
        p.name,
        p.brand,
        p.category,
        p.price,
        p.description,
        p.stock,
        p.is_active,
        p.is_deleted,
        p.tenant_id,
        p.created_at,
        p.updated_at,
        GROUP_CONCAT(pt.tag_name) AS tags
      FROM products p
      LEFT JOIN product_tags pt ON p.id = pt.product_id
      WHERE p.updated_at > :sql_last_value
        AND p.updated_at <= NOW()  -- 避免查询到当前时刻正在写入的数据
      GROUP BY p.id
      ORDER BY p.updated_at ASC
      LIMIT 10000
    "

    # 游标字段:Logstash 用这个字段记录同步进度
    tracking_column => "updated_at"
    tracking_column_type => "timestamp"
    use_column_value => true

    # 游标持久化:重启后从断点继续,而不是全量重新同步
    last_run_metadata_path => "/var/logstash/metadata/product_last_run"

    # 每分钟执行一次(cron 格式)
    schedule => "* * * * *"

    # 时区处理(非常重要!否则时间字段会差8小时)
    jdbc_default_timezone => "Asia/Shanghai"

    # 批量处理大小(每批拉多少条)
    jdbc_fetch_size => 1000
  }
}

filter {
  # 数据转换
  mutate {
    # 重命名字段(MySQL 列名 → ES 字段名)
    rename => { "is_active" => "isActive" }
    rename => { "is_deleted" => "isDeleted" }

    # 把 tags 字符串分割成数组
    split => { "tags" => "," }

    # 移除 Logstash 自动添加的元数据字段
    remove_field => ["@version", "tags"]
  }

  # 处理软删除:如果 is_deleted=true,在 ES 中也删除该文档
  if [isDeleted] == 1 {
    mutate {
      add_field => { "[@metadata][action]" => "delete" }
    }
  } else {
    mutate {
      add_field => { "[@metadata][action]" => "index" }
    }
  }

  # 类型转换(JDBC 拿到的数值可能是字符串,需要显式转换)
  mutate {
    convert => {
      "price" => "float"
      "stock" => "integer"
    }
  }
}

output {
  elasticsearch {
    hosts => ["https://es-node1:9200"]
    user => "elastic"
    password => "${ES_PASSWORD}"
    ssl => true
    ssl_certificate_verification => false  # 自签名证书,生产用 cacert 替换

    # 目标索引(利用 tenant_id 字段路由到不同索引,需要 ES 索引模板支持)
    index => "tenant_%{tenant_id}_products"

    # 文档 ID(用 MySQL 的主键,保证幂等)
    document_id => "%{id}"

    # 根据 action 字段决定是写入还是删除
    action => "%{[@metadata][action]}"

    # 批量写入配置
    flush_size => 1000        # 积累 1000 条就写入
    idle_flush_time => 10     # 最多等 10 秒就写入(保证实时性)
  }

  # 调试时开启,生产关闭
  # stdout { codec => rubydebug }
}

3.2 Logstash 方案的局限

这个方案的关键限制是:无法捕获删除操作 (除非用软删除)和无法捕获增量快于轮询间隔的变更(如 1 分钟内同一条记录改了 10 次,只能同步最终状态,中间状态丢失)。

对于绝大多数搜索场景,这两个限制都是可以接受的------我们只需要最终状态。所以 Logstash JDBC 是中小体量、允许分钟级延迟场景的完美方案:零代码改造,配置文件搞定。


四、方案三:Canal + RocketMQ------主流生产方案

Canal 是阿里开源的 MySQL binlog 解析工具,通过伪装成 MySQL Slave,实时接收 binlog 事件,可以做到秒级甚至毫秒级的数据同步,且天然支持增删改三种操作。

4.1 架构设计

bash 复制代码
MySQL Primary
    │ binlog(row格式)
    ↓
Canal Server(伪装成 MySQL Slave)
    │ 解析 binlog 事件
    ↓
RocketMQ Topic: es-sync-product
    │ 消费
    ↓
ES Sync Consumer(Java 服务)
    │ 数据转换 + 幂等处理
    ↓
Elasticsearch

为什么要加 MQ 而不是 Canal 直接写 ES?

Canal Server 本身不负责重试和容错,如果直接写 ES,ES 短暂不可用时 binlog 事件就会丢失。加入 MQ 后,Canal 只负责把事件发到 MQ(可靠投递),消费者按自己的速度处理,ES 不可用时消息堆积在 MQ,恢复后继续消费------解耦了生产和消费,提升了整体可靠性。

4.2 MySQL 配置

ini 复制代码
# my.cnf ------ Canal 依赖 binlog row 格式
[mysqld]
log-bin=mysql-bin           # 开启 binlog
binlog-format=ROW           # 必须是 ROW 格式(记录行变更,而不是 SQL 语句)
binlog-row-image=FULL       # 完整记录变更前后的所有列值(Canal 需要)
server-id=1                 # 必须与 Canal 的 server-id 不同

# 为 Canal 创建专用账号
sql 复制代码
-- 创建 Canal 专用 MySQL 账号(权限最小化原则)
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

4.3 Canal Server 部署(Docker)

yaml 复制代码
# docker-compose-canal.yml
services:
  canal-server:
    image: canal/canal-server:v1.1.7
    container_name: canal-server
    environment:
      # Canal 实例名称(对应 conf/example 目录)
      - canal.destinations=product-sync
      # MySQL 连接信息
      - canal.instance.master.address=mysql:3306
      - canal.instance.dbUsername=canal
      - canal.instance.dbPassword=canal_password
      # 只监听指定的库和表(减少无关 binlog 的处理)
      - canal.instance.filter.regex=saas_db\\.products,saas_db\\.product_tags
      # MQ 配置(Canal 把事件发到 RocketMQ)
      - canal.mq.servers=rocketmq-nameserver:9876
      - canal.mq.topic=es-sync-product
      # 按 tenant_id 做 MQ partition,保证同一租户的事件有序
      - canal.mq.partition.hash=.*\\..*:tenant_id
    volumes:
      - canal-logs:/home/admin/canal-server/logs
    networks:
      - sync-net

4.4 Canal 事件消费者(核心实现)

java 复制代码
// CanalEsSyncConsumer.java
@Component
@RequiredArgsConstructor
@Slf4j
@RocketMQMessageListener(
    topic = "es-sync-product",
    consumerGroup = "es-sync-consumer-group",
    // 顺序消费:保证同一分区(同一租户)的消息按顺序处理
    consumeMode = ConsumeMode.ORDERLY,
    messageModel = MessageModel.CLUSTERING
)
public class CanalEsSyncConsumer implements RocketMQListener<String> {

    private final ElasticsearchClient esClient;
    private final ObjectMapper objectMapper;
    private final IdempotencyChecker idempotencyChecker;

    /**
     * 消费 Canal 发送的 binlog 事件
     *
     * Canal 消息格式示例:
     * {
     *   "type": "UPDATE",                    // INSERT/UPDATE/DELETE
     *   "table": "products",
     *   "ts": 1705300245000,                 // 事件时间戳(毫秒)
     *   "data": [{"id":"P001","name":"..."}], // 变更后的数据
     *   "old": [{"name":"old name"}]          // 变更前的数据(UPDATE 时有)
     * }
     */
    @Override
    public void onMessage(String message) {
        CanalMessage canalMsg;
        try {
            canalMsg = objectMapper.readValue(message, CanalMessage.class);
        } catch (JsonProcessingException e) {
            log.error("Canal 消息解析失败: {}", message, e);
            // 解析失败的消息不能重试(格式问题,重试也没用),记录后丢弃
            deadLetterService.save(message, "PARSE_ERROR");
            return;
        }

        log.debug("处理 Canal 事件: type={}, table={}, ids={}",
            canalMsg.getType(), canalMsg.getTable(),
            canalMsg.getData().stream().map(d -> d.get("id")).collect(Collectors.toList())
        );

        try {
            switch (canalMsg.getType()) {
                case "INSERT", "UPDATE" -> handleUpsert(canalMsg);
                case "DELETE" -> handleDelete(canalMsg);
                default -> log.warn("未知事件类型: {}", canalMsg.getType());
            }
        } catch (Exception e) {
            // 业务处理异常,抛出让 RocketMQ 重试
            // RocketMQ 会按退避策略重试(1s, 5s, 10s, 30s...)
            log.error("处理 Canal 事件失败,将重试: {}", canalMsg, e);
            throw new RuntimeException("ES 同步失败,触发重试", e);
        }
    }

    /**
     * 处理 INSERT/UPDATE 事件
     *
     * 关键:幂等处理。
     * 因为 MQ 至少一次投递(at-least-once)的语义,同一条消息可能被消费多次。
     * 我们用 ES 的版本号(external_version)来保证幂等:
     * 用 Canal 事件的时间戳作为版本号,只有时间戳更新的消息才能更新文档。
     */
    private void handleUpsert(CanalMessage message) throws IOException {
        List<Map<String, Object>> dataList = message.getData();

        if (dataList.size() == 1) {
            // 单条更新(最常见)
            Map<String, Object> data = dataList.get(0);
            String productId = (String) data.get("id");
            String tenantId = (String) data.get("tenant_id");

            // 幂等检查:如果这个事件已经处理过,跳过
            String eventKey = productId + ":" + message.getTs();
            if (idempotencyChecker.isProcessed(eventKey)) {
                log.debug("重复事件,跳过: {}", eventKey);
                return;
            }

            // 数据转换:MySQL 行数据 → ES 文档格式
            Map<String, Object> esDoc = transformToEsDoc(data);

            // 写入 ES,使用外部版本号保证幂等
            // version_type=external:只有传入的 version > ES 中存储的 version 时才更新
            esClient.index(i -> i
                .index(buildIndexName(tenantId))
                .id(productId)
                .versionType(VersionType.External)
                .version(message.getTs())  // 用 binlog 时间戳作为版本号
                .document(esDoc)
            );

            idempotencyChecker.markProcessed(eventKey, Duration.ofHours(24));
            log.debug("ES 文档更新成功: {}", productId);

        } else {
            // 批量更新(较少见,如 UPDATE ... WHERE ... 影响多行)
            BulkRequest.Builder bulkBuilder = new BulkRequest.Builder();
            for (Map<String, Object> data : dataList) {
                String productId = (String) data.get("id");
                String tenantId = (String) data.get("tenant_id");
                Map<String, Object> esDoc = transformToEsDoc(data);

                bulkBuilder.operations(op -> op
                    .index(idx -> idx
                        .index(buildIndexName(tenantId))
                        .id(productId)
                        .version(message.getTs())
                        .versionType(VersionType.External)
                        .document(esDoc)
                    )
                );
            }
            BulkResponse response = esClient.bulk(bulkBuilder.build());
            if (response.errors()) {
                // 批量操作有失败,检查是否是版本冲突(可以忽略)
                response.items().stream()
                    .filter(item -> item.error() != null)
                    .filter(item -> !"version_conflict_engine_exception"
                            .equals(item.error().type()))  // 版本冲突是幂等的,不算错误
                    .forEach(item -> log.error("Bulk 写入失败: id={}, error={}",
                            item.id(), item.error().reason()));
            }
        }
    }

    /**
     * 处理 DELETE 事件
     */
    private void handleDelete(CanalMessage message) throws IOException {
        for (Map<String, Object> data : message.getData()) {
            String productId = (String) data.get("id");
            String tenantId = (String) data.get("tenant_id");

            try {
                esClient.delete(d -> d
                    .index(buildIndexName(tenantId))
                    .id(productId)
                );
                log.debug("ES 文档删除成功: {}", productId);
            } catch (ElasticsearchException e) {
                if (e.response().status() == 404) {
                    // 文档不存在,视为已删除,幂等处理
                    log.debug("ES 文档已不存在,忽略删除操作: {}", productId);
                } else {
                    throw e;
                }
            }
        }
    }

    /**
     * MySQL 行数据 → ES 文档格式转换
     *
     * 这里是很多 Bug 的高发地:
     * 1. 类型转换:MySQL 的 DECIMAL 类型在 Canal 里是字符串,需要转 double
     * 2. 时间格式:MySQL datetime 到 ES date 的格式转换
     * 3. 布尔值:MySQL 的 TINYINT(1) 在 Canal 里是 0/1,需要转 boolean
     * 4. JSON 字段:MySQL 的 JSON 类型需要额外反序列化
     */
    private Map<String, Object> transformToEsDoc(Map<String, Object> mysqlRow) {
        Map<String, Object> doc = new HashMap<>(mysqlRow);

        // DECIMAL → double
        if (doc.containsKey("price")) {
            doc.put("price", Double.parseDouble(doc.get("price").toString()));
        }

        // TINYINT(1) → boolean
        if (doc.containsKey("is_active")) {
            doc.put("is_active", "1".equals(doc.get("is_active").toString()));
        }
        if (doc.containsKey("is_deleted")) {
            doc.put("is_deleted", "1".equals(doc.get("is_deleted").toString()));
        }

        // 时间格式转换(Canal 输出的是 "2024-01-15 08:00:00" 格式)
        // ES date 字段接受这个格式,通常不需要转换,但要确保时区正确

        // 移除不需要同步的字段
        doc.remove("internal_notes");
        doc.remove("cost_price");  // 成本价不对外暴露

        return doc;
    }

    private String buildIndexName(String tenantId) {
        return "tenant_" + tenantId + "_products";
    }
}

4.5 幂等性保障

java 复制代码
// IdempotencyChecker.java ------ 基于 Redis 的幂等检查
@Component
@RequiredArgsConstructor
public class IdempotencyChecker {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String KEY_PREFIX = "es_sync:idempotent:";

    /**
     * 检查事件是否已处理过
     * 用 Redis SETNX 实现:设置成功说明未处理,设置失败说明已处理
     */
    public boolean isProcessed(String eventKey) {
        String redisKey = KEY_PREFIX + eventKey;
        // GET 比 EXISTS 更准确(考虑 key 过期的边界情况)
        return redisTemplate.opsForValue().get(redisKey) != null;
    }

    public void markProcessed(String eventKey, Duration ttl) {
        String redisKey = KEY_PREFIX + eventKey;
        redisTemplate.opsForValue().set(redisKey, "1", ttl);
    }

    /**
     * 原子性检查 + 标记(推荐用这个,避免 check-then-act 的竞态条件)
     * 返回 true 表示首次处理(可以继续),false 表示重复(应跳过)
     */
    public boolean checkAndMark(String eventKey, Duration ttl) {
        String redisKey = KEY_PREFIX + eventKey;
        // SETNX:只有 key 不存在时才设置,原子操作
        Boolean isNew = redisTemplate.opsForValue()
                .setIfAbsent(redisKey, "1", ttl);
        return Boolean.TRUE.equals(isNew);
    }
}

Flink CDC 是 Apache Flink 生态的变更数据捕获方案,底层使用 Debezium 读取 binlog,上层提供 Flink SQL 接口,支持复杂的流式计算和数据转换。

5.1 适用场景

Flink CDC 适合以下场景:

  • 数据量极大(亿级),需要分布式并行处理
  • 同步过程中需要复杂的数据转换(多表 JOIN、聚合、窗口计算)
  • 已有 Flink 技术栈,统一数据管道
  • 需要同时把数据同步到多个目标(ES + Kafka + 数据仓库)
java 复制代码
// FlinkCdcSyncJob.java
public class FlinkCdcSyncJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

        // 设置检查点(保证 exactly-once 语义)
        env.enableCheckpointing(60000);  // 每 60 秒做一次 checkpoint
        env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
        env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30000);

        // ── 创建 MySQL CDC 源表 ─────────────────────────────────────────
        tableEnv.executeSql("""
            CREATE TABLE mysql_products (
                id STRING,
                name STRING,
                brand STRING,
                category STRING,
                price DECIMAL(10, 2),
                description STRING,
                stock INT,
                is_active BOOLEAN,
                is_deleted BOOLEAN,
                tenant_id STRING,
                created_at TIMESTAMP(3),
                updated_at TIMESTAMP(3),
                PRIMARY KEY (id) NOT ENFORCED
            ) WITH (
                'connector' = 'mysql-cdc',
                'hostname' = 'mysql',
                'port' = '3306',
                'username' = 'canal',
                'password' = '${MYSQL_PASSWORD}',
                'database-name' = 'saas_db',
                'table-name' = 'products',
                -- scan.startup.mode:
                --   initial: 先全量扫描,再增量(适合初次同步)
                --   latest-offset: 只消费最新 binlog(适合已有 ES 数据,只同步增量)
                'scan.startup.mode' = 'initial',
                -- 并行度(设置为 MySQL 分片数,通常是 1,因为 binlog 是串行的)
                'parallelism' = '1'
            )
        """);

        // ── 创建 ES 目标表 ──────────────────────────────────────────────
        tableEnv.executeSql("""
            CREATE TABLE es_products (
                id STRING,
                name STRING,
                brand STRING,
                category STRING,
                price DOUBLE,
                description STRING,
                stock INT,
                is_active BOOLEAN,
                tenant_id STRING,
                created_at STRING,
                updated_at STRING,
                PRIMARY KEY (id) NOT ENFORCED
            ) WITH (
                'connector' = 'elasticsearch-7',
                'hosts' = 'https://es-node1:9200',
                'username' = 'elastic',
                'password' = '${ES_PASSWORD}',
                -- 动态索引名:根据 tenant_id 路由到不同索引
                'index' = 'tenant_{tenant_id}_products',
                'document-id.key-delimiter' = '$',
                'sink.bulk-flush.max-actions' = '1000',
                'sink.bulk-flush.interval' = '5s'
            )
        """);

        // ── 数据转换 + 写入 ─────────────────────────────────────────────
        tableEnv.executeSql("""
            INSERT INTO es_products
            SELECT
                id,
                name,
                brand,
                category,
                CAST(price AS DOUBLE),
                description,
                stock,
                is_active,
                tenant_id,
                DATE_FORMAT(created_at, 'yyyy-MM-dd HH:mm:ss'),
                DATE_FORMAT(updated_at, 'yyyy-MM-dd HH:mm:ss')
            FROM mysql_products
            WHERE is_deleted = FALSE   -- 过滤软删除的记录
        """);

        env.execute("MySQL-to-ES Sync Job");
    }
}

5.3 处理 DELETE 事件

Flink CDC 的 Flink SQL 接口对 DELETE 的处理稍有特殊:

java 复制代码
// Flink SQL 对 CDC 事件的处理:
// INSERT 事件 → 对应 ES 的 index 操作
// UPDATE 事件 → 对应 ES 的 index 操作(覆盖写)
// DELETE 事件 → 对应 ES 的 delete 操作
// Flink 的 ES Connector 会自动处理这三种情况,无需额外代码

// 但如果使用 DataStream API(比 SQL 更灵活),可以显式处理:
DataStream<RowData> cdcStream = // ... 从 MySQL CDC source 读取

cdcStream.addSink(new ElasticsearchSink.Builder<RowData>(
    httpHosts,
    new ElasticsearchSinkFunction<RowData>() {
        @Override
        public void process(RowData element, RuntimeContext ctx,
                           RequestIndexer indexer) {
            RowKind rowKind = element.getRowKind();

            switch (rowKind) {
                case INSERT, UPDATE_AFTER -> {
                    // 写入或更新
                    IndexRequest request = Requests.indexRequest()
                        .index(buildIndexName(element))
                        .id(element.getString(0).toString())  // id 字段
                        .source(rowDataToMap(element), XContentType.JSON);
                    indexer.add(request);
                }
                case DELETE, UPDATE_BEFORE -> {
                    // 删除(UPDATE_BEFORE 是更新前的旧数据,通常不需要处理)
                    if (rowKind == RowKind.DELETE) {
                        DeleteRequest request = Requests.deleteRequest()
                            .index(buildIndexName(element))
                            .id(element.getString(0).toString());
                        indexer.add(request);
                    }
                }
            }
        }
    }
).build());

六、数据一致性保障:定期校验与修复

无论用哪种同步方案,都需要一个定期的数据校验机制,发现 MySQL 和 ES 之间的差异并修复。

java 复制代码
// DataConsistencyCheckService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class DataConsistencyCheckService {

    private final ProductRepository mysqlRepo;
    private final ElasticsearchClient esClient;

    /**
     * 每天凌晨 2 点执行全量校验(选业务低峰期)
     *
     * 校验策略:
     * 不直接比较所有字段(数据量大时无法承受),
     * 而是比较关键字段(updated_at + 文档数量)来快速发现差异
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void dailyConsistencyCheck() {
        log.info("开始 MySQL-ES 数据一致性校验");
        List<String> tenantIds = tenantRepository.findAllActiveTenantIds();

        for (String tenantId : tenantIds) {
            try {
                checkTenantConsistency(tenantId);
            } catch (Exception e) {
                log.error("租户 [{}] 一致性校验失败", tenantId, e);
            }
        }
    }

    private void checkTenantConsistency(String tenantId) throws IOException {
        // 1. 查 MySQL:最近 24 小时有更新的商品(updated_at > 昨天同时)
        LocalDateTime since = LocalDateTime.now().minusHours(25); // 多 1 小时缓冲
        List<Product> mysqlProducts = mysqlRepo
                .findByTenantIdAndUpdatedAtAfter(tenantId, since);

        if (mysqlProducts.isEmpty()) return;

        // 2. 批量查 ES:同一批商品的 updated_at
        List<String> productIds = mysqlProducts.stream()
                .map(Product::getId)
                .collect(Collectors.toList());

        MgetResponse<Map> esResponse = esClient.mget(mg -> mg
            .index("tenant_" + tenantId + "_products")
            .ids(productIds),
            (Class<Map>) (Class<?>) Map.class
        );

        // 3. 比较差异
        Map<String, String> esUpdatedAtMap = esResponse.docs().stream()
                .filter(MultiGetResponseItem::isResult)
                .filter(doc -> doc.result().found())
                .collect(Collectors.toMap(
                    doc -> doc.result().id(),
                    doc -> (String) doc.result().source().get("updated_at")
                ));

        List<String> missingInEs = new ArrayList<>();
        List<String> staleInEs = new ArrayList<>();

        for (Product mysqlProduct : mysqlProducts) {
            String esUpdatedAt = esUpdatedAtMap.get(mysqlProduct.getId());
            if (esUpdatedAt == null) {
                missingInEs.add(mysqlProduct.getId());  // ES 里没有这条数据
            } else if (mysqlProduct.getUpdatedAt()
                    .isAfter(LocalDateTime.parse(esUpdatedAt, dtFormatter))) {
                staleInEs.add(mysqlProduct.getId());    // ES 里的数据比 MySQL 旧
            }
        }

        // 4. 修复差异(重新同步)
        if (!missingInEs.isEmpty() || !staleInEs.isEmpty()) {
            log.warn("租户 [{}] 发现数据不一致:缺失={}, 过期={}",
                tenantId, missingInEs.size(), staleInEs.size());

            List<String> toSync = new ArrayList<>();
            toSync.addAll(missingInEs);
            toSync.addAll(staleInEs);

            List<Product> productsToSync = mysqlRepo.findAllById(toSync);
            productVectorIndexService.batchIndexWithVectors(productsToSync);

            log.info("租户 [{}] 数据修复完成,同步 {} 条", tenantId, toSync.size());
        }
    }
}

七、SaaS 多租户同步的隔离设计

在 SaaS 场景里,大租户的数据量可能是小租户的 1000 倍,同步任务不能用同一个线程池处理所有租户,否则大租户会阻塞小租户的同步。

java 复制代码
// TenantAwareSyncExecutor.java
@Component
public class TenantAwareSyncExecutor {

    // 大租户使用独立线程池(不与小租户竞争资源)
    private final ExecutorService enterprisePool = Executors.newFixedThreadPool(
        10, new NamedThreadFactory("enterprise-sync")
    );

    // 小租户共享线程池
    private final ExecutorService sharedPool = Executors.newFixedThreadPool(
        20, new NamedThreadFactory("shared-sync")
    );

    public CompletableFuture<Void> submitSync(String tenantId,
                                              Runnable syncTask) {
        TenantPlan plan = tenantRepository.getPlan(tenantId);
        ExecutorService pool = (plan == TenantPlan.ENTERPRISE)
                ? enterprisePool : sharedPool;

        return CompletableFuture.runAsync(syncTask, pool)
                .exceptionally(e -> {
                    log.error("租户 [{}] 同步任务失败", tenantId, e);
                    alertService.sendAlert("ES同步失败", tenantId, e);
                    return null;
                });
    }
}

📋 本篇小结

方案 最适用场景 核心优势 主要限制
双写 小项目快速落地 零依赖,最简单 一致性弱,需补偿机制
Logstash JDBC 中小量,允许延迟 零代码改造 不能捕获 DELETE,分钟级延迟
Canal + RocketMQ 生产主流 秒级,可靠,支持 DELETE 需要部署 Canal 和 MQ
Flink CDC 大数据,复杂转换 分布式,exactly-once 技术栈重,门槛高
Debezium 云原生,Kafka 生态 与 Kafka 深度集成 需要 Kafka

❓ 高频面试 & AI 问答

Q: MySQL 数据如何实时同步到 Elasticsearch?

A: 主流方案是 Canal + 消息队列。Canal 伪装成 MySQL Slave 读取 binlog,解析后发到 RocketMQ/Kafka,消费者读取消息并写入 ES。这种方式对应用无侵入,支持增删改三种操作,延迟通常在秒级以内。

Q: Canal 和 Debezium 有什么区别?怎么选?

A: Canal 是阿里开源、Java 生态友好,与 RocketMQ 配合成熟,国内用户多,文档和社区支持好;Debezium 是 Red Hat 开源,与 Kafka 深度集成,支持更多数据库类型(PostgreSQL、MongoDB 等),是云原生/Kafka 生态的首选。Java 团队用 RocketMQ 选 Canal,Kafka 生态选 Debezium。

Q: ES 数据同步如何保证幂等性?

A: 用 binlog 事件的时间戳作为 ES 文档的外部版本号(version_type=external),ES 只接受版本号更大的更新,自动拒绝过期的重复消息。同时在 Redis 里记录已处理的事件 Key(TTL 24小时),双重保障幂等。

Q: 怎么检测 MySQL 和 ES 的数据不一致?

A: 定期(如每日凌晨)运行对账任务:对最近时间段内有变更的数据(通过 updated_at 过滤),批量查询 ES 对应文档的更新时间,比较差异,对不一致的数据重新全量同步。不要全量对比所有数据,效率太低;按时间窗口滚动对比即可。


🔗 上一篇

第08篇ES + AI 大模型------向量检索与语义搜索全栈实战

🔗 下一篇

第10篇(终篇)生产级 ES 运维------监控、备份、安全与故障排查完全手册

前九篇把 ES 从零到功能全部打通,最后一篇聚焦"跑稳":Prometheus + Grafana 监控体系搭建、自动快照备份策略、ES 安全 RBAC 配置、从 Yellow 到 Red 的完整故障排查手册------以及整个系列的学习路线总结与进阶方向。

相关推荐
做个文艺程序员3 小时前
第04篇:Query DSL 全景与高级检索实战——从入门查询到复杂业务场景
elasticsearch·elasticsearch分词·elasticsearch检索
稳如磐石.3 小时前
北京研华上架式工控机
大数据·人工智能·python
mnasd11 小时前
python常用模块
大数据
杰克尼11 小时前
天机学堂复习总结(day03-day04)
java·开发语言·redis·elasticsearch·spring cloud
步里软件11 小时前
2611.某音 MCN 运营效率提升指南:从手动重复到自动化全流程
大数据·自动化·抖音关注·抖音评论
Agent手记14 小时前
制造业生产流程自动化,Agent需要具备哪些能力?深度拆解2026工业级智能体落地范式与核心架构
大数据·人工智能·ai·架构·自动化
硅基流动15 小时前
光谷爱计算 × 硅基流动:AI 算力联合运营,共建高效“Token 工厂”
大数据·人工智能
xinshu52715 小时前
企业工商和司法风险:从定义到AI识别的完整指南
大数据·人工智能·技术分享
anew___16 小时前
国产AI大模型巅峰对决:2026年5月主流模型深度横评
大数据·人工智能