Canal深度解析:MySQL增量数据订阅与消费实战

Canal深度解析:MySQL增量数据订阅与消费实战

一、引言:为什么我们需要Canal?

你的电商系统中,用户下单后修改了商品库存,但是Redis缓存中的库存数据还是旧的,导致超卖问题;或者你需要将MySQL中的数据实时同步到Elasticsearch中供搜索使用,传统的定时任务方式延迟高、性能差。

这些问题的根源在于:数据在不同系统间的实时同步缺乏优雅的解决方案

Canal应运而生。它是阿里巴巴开源的基于MySQL数据库binlog的增量订阅&消费组件,能够帮助我们实现:

  • 实时性高:毫秒级数据同步
  • 侵入性低:无需修改业务代码
  • 可靠性强:基于MySQL主从复制协议
  • 扩展性好:支持多种下游消费场景

二、Canal应用场景全景

Canal在实际生产环境中有着广泛的应用场景:

1. 数据库实时备份

通过订阅MySQL的binlog,可以实时将数据变更同步到备份库,实现数据的实时容灾。相比传统的MySQL主从复制,Canal提供了更灵活的控制能力。

2. 缓存同步

这是最常见的场景。当MySQL中的数据发生变更时,通过Canal实时捕获变更事件,更新Redis、Memcached等缓存,保证缓存与数据库的一致性。

3. 实时数据分析

将MySQL的数据变更实时推送到Kafka等消息队列,供下游的实时计算系统(如Flink、Spark Streaming)消费,构建实时数据仓库。

4. 数据异构迁移

将关系型数据库MySQL中的数据实时同步到NoSQL数据库(如MongoDB、HBase)或搜索引擎(如Elasticsearch),满足不同业务场景的查询需求。

三、Canal工作原理深度剖析

核心原理

Canal的工作原理基于MySQL的主从复制协议。我们知道,MySQL的主从复制过程如下:

  1. Master将数据变更写入binlog(二进制日志)
  2. Slave的IO线程读取Master的binlog并写入relay log(中继日志)
  3. Slave的SQL线程读取relay log并重放SQL,完成数据同步

Canal模拟了MySQL Slave的交互协议,把自己伪装成MySQL的从库,向MySQL Master发送dump请求,MySQL推送binlog给Canal,Canal解析binlog对象(原始为byte流)。

工作流程

markdown 复制代码
1. Canal连接MySQL,伪装成Slave
2. MySQL推送binlog给Canal
3. Canal解析binlog内容
4. Canal将解析后的数据发送给客户端
5. 客户端处理数据(写入缓存、MQ等)

四、Canal架构设计

Canal的架构设计非常优雅,主要包含以下核心组件:

1. EventParser(事件解析器)

负责作为MySQL Slave与MySQL Master建立连接,模拟slave协议与MySQL Master进行交互,请求并接收binlog流,并将其解析成Canal内部的数据结构。

核心职责:

  • 连接管理:建立与MySQL的连接
  • 协议模拟:模拟MySQL Slave协议
  • Binlog获取:从MySQL获取binlog流
  • 事件解析:将binlog解析为结构化事件

2. EventSink(事件过滤器)

负责对EventParser解析出来的数据进行过滤、加工、分发。可以根据正则表达式配置,过滤出需要的表、字段等。

核心职责:

  • 数据过滤:按规则过滤数据
  • 数据转换:格式转换和加工
  • 事件路由:将事件路由到不同的处理器

3. EventStore(事件存储)

负责将数据存储到本地,提供消费端获取数据。采用环形队列结构,支持批量消费和消费位点记录。

核心职责:

  • 数据存储:持久化事件数据
  • 位点管理:记录消费位置
  • 数据提供:为消费者提供数据

五、MySQL Binlog机制详解

要深入理解Canal,必须先理解MySQL的binlog机制。

Binlog是什么?

Binlog(Binary Log)是MySQL的二进制日志,记录了所有DDL和DML语句(不包括SELECT和SHOW等查询语句),以事件的形式记录,并包含语句执行的时间信息。

Binlog的作用

  1. 主从复制:Master将binlog发送给Slave,Slave重放binlog实现数据同步
  2. 数据恢复:通过binlog可以进行point-in-time恢复
  3. 审计:可以通过binlog审计数据库的变更历史

Binlog格式

MySQL binlog有三种格式:

1. Statement格式

sql 复制代码
-- 记录SQL语句
UPDATE users SET age = age + 1 WHERE id < 100;

优点:日志量少 缺点:某些函数如NOW()、UUID()等会导致主从数据不一致

2. Row格式

ini 复制代码
-- 记录每一行的实际变更
[ROW] id=1, age: 25 -> 26
[ROW] id=2, age: 30 -> 31
...

优点:准确记录每一行的变更,保证主从一致 缺点:日志量大

3. Mixed格式 混合使用Statement和Row格式,MySQL自动判断使用哪种格式。

Canal要求使用Row格式,因为只有Row格式才能准确捕获每一行的变更详情。

六、Canal环境搭建实战

6.1 MySQL配置

首先需要开启MySQL的binlog功能,并设置为Row格式。

编辑MySQL配置文件my.cnf

ini 复制代码
[mysqld]
# 开启binlog
log-bin=mysql-bin
# 设置binlog格式为ROW
binlog-format=ROW
# 设置server-id(必须唯一)
server-id=1
# 设置binlog过期时间(天)
expire_logs_days=7
# 设置单个binlog文件大小
max_binlog_size=100M
# 需要同步的数据库
binlog-do-db=test_db

重启MySQL使配置生效:

bash 复制代码
sudo systemctl restart mysql

创建Canal用户并授权:

sql 复制代码
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal@123';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

验证binlog是否开启:

sql 复制代码
SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
SHOW MASTER STATUS;

6.2 Canal Server部署

下载Canal Server:

bash 复制代码
wget https://github.com/alibaba/canal/releases/download/canal-1.1.6/canal.deployer-1.1.6.tar.gz
mkdir canal-server && cd canal-server
tar -zxvf canal.deployer-1.1.6.tar.gz

配置Canal Server,编辑conf/example/instance.properties

properties 复制代码
# MySQL连接配置
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal@123
canal.instance.connectionCharset=UTF-8

# 订阅的binlog位置(首次启动会从最新位置开始)
canal.instance.master.journal.name=
canal.instance.master.position=
canal.instance.master.timestamp=

# 订阅的数据库和表(支持正则)
canal.instance.filter.regex=test_db\\..*
# 黑名单(不订阅的表)
canal.instance.filter.black.regex=

# 启用事务支持
canal.instance.tsdb.enable=true

启动Canal Server:

bash 复制代码
sh bin/startup.sh

查看日志确认启动成功:

bash 复制代码
tail -f logs/canal/canal.log
tail -f logs/example/example.log

七、Canal客户端开发实战

7.1 引入依赖

xml 复制代码
<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>1.1.6</version>
</dependency>

7.2 简单客户端实现

下面是一个完整的Canal客户端示例,演示如何订阅MySQL变更并处理:

java 复制代码
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 java.net.InetSocketAddress;
import java.util.List;

public class SimpleCanalClient {

    public static void main(String[] args) {
        // 创建连接
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress("127.0.0.1", 11111),
            "example",  // destination名称
            "",         // 用户名(默认为空)
            ""          // 密码(默认为空)
        );

        try {
            // 连接Canal Server
            connector.connect();
            // 订阅数据库表
            connector.subscribe("test_db\\..*");
            // 回滚到未消费位置
            connector.rollback();

            while (true) {
                // 获取指定数量的数据
                Message message = connector.getWithoutAck(100);
                long batchId = message.getId();
                int size = message.getEntries().size();

                if (batchId == -1 || size == 0) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                } else {
                    printEntries(message.getEntries());
                }

                // 确认消费
                connector.ack(batchId);
            }
        } finally {
            connector.disconnect();
        }
    }

    private static void printEntries(List<CanalEntry.Entry> entries) {
        for (CanalEntry.Entry entry : entries) {
            if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN ||
                entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
                continue;
            }

            CanalEntry.RowChange rowChange;
            try {
                rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
            } catch (InvalidProtocolBufferException e) {
                throw new RuntimeException("解析binlog失败", e);
            }

            CanalEntry.EventType eventType = rowChange.getEventType();
            String tableName = entry.getHeader().getTableName();
            String schemaName = entry.getHeader().getSchemaName();

            System.out.println(String.format("数据库: %s, 表: %s, 事件类型: %s",
                schemaName, tableName, eventType));

            for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                if (eventType == CanalEntry.EventType.DELETE) {
                    printColumns(rowData.getBeforeColumnsList(), "删除");
                } else if (eventType == CanalEntry.EventType.INSERT) {
                    printColumns(rowData.getAfterColumnsList(), "插入");
                } else if (eventType == CanalEntry.EventType.UPDATE) {
                    System.out.println("------更新前------");
                    printColumns(rowData.getBeforeColumnsList(), "");
                    System.out.println("------更新后------");
                    printColumns(rowData.getAfterColumnsList(), "");
                }
            }
        }
    }

    private static void printColumns(List<CanalEntry.Column> columns, String operation) {
        for (CanalEntry.Column column : columns) {
            System.out.println(String.format("%s: %s = %s (类型: %s, 是否更新: %s)",
                operation,
                column.getName(),
                column.getValue(),
                column.getMysqlType(),
                column.getUpdated()));
        }
    }
}

7.3 封装优雅的客户端

实际项目中,我们需要对客户端进行封装,提供更易用的API:

java 复制代码
import com.alibaba.otter.canal.protocol.CanalEntry;
import java.util.List;

/**
 * Canal事件监听器接口
 */
public interface CanalEventListener {

    /**
     * 处理INSERT事件
     */
    void onInsert(String database, String table, List<CanalEntry.Column> data);

    /**
     * 处理UPDATE事件
     */
    void onUpdate(String database, String table,
                  List<CanalEntry.Column> before,
                  List<CanalEntry.Column> after);

    /**
     * 处理DELETE事件
     */
    void onDelete(String database, String table, List<CanalEntry.Column> data);
}
java 复制代码
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * Canal客户端封装
 */
public class CanalClient {

    private static final Logger logger = LoggerFactory.getLogger(CanalClient.class);

    private CanalConnector connector;
    private String filter;
    private CanalEventListener listener;
    private volatile boolean running = false;

    public CanalClient(String host, int port, String destination, String filter) {
        this.connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress(host, port),
            destination,
            "",
            ""
        );
        this.filter = filter;
    }

    public void setListener(CanalEventListener listener) {
        this.listener = listener;
    }

    public void start() {
        connector.connect();
        connector.subscribe(filter);
        connector.rollback();

        running = true;

        // 启动消费线程
        new Thread(() -> {
            while (running) {
                try {
                    Message message = connector.getWithoutAck(100);
                    long batchId = message.getId();
                    int size = message.getEntries().size();

                    if (batchId != -1 && size > 0) {
                        processEntries(message.getEntries());
                        connector.ack(batchId);
                    } else {
                        TimeUnit.SECONDS.sleep(1);
                    }
                } catch (Exception e) {
                    logger.error("处理Canal消息异常", e);
                    connector.rollback();
                }
            }
        }, "canal-client-thread").start();

        logger.info("Canal客户端启动成功");
    }

    public void stop() {
        running = false;
        connector.disconnect();
        logger.info("Canal客户端已停止");
    }

    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 database = entry.getHeader().getSchemaName();
                String table = entry.getHeader().getTableName();
                CanalEntry.EventType eventType = rowChange.getEventType();

                for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                    if (listener != null) {
                        switch (eventType) {
                            case INSERT:
                                listener.onInsert(database, table, rowData.getAfterColumnsList());
                                break;
                            case UPDATE:
                                listener.onUpdate(database, table,
                                    rowData.getBeforeColumnsList(),
                                    rowData.getAfterColumnsList());
                                break;
                            case DELETE:
                                listener.onDelete(database, table, rowData.getBeforeColumnsList());
                                break;
                            default:
                                break;
                        }
                    }
                }
            } catch (Exception e) {
                logger.error("处理Entry异常", e);
            }
        }
    }
}

八、生产实战案例:Redis缓存同步

场景描述

电商系统中,商品信息存储在MySQL中,为了提高查询性能,在Redis中缓存了商品数据。当商品信息在MySQL中更新时,需要实时同步更新Redis缓存。

实现方案

java 复制代码
import com.alibaba.otter.canal.protocol.CanalEntry;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 商品缓存同步监听器
 */
public class ProductCacheSyncListener implements CanalEventListener {

    private JedisPool jedisPool;
    private ObjectMapper objectMapper = new ObjectMapper();

    public ProductCacheSyncListener(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
    }

    @Override
    public void onInsert(String database, String table, List<CanalEntry.Column> data) {
        if ("product".equals(table)) {
            syncToRedis(data, "INSERT");
        }
    }

    @Override
    public void onUpdate(String database, String table,
                        List<CanalEntry.Column> before,
                        List<CanalEntry.Column> after) {
        if ("product".equals(table)) {
            syncToRedis(after, "UPDATE");
        }
    }

    @Override
    public void onDelete(String database, String table, List<CanalEntry.Column> data) {
        if ("product".equals(table)) {
            deleteFromRedis(data);
        }
    }

    private void syncToRedis(List<CanalEntry.Column> columns, String operation) {
        try (Jedis jedis = jedisPool.getResource()) {
            Map<String, String> productMap = new HashMap<>();
            String productId = null;

            for (CanalEntry.Column column : columns) {
                if ("id".equals(column.getName())) {
                    productId = column.getValue();
                }
                productMap.put(column.getName(), column.getValue());
            }

            if (productId != null) {
                String key = "product:" + productId;
                String value = objectMapper.writeValueAsString(productMap);
                jedis.setex(key, 3600, value);

                System.out.println(String.format("缓存同步成功 [%s]: %s", operation, key));
            }
        } catch (Exception e) {
            System.err.println("同步到Redis失败: " + e.getMessage());
        }
    }

    private void deleteFromRedis(List<CanalEntry.Column> columns) {
        try (Jedis jedis = jedisPool.getResource()) {
            for (CanalEntry.Column column : columns) {
                if ("id".equals(column.getName())) {
                    String key = "product:" + column.getValue();
                    jedis.del(key);
                    System.out.println("缓存删除成功: " + key);
                    break;
                }
            }
        } catch (Exception e) {
            System.err.println("从Redis删除失败: " + e.getMessage());
        }
    }
}

启动应用

java 复制代码
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class CacheSyncApplication {

    public static void main(String[] args) {
        // 创建Redis连接池
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(100);
        config.setMaxIdle(20);
        JedisPool jedisPool = new JedisPool(config, "localhost", 6379);

        // 创建Canal客户端
        CanalClient canalClient = new CanalClient(
            "localhost",
            11111,
            "example",
            "test_db\\.product"
        );

        // 设置监听器
        canalClient.setListener(new ProductCacheSyncListener(jedisPool));

        // 启动
        canalClient.start();

        // 添加关闭钩子
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            canalClient.stop();
            jedisPool.close();
        }));

        System.out.println("商品缓存同步服务已启动...");
    }
}

九、与Spring Boot集成

9.1 添加依赖

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>

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

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
</dependencies>

9.2 配置文件

yaml 复制代码
# application.yml
spring:
  redis:
    host: localhost
    port: 6379
    database: 0

canal:
  server:
    host: localhost
    port: 11111
  destination: example
  filter: test_db\\..*
  batch-size: 100

9.3 Canal配置类

java 复制代码
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "canal")
public class CanalProperties {

    private Server server = new Server();
    private String destination;
    private String filter;
    private int batchSize = 100;

    // Getters and Setters

    public static class Server {
        private String host;
        private int port;

        // Getters and Setters
    }
}

9.4 Canal启动器

java 复制代码
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.Message;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;

@Component
public class CanalStarter implements CommandLineRunner {

    @Autowired
    private CanalProperties canalProperties;

    @Autowired
    private CanalEventListener canalEventListener;

    @Override
    public void run(String... args) {
        CanalConnector connector = CanalConnectors.newSingleConnector(
            new InetSocketAddress(
                canalProperties.getServer().getHost(),
                canalProperties.getServer().getPort()
            ),
            canalProperties.getDestination(),
            "",
            ""
        );

        connector.connect();
        connector.subscribe(canalProperties.getFilter());
        connector.rollback();

        new Thread(() -> {
            while (true) {
                try {
                    Message message = connector.getWithoutAck(canalProperties.getBatchSize());
                    long batchId = message.getId();

                    if (batchId != -1 && !message.getEntries().isEmpty()) {
                        // 处理消息
                        message.getEntries().forEach(entry -> {
                            // 调用监听器处理
                        });
                        connector.ack(batchId);
                    } else {
                        TimeUnit.SECONDS.sleep(1);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                    connector.rollback();
                }
            }
        }).start();
    }
}

十、生产环境最佳实践

1. 高可用部署

生产环境建议使用Canal HA模式,基于ZooKeeper实现:

  • 部署多个Canal Server实例
  • 使用ZooKeeper进行协调和failover
  • 客户端自动连接到可用的Server

2. 性能优化

批量消费

java 复制代码
// 一次获取多条消息,提高吞吐量
Message message = connector.getWithoutAck(1000);

并行处理

java 复制代码
ExecutorService executor = Executors.newFixedThreadPool(10);
message.getEntries().forEach(entry -> {
    executor.submit(() -> processEntry(entry));
});

合理设置超时时间

properties 复制代码
canal.instance.network.receiveBufferSize = 16384
canal.instance.network.sendBufferSize = 16384
canal.instance.network.soTimeout = 30

十一、总结

Canal作为阿里开源的MySQL增量数据订阅组件,凭借其高性能、低侵入、易扩展的特点,已经成为数据同步领域的标准解决方案。

适用场景:

  • 数据库实时备份
  • 缓存实时同步
  • 搜索引擎索引构建
  • 实时数据仓库
  • 数据异构迁移

Canal虽然强大,但并非银弹。在实际使用中,需要根据业务场景选择合适的binlog格式、部署模式、消费策略,并做好异常处理和监控,才能真正发挥其价值。


相关推荐
hhzz5 小时前
Activiti7工作流(五)流程操作
java·activiti·工作流引擎·工作流
慧都小项5 小时前
JAVA自动化测试平台Parasoft Jtest 插件Eclipse/IDEA安装教程
java·软件测试·测试工具·eclipse·intellij-idea
running up5 小时前
Spring核心深度解析:AOP与事务管理(TX)全指南
java·数据库·spring
一水鉴天5 小时前
整体设计 定稿 之6 完整设计文档讨论及定稿 之1(豆包周助手)
java·前端·数据库
五阿哥永琪6 小时前
Spring Boot 权限控制三件套:JWT 登录校验 + 拦截器 + AOP 角色注解实战
java·spring boot·python
光算科技6 小时前
商品颜色/尺码选项太多|谷歌爬虫不收录怎么办
java·javascript·爬虫
派大鑫wink6 小时前
分享一些在Spring Boot中进行参数配置的最佳实践
java·spring boot·后端
想学习java初学者6 小时前
SpringBoot整合MQTT多租户(优化版)
java·spring boot·后端
代码栈上的思考6 小时前
MyBatis XML的方式来实现
xml·java·mybatis