【架构实战】Canal数据同步:MySQL数据变更实时捕获

一、一次数据不一致让我通宵排查

2019年,我们的订单系统和财务系统数据不一致,差了2000多条记录。

财务那边说订单金额和财务对账对不上,让我们查。我花了整整一个通宵,逐条对比两个系统的数据库,发现是同步脚本漏跑了一批数据。

当时的同步方案是每小时跑一个定时任务,从订单数据库导出数据,然后导入到财务数据库。但是凌晨3点的时候,定时任务因为数据库连接超时失败了,而这批数据再也没有被补上。

从那以后,我们引入了Canal做实时数据同步,再也没有出现过数据不一致的问题。


二、Canal原理

2.1 MySQL主从复制原理

复制代码
MySQL主从复制:

1. Master将变更写入Binlog
2. Slave的IO线程连接Master,请求Binlog
3. Master的Dump线程发送Binlog给Slave
4. Slave的IO线程将Binlog写入Relay Log
5. Slave的SQL线程读取Relay Log,执行SQL

Canal伪装成Slave:
- Canal连接MySQL,假装自己是Slave
- 接收Binlog事件
- 解析Binlog,提取数据变更
- 将变更推送给下游消费者

2.2 Canal架构

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                      Canal架构                                   │
│                                                                  │
│  ┌──────────┐    ┌──────────┐    ┌──────────────┐              │
│  │  MySQL   │───▶│  Canal   │───▶│ Canal Client │              │
│  │ (Master) │    │  Server  │    │  (应用层)     │              │
│  └──────────┘    └──────────┘    └──────────────┘              │
│                       │                                          │
│                       ▼                                          │
│                 ┌──────────┐                                     │
│                 │  MQ      │                                     │
│                 │(Kafka/   │                                     │
│                 │ RocketMQ)│                                     │
│                 └──────────┘                                     │
│                       │                                          │
│                       ▼                                          │
│                 ┌──────────┐                                     │
│                 │ 下游系统 │                                     │
│                 └──────────┘                                     │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

三、Canal部署与配置

3.1 MySQL配置

ini 复制代码
# my.cnf - 开启Binlog
[mysqld]
# Binlog格式:ROW(推荐,数据最完整)
binlog-format=ROW

# Server ID(主从环境中必须唯一)
server-id=1

# Binlog文件名
log-bin=mysql-bin

# 需要同步的数据库(可选)
binlog-do-db=order_db,product_db

# 不需要同步的数据库
binlog-ignore-db=mysql,information_schema

# Binlog保留天数
expire-logs-days=7

# 每次事务提交都刷盘
sync-binlog=1

3.2 Canal Server配置

yaml 复制代码
# canal.properties
canal:
  server:
    mode: tcp  # tcp/kafka/rocketMQ
    port: 11111
    
  # ZooKeeper配置(HA模式)
  zk:
    servers: 127.0.0.1:2181
    
  # 实例配置
  instances:
    - name: order
      destination: order_sync
yaml 复制代码
# instance.properties - 订单库同步
canal:
  instance:
    master:
      address: 127.0.0.1:3306
      
    # 用户名密码(需要REPLICATION权限)
    dbUsername: canal
    dbPassword: canal123
    
    # 过滤规则
    filter: order_db\\..*
    
    # Binlog位置(首次启动时)
    journalName: mysql-bin.000001
    position: 0

3.3 Canal Client开发

java 复制代码
/**
 * Canal客户端
 */
@Component
@Slf4j
public class CanalClient implements InitializingBean, DisposableBean {
    
    private CanalConnector connector;
    
    @Value("${canal.server.host:127.0.0.1}")
    private String canalHost;
    
    @Value("${canal.server.port:11111}")
    private int canalPort;
    
    @Value("${canal.destination:order_sync}")
    private String destination;
    
    private volatile boolean running = false;
    
    @Override
    public void afterPropertiesSet() {
        // 创建连接
        connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress(canalHost, canalPort),
            destination,
            "",
            ""
        );
        
        running = true;
        
        // 启动消费线程
        new Thread(this::consume, "canal-client").start();
    }
    
    /**
     * 消费Binlog
     */
    private void consume() {
        while (running) {
            try {
                connector.connect();
                connector.subscribe("order_db\\..*");
                connector.rollback();
                
                while (running) {
                    // 获取一批数据
                    Message message = connector.getWithoutAck(1000);
                    long batchId = message.getId();
                    int size = message.getEntries().size();
                    
                    if (batchId == -1 || size == 0) {
                        Thread.sleep(1000);
                        continue;
                    }
                    
                    // 处理数据变更
                    processEntries(message.getEntries());
                    
                    // 确认
                    connector.ack(batchId);
                }
                
            } catch (Exception e) {
                log.error("Canal消费异常,5秒后重连", e);
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            } finally {
                connector.disconnect();
            }
        }
    }
    
    /**
     * 处理数据变更
     */
    private void processEntries(List<CanalEntry.Entry> entries) {
        for (CanalEntry.Entry entry : entries) {
            if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
                continue;
            }
            
            try {
                CanalEntry.RowChange rowChange = 
                    CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                
                String tableName = entry.getHeader().getTableName();
                CanalEntry.EventType eventType = rowChange.getEventType();
                
                for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                    DataChangeEvent event = DataChangeEvent.builder()
                        .table(tableName)
                        .eventType(eventType)
                        .before(rowData.getBeforeColumnsList())
                        .after(rowData.getAfterColumnsList())
                        .timestamp(entry.getHeader().getTimestamp())
                        .build();
                    
                    // 发布事件
                    handleEvent(event);
                }
                
            } catch (Exception e) {
                log.error("处理Entry异常: {}", entry, e);
            }
        }
    }
    
    /**
     * 处理事件
     */
    private void handleEvent(DataChangeEvent event) {
        switch (event.getEventType()) {
            case INSERT:
                log.info("INSERT: table={}, data={}", event.getTable(), event.getAfter());
                handleInsert(event);
                break;
            case UPDATE:
                log.info("UPDATE: table={}, before={}, after={}", 
                    event.getTable(), event.getBefore(), event.getAfter());
                handleUpdate(event);
                break;
            case DELETE:
                log.info("DELETE: table={}, data={}", event.getTable(), event.getBefore());
                handleDelete(event);
                break;
        }
    }
    
    @Override
    public void destroy() {
        running = false;
        if (connector != null) {
            connector.disconnect();
        }
    }
}

四、Canal+MQ方案

4.1 Canal投递到Kafka

yaml 复制代码
# canal.properties - Kafka模式
canal:
  server:
    mode: kafka
    
  kafka:
    bootstrap:
      servers: 127.0.0.1:9092
    topic: canal-data-sync
    partition: 1
    replication: 1
    acks: all
    retries: 3
    batch:
      size: 16384
    linger:
      ms: 1
    buffer:
      memory: 33554432

4.2 消费Kafka消息

java 复制代码
/**
 * Kafka消费者 - 处理Canal消息
 */
@Component
@KafkaListener(topics = "canal-data-sync", groupId = "data-sync-group")
@Slf4j
public class CanalKafkaConsumer {
    
    @Autowired
    private DataSyncHandlerFactory handlerFactory;
    
    /**
     * 消费Canal消息
     */
    @KafkaListener(topics = "canal-data-sync")
    public void consume(ConsumerRecord<String, String> record) {
        String value = record.value();
        
        try {
            // 解析Canal消息
            CanalMessage message = JSON.parseObject(value, CanalMessage.class);
            
            // 获取处理器
            DataSyncHandler handler = handlerFactory.getHandler(message.getTable());
            
            if (handler != null) {
                // 处理数据同步
                handler.handle(message);
            } else {
                log.warn("没有找到处理器: table={}", message.getTable());
            }
            
        } catch (Exception e) {
            log.error("消费Canal消息失败: offset={}", record.offset(), e);
        }
    }
}

/**
 * 数据同步处理器工厂
 */
@Component
public class DataSyncHandlerFactory {
    
    private Map<String, DataSyncHandler> handlers = new HashMap<>();
    
    @Autowired
    public void setHandlers(List<DataSyncHandler> handlerList) {
        for (DataSyncHandler handler : handlerList) {
            handlers.put(handler.getTable(), handler);
        }
    }
    
    public DataSyncHandler getHandler(String table) {
        return handlers.get(table);
    }
}

/**
 * 订单同步处理器
 */
@Component
@Slf4j
public class OrderSyncHandler implements DataSyncHandler {
    
    @Autowired
    private FinanceServiceClient financeClient;
    
    @Override
    public String getTable() {
        return "t_order";
    }
    
    @Override
    public void handle(CanalMessage message) {
        switch (message.getEventType()) {
            case INSERT:
            case UPDATE:
                // 同步到财务系统
                syncToFinance(message.getAfter());
                break;
            case DELETE:
                // 通知财务系统
                financeClient.notifyDelete(message.getBefore().get("id").toString());
                break;
        }
    }
    
    private void syncToFinance(Map<String, String> data) {
        FinanceOrderDTO dto = FinanceOrderDTO.builder()
            .orderId(data.get("id"))
            .amount(new BigDecimal(data.get("amount")))
            .status(data.get("status"))
            .createTime(data.get("create_time"))
            .build();
        
        financeClient.syncOrder(dto);
        log.info("同步订单到财务系统: orderId={}", dto.getOrderId());
    }
}

五、踩坑实录

坑1:Binlog格式不对

用了STATEMENT格式的Binlog,导致Canal解析出来的SQL和实际数据不一致。

解决:必须使用ROW格式,数据最完整。

坑2:Canal位点丢失

Canal Server宕机后,消费位点丢失,重复消费或漏消费。

解决:定期保存位点,使用ZooKeeper做HA。

坑3:大事务导致延迟

一次性删除100万条记录,Binlog太大,Canal消费延迟严重。

解决:分批操作,每批1000条,减少单次Binlog大小。

坑4:字段类型变更

ALTER TABLE修改了字段类型,Canal解析失败。

解决:Canal配置支持DDL变更,或事先通知Canal刷新元数据。

坑5:循环同步

A系统同步到B系统,B系统又同步回A系统,形成循环。

解决:标记数据来源,避免处理自己发出的变更。


六、总结

Canal是MySQL数据实时同步的最佳方案:

  • 原理:伪装MySQL Slave,解析Binlog
  • 架构:Canal Server + Client/MQ
  • 应用:数据同步、缓存更新、搜索引擎索引

最佳实践:

  1. 使用ROW格式的Binlog
  2. 做好位点管理和HA
  3. 避免大事务
  4. 防止循环同步
  5. 监控消费延迟

血的教训:

数据同步看似简单,但一旦出问题就是数据不一致,修复成本极高。Canal是好的工具,但用好它需要对MySQL和业务有深刻理解。

思考题: 你的系统用了什么数据同步方案?有没有遇到过数据不一致的问题?


个人观点,仅供参考

相关推荐
cdbqss11 小时前
VB2026 动态生成工具栏类 BqGetToolStrip
数据库·oracle·开源·.net·学习方法·教育电商·basic
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第85题】【Mysql篇】第15题:MySQL 的事务中,幻读是怎么解决的?
java·开发语言·数据库·mysql·面试
yoothey1 小时前
MySQL 索引小白面试详解
数据库·mysql
张忠琳1 小时前
【kubernetes v1.21】(kube-apiserver 4)kube-apiserver Storage/ETCD 与 Watch 机制
云原生·架构·kubernetes
一 乐1 小时前
在线考试|基于Springboot的在线考试管理系统设计与实现(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·毕设·在线考试管理系统
玄米乌龙茶1231 小时前
数据库与缓存核心概念
数据库·缓存
小陈的进阶之路1 小时前
MySQL 索引
数据库·mysql
IronMurphy1 小时前
MySQL拷打最后一讲!!!
mysql
無限進步D1 小时前
MySQL 子查询
数据库·mysql