Canal + RabbitMQ 数据同步实战:从填坑到打通全链路

Canal + RabbitMQ 数据同步实战:从填坑到打通全链路

一次完整的 MySQL 实时数据同步实践,记录从零搭建、版本踩坑到成功打通 Canal + RabbitMQ 全链路的完整过程

一、背景与目标

在微服务架构中,经常需要将 MySQL 的数据变更实时同步到其他系统(如缓存、搜索引擎、数据仓库等)。Canal 作为阿里巴巴开源的 MySQL Binlog 解析组件,是实现这一目标的常用工具。

本文目标是:

  • macOS 本地完成 Canal 的安装与配置
  • 将 Canal 捕获的 MySQL 变更数据投递到 RabbitMQ
  • 为下游消费者提供标准化的 JSON 数据

环境信息:

  • 操作系统:macOS (Apple Silicon)
  • MySQL:8.0.45(官方 .dmg 安装)
  • Canal:1.1.7(TCP 模式)
  • RabbitMQ:4.2.5
  • Java:JDK 17
  • Spring Boot:3.3.4

二、MySQL 准备:开启 Binlog

2.1 创建配置文件

Canal 基于 MySQL 的主从复制协议工作,因此必须开启 binlog。

编辑 /etc/my.cnf

ini 复制代码
[mysqld]
server-id = 1
log-bin = mysql-bin
binlog-format = ROW

2.2 重启 MySQL

由于是官方 .dmg 安装,通过系统设置中的 MySQL 面板重启即可。

2.3 验证 Binlog 是否开启

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

2.4 创建 Canal 专用账号

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

三、Canal 安装与版本踩坑

3.1 安装 Canal

从 GitHub Releases 下载 canal.deployer-1.1.7.tar.gz

bash 复制代码
tar -zxvf canal.deployer-1.1.7.tar.gz -C ~/canal-1.1.7
cd ~/canal-1.1.7

3.2 配置 Canal

conf/canal.properties
properties 复制代码
# 工作模式:先使用 TCP 模式验证核心功能
canal.serverMode = tcp
conf/example/instance.properties
properties 复制代码
# MySQL 连接信息
canal.instance.master.address = 127.0.0.1:3306
canal.instance.dbUsername = canal
canal.instance.dbPassword = canal

# 监听所有表
canal.instance.filter.regex = .*\\..*

3.3 启动 Canal

bash 复制代码
sh bin/startup.sh
tail -f logs/example/example.log

3.4 ⚠️ 版本踩坑:RabbitMQ 直连模式 Bug

在尝试使用 Canal 的 RabbitMQ 直连模式时,遇到了反复出现的错误:

复制代码
PRECONDITION_FAILED - unknown exchange type '1'

这个问题在 Canal 1.1.71.1.8 版本中都存在。无论如何在 canal.properties 中配置 rabbitmq.exchange.type = direct,Canal 内部都固执地使用了无效的整数值(12),导致连接失败。

结论:Canal 1.1.x 系列的 RabbitMQ 直连模式存在无法绕过的缺陷。


四、解决方案:TCP 模式 + Java 客户端中转

4.1 架构调整

放弃 Canal 的 RabbitMQ 直连模式,改用 TCP 模式 + Spring Boot 客户端 方案:

复制代码
MySQL 变更 → Canal (TCP 模式) → Spring Boot 客户端 → RabbitMQ

这种方案的优势:

  • Canal 的 TCP 模式是其最成熟、最稳定的工作方式
  • 客户端可以对数据进行精细化处理
  • 完全绕开 Canal 的 MQ 连接 Bug
  • 获得对数据流的绝对控制权

4.2 客户端项目结构

复制代码
Canal-Java-Client/
├── pom.xml
└── src/main/java/com/dp/canaljavaclient/
    ├── CanalJavaClientApplication.java
    ├── config/
    │   ├── CanalConfig.java
    │   └── RabbitMQConfig.java
    ├── service/
    │   └── CanalConsumerService.java
    └── resources/
        └── application.yml

4.3 关键依赖

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.4</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba.otter</groupId>
        <artifactId>canal.client</artifactId>
        <version>1.1.7</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba.fastjson2</groupId>
        <artifactId>fastjson2</artifactId>
        <version>2.0.53</version>
    </dependency>
</dependencies>

4.4 核心服务实现

Canal 配置类
java 复制代码
@Data
@Component
@ConfigurationProperties(prefix = "canal")
public class CanalConfig {
    private String host = "127.0.0.1";
    private int port = 11111;
    private String destination = "example";
    private String username = "";
    private String password = "";
    private int batchSize = 100;
    private String filterRegex = ".*\\..*";
    private long idleSleepMs = 1000;
}
Canal 消费者服务(核心)
java 复制代码
@Slf4j
@Service
@RequiredArgsConstructor
public class CanalConsumerService {

    private final CanalConfig canalConfig;
    private final RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void init() {
        startConsumer();
    }

    private void startConsumer() {
        Thread consumerThread = new Thread(() -> {
            try {
                CanalConnector connector = CanalConnectors.newSingleConnector(
                    new InetSocketAddress(canalConfig.getHost(), canalConfig.getPort()),
                    canalConfig.getDestination(),
                    canalConfig.getUsername(),
                    canalConfig.getPassword()
                );
                connector.connect();
                connector.subscribe(canalConfig.getFilterRegex());
                connector.rollback();

                while (running) {
                    Message message = connector.getWithoutAck(canalConfig.getBatchSize());
                    if (message.getId() == -1) {
                        Thread.sleep(canalConfig.getIdleSleepMs());
                        continue;
                    }
                    handleMessage(message);
                    connector.ack(message.getId());
                }
            } catch (Exception e) {
                log.error("Canal 消费者异常", e);
            }
        });
        consumerThread.setDaemon(true);
        consumerThread.start();
    }

    private void handleMessage(Message message) {
        for (CanalEntry.Entry entry : message.getEntries()) {
            if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) continue;

            CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
            if (rowChange.getIsDdl()) continue;

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

            // 提取列数据,避免序列化整个 RowChange 对象
            List<Map<String, Object>> rowDataList = new ArrayList<>();
            for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                Map<String, Object> rowMap = new LinkedHashMap<>();
                for (CanalEntry.Column column : rowData.getAfterColumnsList()) {
                    rowMap.put(column.getName(), column.getValue());
                }
                rowDataList.add(rowMap);
            }

            // 构造简洁的消息体
            Map<String, Object> simpleMessage = new LinkedHashMap<>();
            simpleMessage.put("schema", schemaName);
            simpleMessage.put("table", tableName);
            simpleMessage.put("event", eventType);
            simpleMessage.put("timestamp", System.currentTimeMillis());
            simpleMessage.put("data", rowDataList);

            String jsonMessage = JSON.toJSONString(simpleMessage);
            rabbitTemplate.convertAndSend("canal.exchange", "canal.routingkey", jsonMessage);

            log.info("✅ 已转发: {}.{} -> {}, 数据行数: {}", schemaName, tableName, eventType, rowDataList.size());
        }
    }
}
RabbitMQ 配置
java 复制代码
@Configuration
public class RabbitMQConfig {

    public static final String EXCHANGE_NAME = "canal.exchange";
    public static final String QUEUE_NAME = "canal.queue";
    public static final String ROUTING_KEY = "canal.routingkey";

    @Bean
    public TopicExchange canalExchange() {
        return new TopicExchange(EXCHANGE_NAME, true, false);
    }

    @Bean
    public Queue canalQueue() {
        return new Queue(QUEUE_NAME, true);
    }

    @Bean
    public Binding canalBinding() {
        return BindingBuilder.bind(canalQueue())
                .to(canalExchange())
                .with(ROUTING_KEY);
    }
}

4.5 ⚠️ 踩坑:Fastjson2 序列化错误

在处理数据时遇到了 level too large: 2048 的序列化错误,原因是 CanalEntry.RowChange 对象内部存在深层嵌套结构。

解决方案 :不直接序列化整个 RowChange 对象,而是手动提取需要的 afterColumns 字段,构造简洁的 JSON 消息。

4.6 配置文件

yaml 复制代码
spring:
  application:
    name: Canal-Java-Client
  rabbitmq:
    host: 127.0.0.1
    port: 5672
    username: guest
    password: guest
    virtual-host: /

Canal:
  host: 127.0.0.1
  port: 11111
  destination: example
  batchSize: 100
  filterRegex: ".*\\..*"
  idleSleepMs: 1000

logging:
  level:
    com.dp.canaljavaclient: DEBUG

五、验证与测试

5.1 启动流程

bash 复制代码
# 1. 启动 Canal(TCP 模式)
cd ~/canal-1.1.7
sh bin/startup.sh

# 2. 启动 Spring Boot 客户端
java -jar target/canal-java-client-0.0.1-SNAPSHOT.jar

# 3. 在 MySQL 中执行变更
INSERT INTO feng.user (name, age, email) VALUES ('test', 25, 'test@example.com');

5.2 查看结果

客户端日志:

复制代码
✅ 已转发: feng.user -> INSERT, 数据行数: 1

RabbitMQ 管理界面(http://localhost:15672):

复制代码
队列: canal.queue
Ready: 1

消息内容:

json 复制代码
{
    "schema": "feng",
    "table": "user",
    "event": "INSERT",
    "timestamp": 1782295977691,
    "data": [
        {
            "id": "4",
            "name": "test",
            "age": "25",
            "email": "test@example.com"
        }
    ]
}

六、成功经验总结

6.1 核心方法论:区分"捷径"与"正道"

方案 结果 教训
Canal RabbitMQ 直连模式 ❌ 失败 存在无法绕过的 Bug,果断放弃
Canal TCP 模式 + 客户端 ✅ 成功 官方最稳定的工作方式

启示:当组件的外围功能存在长期 Bug 时,果断使用其核心功能 + 自己编写适配层,往往能获得更强的稳定性和控制权。

6.2 关键决策

  1. 先验证核心链路:第一时间用 TCP 模式验证 Canal 的 binlog 解析功能是否正常
  2. 自主构建客户端:编写轻量级 Spring Boot 客户端作为 Canal 和 RabbitMQ 之间的桥梁
  3. 精细化数据处理:手动提取字段,避免序列化深层嵌套对象
  4. 使用固定的 Routing Key:简化绑定关系,确保消息正确路由

6.3 最终架构

复制代码
┌─────────────┐     ┌─────────────┐     ┌─────────────────────┐     ┌─────────────┐
│   MySQL     │────▶│   Canal     │────▶│  Spring Boot 3      │────▶│  RabbitMQ   │
│   Binlog    │     │   TCP 模式   │     │  + JDK 17 客户端    │     │  Queue      │
│   feng.user │     │   11111端口 │     │  拉取 → 转换 → 转发  │     │  Ready: 1   │
└─────────────┘     └─────────────┘     └─────────────────────┘     └─────────────┘
                                                                           │
                                                                           ▼
                                                                    ┌─────────────┐
                                                                    │  下游消费者  │
                                                                    │  (待扩展)   │
                                                                    └─────────────┘

七、扩展建议

7.1 多表路由

java 复制代码
// 按表名路由到不同队列
if ("user".equals(tableName)) {
    rabbitTemplate.convertAndSend("canal.exchange", "user.queue", jsonMessage);
} else if ("order".equals(tableName)) {
    rabbitTemplate.convertAndSend("canal.exchange", "order.queue", jsonMessage);
}

7.2 下游消费者

java 复制代码
@Component
@Slf4j
public class CanalMessageConsumer {
    @RabbitListener(queues = "canal.queue")
    public void handle(String message) {
        log.info("收到消息: {}", message);
        // 更新缓存、同步 ES、触发通知...
    }
}

测试:

http://localhost:15672/#/queues/%2F/canal.queue

八、结语

本文完整记录了从零搭建 Canal + RabbitMQ 数据同步管道的过程,重点分享了:

  1. 版本踩坑经验:Canal 1.1.x 的 RabbitMQ 直连模式存在 bug
  2. 稳健的替代方案:TCP 模式 + Spring Boot 客户端
  3. 实际问题解决:序列化异常、消息路由、配置优化

这套方案已在本地环境完全验证通过,可作为微服务架构中数据同步的基础设施。


项目源码地址https://github.com/lvan-jone/canal-java-client.git

欢迎交流讨论! 😊