🚀 MySQL到ES数据同步:让数据跨界旅行的魔法之旅

40 阅读13分钟

知识点编号:265
难度系数:⭐⭐⭐⭐
实用指数:💯💯💯💯💯


📖 前言:一个数据的"跨界"故事

想象一下,你有一家超市(MySQL数据库),里面存放着各种商品信息:名称、价格、库存等。现在,你想开一个"搜索服务台"(ElasticSearch),让顾客能快速搜索到想要的商品。

问题来了:超市货架上的商品每天都在变化(增删改),你的搜索服务台怎么能实时知道这些变化呢?🤔

总不能每次都让员工手动跑到货架前抄写信息吧?这就是我们今天要解决的问题:如何优雅地实现MySQL到ElasticSearch的数据同步!


🎯 为什么需要MySQL到ES的数据同步?

场景1:电商搜索 🛒

  • MySQL:存储商品完整信息(订单、库存、价格)
  • ElasticSearch:提供快速的商品搜索功能

场景2:日志分析 📊

  • MySQL:存储业务操作记录
  • ElasticSearch:提供强大的日志检索和分析

场景3:数据仓库 🏢

  • MySQL:业务系统的主数据库
  • ElasticSearch:提供实时数据分析和报表

核心原因

  • MySQL擅长事务处理(ACID特性) ✅
  • ElasticSearch擅长全文搜索和聚合分析 🔍
  • 让专业的人做专业的事!💪

🎬 实现方案全景图

┌──────────────────────────────────────────────────────────┐
│                    数据同步方案                            │
├──────────────────────────────────────────────────────────┤
│                                                            │
│  方案1: 定时全量同步 (简单粗暴) 😅                        │
│  方案2: 应用双写 (代码侵入) 😓                            │
│  方案3: Binlog订阅 (推荐⭐⭐⭐⭐⭐)                       │
│      └─ Canal (阿里开源)                                  │
│      └─ Maxwell (Zendesk开源)                             │
│      └─ Debezium (RedHat开源)                             │
│  方案4: Logstash (ELK全家桶) 🎨                           │
│                                                            │
└──────────────────────────────────────────────────────────┘

🌟 方案一:定时全量同步(小白入门版)

原理图:

┌─────────┐    定时任务     ┌────────┐      批量写入    ┌──────┐
│ MySQL   │ ───────────→  │ 应用    │ ───────────→  │  ES  │
│ (主库)  │    SELECT *    │        │   Bulk API    │      │
└─────────┘                └────────┘                └──────┘
     ↓
   每5分钟执行一次

生活比喻 🏪:

就像你每隔5分钟跑到超市货架前,把所有商品信息抄一遍,然后更新到搜索服务台。

实现代码:

@Scheduled(cron = "0 */5 * * * ?")  // 每5分钟执行一次
public void syncData() {
    // 1. 从MySQL查询所有数据
    List<Product> products = productMapper.selectAll();
    
    // 2. 批量写入ES
    BulkRequest bulkRequest = new BulkRequest();
    for (Product product : products) {
        IndexRequest request = new IndexRequest("products")
            .id(product.getId().toString())
            .source(JSON.toJSONString(product), XContentType.JSON);
        bulkRequest.add(request);
    }
    
    // 3. 执行批量操作
    restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
    
    log.info("同步完成,共同步{}条数据", products.size());
}

优缺点分析:

维度评价说明
实现难度极简单,新手友好
实时性有延迟(取决于定时间隔)
性能❌❌每次全量扫描,数据库压力大
数据一致性⚠️可能出现短暂不一致
适用场景📦数据量小、实时性要求不高

🎪 方案二:应用双写(代码侵入版)

原理图:

         写请求
           ↓
      ┌────────┐
      │  应用   │
      └────┬───┘
           │
      ┌────┴────┐
      ↓         ↓
  ┌───────┐  ┌──────┐
  │ MySQL │  │  ES  │
  └───────┘  └──────┘
   (同步写)   (同步写)

生活比喻 📝:

你在超市添加新商品时,既要在货架上摆放(MySQL),也要在搜索服务台记录(ES)。

实现代码:

@Service
public class ProductService {
    
    @Autowired
    private ProductMapper productMapper;
    
    @Autowired
    private RestHighLevelClient esClient;
    
    @Transactional
    public void addProduct(Product product) {
        try {
            // 1. 写入MySQL
            productMapper.insert(product);
            
            // 2. 写入ES
            IndexRequest request = new IndexRequest("products")
                .id(product.getId().toString())
                .source(JSON.toJSONString(product), XContentType.JSON);
            esClient.index(request, RequestOptions.DEFAULT);
            
            log.info("商品添加成功,id={}", product.getId());
        } catch (Exception e) {
            log.error("商品添加失败", e);
            throw new RuntimeException("添加商品失败");
        }
    }
    
    @Transactional
    public void updateProduct(Product product) {
        // 1. 更新MySQL
        productMapper.updateById(product);
        
        // 2. 更新ES
        try {
            UpdateRequest request = new UpdateRequest("products", 
                product.getId().toString())
                .doc(JSON.toJSONString(product), XContentType.JSON);
            esClient.update(request, RequestOptions.DEFAULT);
        } catch (Exception e) {
            log.error("ES更新失败", e);
            // 异步补偿机制
            sendToRetryQueue(product);
        }
    }
}

优缺点分析:

维度评价说明
实现难度⭐⭐需要改造业务代码
实时性实时同步
性能⚠️每次操作要写两个库
数据一致性⚠️可能出现双写不一致
代码侵入性业务代码耦合严重

常见问题 ⚠️:

问题1:MySQL写成功,ES写失败怎么办?

// 解决方案:引入重试队列
@Async
public void sendToRetryQueue(Product product) {
    // 发送到MQ进行异步重试
    rabbitTemplate.convertAndSend("es-retry-queue", product);
}

问题2:事务怎么办?

  • MySQL的事务无法覆盖ES
  • 需要引入最终一致性方案(后面会讲)

🎉 方案三:Binlog订阅(业界最佳实践⭐⭐⭐⭐⭐)

什么是Binlog?📚

Binlog(Binary Log) = MySQL的"小本本"📔

想象MySQL是一个勤奋的会计,它会把每一笔账(数据变更)都记录在小本本上:

  • 张三买了一件商品 ✍️
  • 李四修改了地址 ✍️
  • 王五删除了订单 ✍️

这个小本本就是Binlog

Binlog的三种格式:

格式记录内容优点缺点
StatementSQL语句日志量小可能导致主从不一致
Row数据行变化数据准确日志量大
Mixed混合模式平衡复杂度高

数据同步推荐使用:Row格式

Canal:Binlog订阅神器 🔧

Canal是什么? Canal是阿里巴巴开源的,基于MySQL Binlog的增量订阅&消费组件。

形象比喻: Canal就像一个"邮递员"📮,专门负责监听MySQL的小本本(Binlog),一旦有新内容,就立即送到ES那里。

Canal工作原理:

┌──────────────────────────────────────────────────────────┐
│                    Canal 工作原理                          │
└──────────────────────────────────────────────────────────┘

  ┌─────────────┐
  │   MySQL     │
  │  主库        │
  │             │
  │  Binlog ━━━━┿━━━━━━━━┓
  └─────────────┘         ║
                          ║ 1. Canal伪装成MySQL从库
                          ↓
                  ┌───────────────┐
                  │  Canal Server │ 
                  │  (订阅Binlog)  │
                  └───────┬───────┘
                          │
                          │ 2. 解析Binlog事件
                          │    - INSERT- UPDATE- DELETE
                          ↓
                  ┌───────────────┐
                  │ Canal Client  │
                  │  (业务消费)    │
                  └───────┬───────┘
                          │
                          │ 3. 写入ES
                          ↓
                    ┌──────────┐
                    │    ES    │
                    └──────────┘

Canal详细配置步骤 🛠️

步骤1:MySQL开启Binlog

编辑MySQL配置文件my.cnf

[mysqld]
# 开启binlog
log-bin=mysql-bin
# 选择Row模式
binlog-format=ROW
# 设置server-id
server-id=1

重启MySQL:

systemctl restart mysqld

验证Binlog是否开启:

mysql> SHOW VARIABLES LIKE 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin       | ON    |
+---------------+-------+

mysql> SHOW MASTER STATUS;
+------------------+----------+--------------+------------------+
| File             | Position | Binlog_Do_DB | Binlog_Ignore_DB |
+------------------+----------+--------------+------------------+
| mysql-bin.000001 |      154 |              |                  |
+------------------+----------+--------------+------------------+

步骤2:创建Canal专用账号

-- 创建canal用户
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal123';

-- 授权
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';

-- 刷新权限
FLUSH PRIVILEGES;

步骤3:部署Canal Server

Docker方式(推荐)

# 拉取镜像
docker pull canal/canal-server:latest

# 启动Canal
docker run -d \
  --name canal-server \
  -p 11111:11111 \
  -e canal.instance.master.address=192.168.1.100:3306 \
  -e canal.instance.dbUsername=canal \
  -e canal.instance.dbPassword=canal123 \
  -e canal.instance.filter.regex=.*\..* \
  canal/canal-server:latest

传统方式

# 1. 下载Canal
wget https://github.com/alibaba/canal/releases/download/canal-1.1.6/canal.deployer-1.1.6.tar.gz

# 2. 解压
mkdir canal && tar -zxvf canal.deployer-1.1.6.tar.gz -C canal

# 3. 修改配置
cd canal/conf/example
vi instance.properties

修改instance.properties

# MySQL连接配置
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal123

# 订阅的表(支持正则)
# 示例:只订阅test库的所有表
canal.instance.filter.regex=test\..*

# Binlog位置(首次同步)
canal.instance.master.journal.name=
canal.instance.master.position=
canal.instance.master.timestamp=

启动Canal:

sh bin/startup.sh

查看日志:

tail -f logs/canal/canal.log
tail -f logs/example/example.log

步骤4:编写Canal客户端代码

添加依赖

<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>1.1.6</version>
</dependency>

客户端代码

import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import com.google.protobuf.InvalidProtocolBufferException;
import org.elasticsearch.action.delete.DeleteRequest;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.update.UpdateRequest;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.net.InetSocketAddress;
import java.util.List;

@Component
public class CanalClient {

    @Autowired
    private RestHighLevelClient esClient;

    private CanalConnector connector;

    @PostConstruct
    public void init() {
        // 连接Canal Server
        connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress("127.0.0.1", 11111),
            "example",  // destination名称
            "",         // 用户名(空表示不需要)
            ""          // 密码
        );
    }

    public void start() {
        try {
            // 1. 连接Canal
            connector.connect();
            
            // 2. 订阅(可以指定具体的表)
            connector.subscribe("test\.product");
            
            // 3. 回滚到未消费的位置
            connector.rollback();
            
            while (true) {
                // 4. 获取数据(一次最多100条)
                Message message = connector.getWithoutAck(100);
                long batchId = message.getId();
                int size = message.getEntries().size();
                
                if (batchId == -1 || size == 0) {
                    Thread.sleep(1000);
                } else {
                    // 5. 处理数据
                    handleMessage(message.getEntries());
                    
                    // 6. 确认消费
                    connector.ack(batchId);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            connector.disconnect();
        }
    }

    private void handleMessage(List<Entry> entries) {
        for (Entry entry : entries) {
            // 只处理行变更事件
            if (entry.getEntryType() != EntryType.ROWDATA) {
                continue;
            }

            try {
                // 解析数据
                RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
                EventType eventType = rowChange.getEventType();
                String tableName = entry.getHeader().getTableName();
                
                // 处理不同类型的事件
                for (RowData rowData : rowChange.getRowDatasList()) {
                    switch (eventType) {
                        case INSERT:
                            handleInsert(tableName, rowData);
                            break;
                        case UPDATE:
                            handleUpdate(tableName, rowData);
                            break;
                        case DELETE:
                            handleDelete(tableName, rowData);
                            break;
                        default:
                            break;
                    }
                }
            } catch (InvalidProtocolBufferException e) {
                e.printStackTrace();
            }
        }
    }

    // 处理INSERT事件
    private void handleInsert(String tableName, RowData rowData) {
        try {
            // 获取插入后的数据
            List<Column> columns = rowData.getAfterColumnsList();
            String id = null;
            JSONObject json = new JSONObject();
            
            for (Column column : columns) {
                if (column.getIsKey()) {
                    id = column.getValue();
                }
                json.put(column.getName(), column.getValue());
            }
            
            // 写入ES
            IndexRequest request = new IndexRequest(tableName)
                .id(id)
                .source(json.toJSONString(), XContentType.JSON);
            esClient.index(request, RequestOptions.DEFAULT);
            
            System.out.println("✅ INSERT: " + json.toJSONString());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 处理UPDATE事件
    private void handleUpdate(String tableName, RowData rowData) {
        try {
            // 获取更新后的数据
            List<Column> columns = rowData.getAfterColumnsList();
            String id = null;
            JSONObject json = new JSONObject();
            
            for (Column column : columns) {
                if (column.getIsKey()) {
                    id = column.getValue();
                }
                json.put(column.getName(), column.getValue());
            }
            
            // 更新ES
            UpdateRequest request = new UpdateRequest(tableName, id)
                .doc(json.toJSONString(), XContentType.JSON)
                .docAsUpsert(true);  // 如果不存在则插入
            esClient.update(request, RequestOptions.DEFAULT);
            
            System.out.println("🔄 UPDATE: " + json.toJSONString());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 处理DELETE事件
    private void handleDelete(String tableName, RowData rowData) {
        try {
            // 获取删除前的数据
            List<Column> columns = rowData.getBeforeColumnsList();
            String id = null;
            
            for (Column column : columns) {
                if (column.getIsKey()) {
                    id = column.getValue();
                    break;
                }
            }
            
            // 从ES删除
            DeleteRequest request = new DeleteRequest(tableName, id);
            esClient.delete(request, RequestOptions.DEFAULT);
            
            System.out.println("❌ DELETE: id=" + id);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Canal优缺点分析:

维度评价说明
实现难度⭐⭐⭐需要部署Canal Server
实时性✅✅毫秒级延迟
性能✅✅对MySQL无压力
数据一致性✅✅准实时一致
代码侵入性✅✅业务代码无感知
可靠性✅✅支持HA、断点续传

🎨 方案四:Logstash-JDBC(ELK全家桶)

原理图:

┌─────────┐  JDBC轮询   ┌───────────┐  输出插件   ┌──────┐
│ MySQL   │ ─────────→ │ Logstash  │ ─────────→ │  ES  │
└─────────┘             └───────────┘             └──────┘

配置示例:

# logstash-mysql.conf
input {
  jdbc {
    jdbc_driver_library => "/path/to/mysql-connector-java.jar"
    jdbc_driver_class => "com.mysql.jdbc.Driver"
    jdbc_connection_string => "jdbc:mysql://localhost:3306/test"
    jdbc_user => "root"
    jdbc_password => "password"
    
    # SQL查询(增量同步)
    statement => "SELECT * FROM product WHERE update_time > :sql_last_value"
    
    # 定时执行(每分钟)
    schedule => "* * * * *"
    
    # 记录上次同步位置
    use_column_value => true
    tracking_column => "update_time"
    tracking_column_type => "timestamp"
    last_run_metadata_path => ".logstash_jdbc_last_run"
  }
}

filter {
  # 数据转换(可选)
  mutate {
    remove_field => ["@version", "@timestamp"]
  }
}

output {
  elasticsearch {
    hosts => ["localhost:9200"]
    index => "products"
    document_id => "%{id}"
  }
  
  # 同时输出到控制台(调试用)
  stdout {
    codec => json_lines
  }
}

启动Logstash:

bin/logstash -f logstash-mysql.conf

优缺点分析:

维度评价说明
实现难度⭐⭐配置简单
实时性⚠️取决于轮询间隔
性能⚠️定期全表扫描压力大
数据一致性基于时间戳的增量同步
适用场景📊数据分析、日志收集

🎯 方案对比总结

方案实时性性能复杂度推荐指数适用场景
定时全量❌ 差❌ 差⭐ 简单⭐⭐数据量小、离线场景
应用双写✅ 好⚠️ 一般⭐⭐ 中等⭐⭐⭐新项目、可控代码
Canal/Binlog✅✅ 很好✅✅ 很好⭐⭐⭐ 较复杂⭐⭐⭐⭐⭐生产推荐
Logstash⚠️ 一般⚠️ 一般⭐⭐ 中等⭐⭐⭐已有ELK环境

🚨 常见问题与解决方案

问题1:数据一致性如何保证?

挑战

  • MySQL写成功,ES写失败 ❌
  • 网络抖动导致重复消费 🔁
  • Canal宕机后数据丢失 💥

解决方案

1) 幂等性设计

// 使用唯一ID,ES会自动处理重复写入
IndexRequest request = new IndexRequest("products")
    .id(product.getId().toString())  // 指定ID
    .source(json);

2) 重试机制

@Retryable(
    value = {IOException.class},
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000)
)
public void syncToES(Product product) {
    // ES写入逻辑
}

3) 死信队列

@RabbitListener(queues = "es-sync-queue")
public void handleMessage(Product product) {
    try {
        syncToES(product);
    } catch (Exception e) {
        // 重试3次后放入死信队列
        rabbitTemplate.convertAndSend("es-dead-letter-queue", product);
    }
}

问题2:全量数据如何初始化?

场景:项目上线前,MySQL已有1000万条历史数据。

解决方案

方案A:使用Logstash进行全量导入

# 一次性全量导入
input {
  jdbc {
    statement => "SELECT * FROM product"
    schedule => "* * * * *"
  }
}

方案B:自己写脚本批量导入

public class BatchSyncService {
    
    private static final int BATCH_SIZE = 1000;
    
    public void fullSync() {
        long total = productMapper.selectCount(null);
        long pages = (total + BATCH_SIZE - 1) / BATCH_SIZE;
        
        for (long page = 0; page < pages; page++) {
            // 分页查询
            List<Product> products = productMapper.selectPage(
                new Page<>(page, BATCH_SIZE)
            ).getRecords();
            
            // 批量写入ES
            BulkRequest bulkRequest = new BulkRequest();
            for (Product product : products) {
                bulkRequest.add(new IndexRequest("products")
                    .id(product.getId().toString())
                    .source(JSON.toJSONString(product), XContentType.JSON));
            }
            
            esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
            
            log.info("同步进度:{}/{}", page + 1, pages);
            
            // 休息一下,避免打爆ES
            Thread.sleep(100);
        }
        
        log.info("✅ 全量同步完成!");
    }
}

问题3:如何监控同步状态?

监控指标

  • Canal消费延迟(秒)
  • ES写入成功率(%)
  • 同步失败次数(次)
  • 数据一致性(MySQL vs ES数量)

实现示例

@Component
public class SyncMonitor {
    
    @Scheduled(cron = "0 */5 * * * ?")
    public void checkConsistency() {
        // 1. 查询MySQL总数
        long mysqlCount = productMapper.selectCount(null);
        
        // 2. 查询ES总数
        CountRequest countRequest = new CountRequest("products");
        long esCount = esClient.count(countRequest, RequestOptions.DEFAULT)
            .getCount();
        
        // 3. 对比差异
        long diff = Math.abs(mysqlCount - esCount);
        if (diff > 100) {
            // 发送告警
            alertService.sendAlert(
                "数据同步异常",
                String.format("MySQL: %d, ES: %d, 差异: %d", 
                    mysqlCount, esCount, diff)
            );
        }
        
        log.info("数据一致性检查: MySQL={}, ES={}, Diff={}", 
            mysqlCount, esCount, diff);
    }
}

问题4:如何处理大字段?

场景:商品详情字段有HTML内容,达到100KB。

问题

  • ES索引速度慢 🐌
  • 占用大量存储空间 💾

解决方案

方案A:字段拆分

// MySQL存储完整数据
@Data
public class Product {
    private Long id;
    private String name;
    private String detail;  // 详情(大字段)
}

// ES只存储搜索必要字段
@Data
public class ProductES {
    private Long id;
    private String name;
    private String summary;  // 摘要(小字段)
}

方案B:禁用索引

PUT /products
{
  "mappings": {
    "properties": {
      "id": {"type": "long"},
      "name": {"type": "text"},
      "detail": {
        "type": "text",
        "index": false  // 不建索引,只存储
      }
    }
  }
}

问题5:DDL变更如何处理?

场景:MySQL表结构变更(新增字段、修改字段类型)。

挑战

  • Canal能感知DDL吗?✅ 能!
  • ES的Mapping需要手动更新吗?⚠️ 看情况

解决方案

自动感知DDL

// Canal可以订阅DDL事件
if (entry.getEntryType() == EntryType.DDLENTRY) {
    RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
    String sql = rowChange.getSql();
    
    log.warn("检测到DDL变更: {}", sql);
    
    // 发送通知
    notifyService.sendDingTalk("⚠️ 数据库表结构变更: " + sql);
}

ES Mapping更新策略

# 1. 新增字段(ES会自动识别)
# 无需操作,ES动态映射会自动添加

# 2. 修改字段类型(需要重建索引)
# 步骤:
# a) 创建新索引
PUT /products_v2
{
  "mappings": {
    "properties": {
      "price": {"type": "double"}  # 原来是long
    }
  }
}

# b) 数据迁移
POST /_reindex
{
  "source": {"index": "products"},
  "dest": {"index": "products_v2"}
}

# c) 切换别名
POST /_aliases
{
  "actions": [
    {"remove": {"index": "products_v1", "alias": "products"}},
    {"add": {"index": "products_v2", "alias": "products"}}
  ]
}

🎓 生产环境最佳实践

1️⃣ 架构设计

┌──────────────────────────────────────────────────────────┐
│              高可用架构设计                                 │
└──────────────────────────────────────────────────────────┘

        ┌─────────────┐
        │MySQL 主库   │
        └──────┬──────┘
               │ Binlog
        ┌──────┴──────┐
        ↓             ↓
  ┌───────────┐ ┌───────────┐
  │  Canal    │ │  Canal    │  ← 高可用(HA)
  │  Server1  │ │  Server2  │
  └─────┬─────┘ └─────┬─────┘
        │             │
        └──────┬──────┘
               ↓
        ┌────────────┐
        │  MQ        │  ← 削峰+解耦
        │  (Kafka)   │
        └─────┬──────┘
              │
        ┌─────┴──────┐
        ↓            ↓
  ┌──────────┐ ┌──────────┐
  │Consumer 1│ │Consumer 2│  ← 并行消费
  └────┬─────┘ └────┬─────┘
       │            │
       └──────┬─────┘
              ↓
      ┌──────────────┐
      │ ES Cluster   │
      │ (3节点)       │
      └──────────────┘

2️⃣ 性能优化

批量写入ES

private static final int BATCH_SIZE = 500;
private List<IndexRequest> batchBuffer = new ArrayList<>();

public void syncToES(Product product) {
    IndexRequest request = new IndexRequest("products")
        .id(product.getId().toString())
        .source(JSON.toJSONString(product), XContentType.JSON);
    
    batchBuffer.add(request);
    
    // 达到批次大小或超时,批量提交
    if (batchBuffer.size() >= BATCH_SIZE) {
        flush();
    }
}

private void flush() {
    if (batchBuffer.isEmpty()) return;
    
    BulkRequest bulkRequest = new BulkRequest();
    batchBuffer.forEach(bulkRequest::add);
    
    try {
        BulkResponse response = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
        if (response.hasFailures()) {
            // 处理失败
            handleFailures(response);
        }
    } catch (Exception e) {
        log.error("批量写入ES失败", e);
    } finally {
        batchBuffer.clear();
    }
}

异步写入

@Configuration
public class AsyncConfig {
    
    @Bean(name = "esExecutor")
    public Executor esExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("es-sync-");
        executor.initialize();
        return executor;
    }
}

@Service
public class AsyncSyncService {
    
    @Async("esExecutor")
    public void asyncSyncToES(Product product) {
        // 异步写入ES
    }
}

3️⃣ 监控告警

@Component
public class CanalMetrics {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    // 消费延迟
    private Timer syncLatency;
    
    // 失败次数
    private Counter failCounter;
    
    @PostConstruct
    public void init() {
        syncLatency = Timer.builder("canal.sync.latency")
            .description("Canal同步延迟")
            .register(meterRegistry);
        
        failCounter = Counter.builder("canal.sync.fail")
            .description("Canal同步失败次数")
            .register(meterRegistry);
    }
    
    public void recordSuccess(long latencyMs) {
        syncLatency.record(latencyMs, TimeUnit.MILLISECONDS);
    }
    
    public void recordFailure() {
        failCounter.increment();
    }
}

4️⃣ 数据校验

@Component
public class DataVerifier {
    
    /**
     * 数据一致性校验
     */
    @Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点
    public void verify() {
        // 1. 抽样校验
        List<Long> sampleIds = randomSampleIds(1000);
        
        int inconsistentCount = 0;
        for (Long id : sampleIds) {
            Product mysql = productMapper.selectById(id);
            ProductES es = searchFromES(id);
            
            if (!isConsistent(mysql, es)) {
                inconsistentCount++;
                log.warn("数据不一致: id={}", id);
                
                // 自动修复
                syncToES(mysql);
            }
        }
        
        // 2. 发送报告
        String report = String.format(
            "数据校验完成: 抽样%d条,不一致%d条,一致率%.2f%%",
            sampleIds.size(),
            inconsistentCount,
            (1 - inconsistentCount * 1.0 / sampleIds.size()) * 100
        );
        
        log.info(report);
        notifyService.sendReport(report);
    }
}

📝 总结

关键要点 🎯

  1. Canal是生产环境首选方案 ⭐⭐⭐⭐⭐

    • 非侵入式
    • 高性能
    • 准实时
    • 高可用
  2. 核心组件

    • MySQL Binlog(数据源)
    • Canal Server(订阅解析)
    • MQ(削峰解耦)
    • Consumer(业务消费)
    • ElasticSearch(目标存储)
  3. 三大挑战

    • 数据一致性 → 幂等性 + 重试 + 校验
    • 性能优化 → 批量 + 异步 + 并行
    • 高可用 → HA + 监控 + 告警
  4. 生活类比

    • MySQL = 超市货架 🏪
    • Binlog = 进货日志 📔
    • Canal = 搬运工 🚚
    • MQ = 中转仓库 📦
    • ES = 搜索服务台 🔍

🎉 彩蛋:面试官可能追问的问题

Q1:Canal宕机了怎么办?

A:Canal支持HA模式,使用ZooKeeper做选主,同时记录消费位点(binlog position),重启后可以从断点继续消费。

Q2:如果Binlog被删除了呢?

A:定期做全量同步兜底,同时监控binlog保留时间,确保足够长(建议7天以上)。

Q3:性能瓶颈在哪里?

A:通常在ES写入端,可以通过批量写入、增加Consumer数量、优化ES配置来提升。

Q4:如何保证数据100%一致?

A:理论上无法100%保证,但可以通过定时校验+自动修复,达到99.99%的一致性。


📚 参考资料


💡 温馨提示: 数据同步不是银弹,选择合适的方案比追求完美更重要!根据业务场景、团队能力、成本预算综合考虑。

加油,打工人! 💪💪💪


⭐ 如果觉得有帮助,别忘了给个Star哦!⭐

📧 有问题欢迎提Issue交流!

🎓 学习路上,我们一起成长!

````