Canal 数据同步:MySQL 到 Redis 实时同步方案

88 阅读11分钟

Canal 数据同步:MySQL 到 Redis 实时同步方案

作为深耕 Java 后端八年的老兵,我太懂日常开发中「MySQL 与 Redis 数据一致性」的痛点了:手动双写代码冗余还容易漏写、定时任务同步有延迟导致查询错乱、分布式场景下缓存与数据库不一致引发线上故障…… 这些问题轻则影响用户体验,重则导致数据错乱,排查起来耗时耗力。

直到把 Canal 这套实时同步方案用透,才算彻底解放双手 —— 它能模拟 MySQL 主从复制机制,实时捕获数据库变更,再通过客户端将数据同步到 Redis,全程无需侵入业务代码,延迟控制在毫秒级。今天就把这套经过生产验证的方案掰开揉碎,从原理到实战,从配置到踩坑,全是可直接抄作业的干货,新手也能半小时上手!

一、先搞懂:Canal 为什么能实现实时同步?(人话版原理)

很多新手觉得 Canal 很神秘,其实核心原理超简单 ——模拟 MySQL 主从复制过程,一句话讲清流程:

  1. MySQL 开启 binlog(二进制日志),所有数据变更(增删改)都会记录到 binlog 里;
  2. Canal 伪装成 MySQL 的从库,向主库发送 dump 请求,获取 binlog 日志;
  3. Canal 解析 binlog 日志,提取变更数据(表名、操作类型、字段值等),以统一格式发送给客户端;
  4. 我们写的 Java 客户端监听 Canal 消息,拿到变更数据后同步到 Redis。

对比传统方案的优势一眼看穿:

方案延迟侵入性一致性维护成本
业务代码双写无延迟差(易漏写)
定时任务同步分钟级差(有延迟)
Canal 同步毫秒级

一句话总结:Canal 就是「非侵入式、低延迟、高可靠」的 MySQL 数据变更监听工具,天生适配 MySQL→Redis 的实时同步场景。

二、实战部署:从 0 到 1 搭建同步环境(生产级配置)

1. 环境准备(先把基础环境搭好)

组件版本要求核心作用
MySQL5.7+/8.0+源数据库,需开启 binlog
Canal1.1.7(稳定版)解析 binlog,推送变更消息
Redis6.0+目标缓存,存储同步后的数据
Java8+/11+开发 Canal 客户端,处理同步逻辑
Spring Boot2.7.x(推荐)快速搭建 Java 客户端

注意:生产环境建议用稳定版 Canal(1.1.7 或 1.1.8),避免用最新版踩坑;MySQL 必须开启 binlog,且格式为 ROW 模式(否则 Canal 无法解析具体数据)。

2. 第一步:MySQL 开启 binlog(关键!)

Canal 的核心依赖 MySQL 的 binlog,这一步配置错了,后面全白搭:

2.1 修改 MySQL 配置文件(my.cnf 或 my.ini)
# 开启binlog
log_bin = /var/lib/mysql/mysql-bin
# binlog格式:必须是ROW模式(记录每行数据的变更,Canal才能解析)
binlog_format = ROW
# 服务器ID(主从复制必备,唯一,1-2^32-1)
server_id = 1
# 只记录需要同步的数据库(减少binlog体积,可选)
binlog_do_db = test_db  # 你的业务数据库名
# 不记录的数据库(可选)
binlog_ignore_db = mysql
binlog_ignore_db = information_schema
# binlog过期时间(避免磁盘满,7天)
expire_logs_days = 7
2.2 重启 MySQL 并验证
-- 重启MySQL(CentOS示例)
systemctl restart mysqld

-- 登录MySQL,验证配置是否生效
mysql -u root -p
show variables like 'log_bin'; -- 结果为ON表示开启成功
show variables like 'binlog_format'; -- 结果为ROW表示格式正确
show variables like 'server_id'; -- 结果为1(和配置一致)
2.3 创建 Canal 专属 MySQL 用户(最小权限原则)

Canal 需要读取 binlog 和元数据,创建一个只读用户即可,避免用 root:

-- 创建用户(用户名:canal,密码:Canal@123456)
CREATE USER 'canal'@'%' IDENTIFIED BY 'Canal@123456';
-- 授权(复制权限+查询权限)
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
-- 刷新权限
FLUSH PRIVILEGES;

3. 第二步:部署 Canal Server(核心中间件)

Canal Server 负责连接 MySQL、解析 binlog,部署超简单,推荐用 Docker(避免环境依赖):

3.1 Docker 部署 Canal Server
# 1. 拉取Canal镜像(稳定版1.1.7)
docker pull canal/canal-server:v1.1.7

# 2. 启动容器(关键配置通过环境变量传递)
docker run -d \
  --name canal-server \
  -p 11111:11111 \  # Canal默认端口
  -e canal.instance.master.address=172.31.64.10:3306 \  # 你的MySQL地址
  -e canal.instance.dbUsername=canal \  # 刚才创建的MySQL用户
  -e canal.instance.dbPassword=Canal@123456 \  # 密码
  -e canal.instance.connectionCharset=UTF-8 \  # 字符集
  -e canal.instance.tsdb.enable=true \  # 开启日志记录(便于排查)
  -e canal.instance.gtidon=false \  # 关闭GTID(默认,复杂场景再开启)
  -e canal.instance.filter.regex=test_db\..* \  # 同步规则:test_db库下所有表
  canal/canal-server:v1.1.7
3.2 关键配置说明(避免踩坑)
  • canal.instance.filter.regex:同步规则,格式为「数据库名。表名」,支持通配符:

    • 同步单个表:test_db.user
    • 同步多个表:test_db.user,test_db.order
    • 同步整个库:test_db\..*(注意转义符)
  • 如果需要同步多个数据库 / 表,建议用配置文件挂载(Docker -v 参数),比环境变量更灵活:

    # 本地创建Canal配置文件 canal.properties 和 instance.properties
    docker run -d \
      --name canal-server \
      -p 11111:11111 \
      -v /usr/local/canal/conf:/home/admin/canal-server/conf \  # 挂载配置目录
      canal/canal-server:v1.1.7
    
3.3 验证 Canal Server 是否启动成功
# 查看容器日志
docker logs -f canal-server

# 成功标志:日志中出现 "start successful"
2024-05-20 10:00:00.000 [main] INFO  com.alibaba.otter.canal.deployer.CanalLauncher - ## start the canal server.
2024-05-20 10:00:01.234 [main] INFO  com.alibaba.otter.canal.deployer.CanalLauncher - ## canal server is running now ......

4. 第三步:开发 Java 客户端(核心:监听 Canal + 同步 Redis)

Canal Server 解析完 binlog 后,会把变更消息推给客户端,我们需要写一个 Java 客户端监听消息,然后同步到 Redis。推荐用 Spring Boot,配合 Canal 官方客户端依赖。

4.1 引入依赖(pom.xml)
<!-- Spring Boot核心依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>

<!-- Canal客户端依赖(官方推荐) -->
<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>1.1.7</version>
</dependency>

<!-- Redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- 工具类依赖(简化JSON处理) -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.40</version>
</dependency>
4.2 配置 Redis(application.yml)
spring:
  redis:
    host: 172.31.64.11  # 你的Redis地址
    port: 6379
    password: Redis@123456  # Redis密码(如果有)
    lettuce:
      pool:
        max-active: 100  # 最大连接数
        max-idle: 20     # 最大空闲连接
        min-idle: 5      # 最小空闲连接

# Canal客户端配置
canal:
  server: 172.31.64.12:11111  # Canal Server地址
  destination: example        # Canal实例名(默认example,和Server配置一致)
  username: ''                # Canal用户名(默认空)
  password: ''                # Canal密码(默认空)
4.3 核心代码:Canal 客户端监听 + Redis 同步
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.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Slf4j
@Component
public class CanalRedisSyncClient {
    // Canal连接参数
    @Value("${canal.server}")
    private String canalServer;
    @Value("${canal.destination}")
    private String destination;
    @Value("${canal.username}")
    private String canalUsername;
    @Value("${canal.password}")
    private String canalPassword;

    // Redis模板
    @Autowired
    private StringRedisTemplate redisTemplate;

    // 线程池(单线程足够,避免并发同步冲突)
    private final ExecutorService executorService = Executors.newSingleThreadExecutor();
    // Canal连接器
    private CanalConnector canalConnector;

    // 项目启动后初始化Canal连接并开始监听
    @PostConstruct
    public void init() {
        // 1. 创建Canal连接器
        canalConnector = CanalConnectors.newSingleConnector(
                new InetSocketAddress(canalServer.split(":")[0], Integer.parseInt(canalServer.split(":")[1])),
                destination, canalUsername, canalPassword);

        // 2. 启动监听线程
        executorService.submit(this::startListen);
        log.info("Canal客户端初始化成功,开始监听Canal Server:{}", canalServer);
    }

    // 监听Canal消息
    private void startListen() {
        try {
            // 连接Canal Server
            canalConnector.connect();
            // 订阅所有表(和Canal Server的filter.regex一致)
            canalConnector.subscribe(".*\..*");
            // 回滚到上次同步的位置(避免重复消费)
            canalConnector.rollback();

            // 循环监听消息
            while (!Thread.currentThread().isInterrupted()) {
                // 每次拉取100条消息(批量处理,提升性能)
                Message message = canalConnector.getWithoutAck(100);
                long batchId = message.getId();
                int size = message.getEntries().size();

                if (batchId == -1 || size == 0) {
                    // 没有消息,休眠100ms再拉取(避免空轮询消耗CPU)
                    Thread.sleep(100);
                    continue;
                }

                // 处理消息(同步到Redis)
                handleMessage(message.getEntries());

                // 确认消息已处理(提交offset,避免重复消费)
                canalConnector.ack(batchId);
                log.info("处理完成,提交batchId:{},消息条数:{}", batchId, size);
            }
        } catch (Exception e) {
            log.error("Canal监听消息异常", e);
            // 异常时重连(容错机制)
            reconnect();
        }
    }

    // 处理Canal消息,同步到Redis
    private void handleMessage(List<CanalEntry.Entry> entries) {
        for (CanalEntry.Entry entry : entries) {
            // 只处理数据变更消息(过滤事务开始/结束等消息)
            if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
                continue;
            }

            try {
                // 解析binlog日志
                CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                CanalEntry.EventType eventType = rowChange.getEventType(); // 操作类型:INSERT/UPDATE/DELETE
                String tableName = entry.getHeader().getTableName(); // 变更的表名
                String dbName = entry.getHeader().getSchemaName(); // 变更的数据库名

                log.info("收到数据变更:数据库={},表={},操作类型={}", dbName, tableName, eventType);

                // 处理每行数据的变更
                for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                    // 组装数据(key:表名:主键值,value:JSON格式的字段值)
                    JSONObject dataJson = new JSONObject();
                    String redisKey = buildRedisKey(dbName, tableName, rowData, eventType);

                    if (eventType == CanalEntry.EventType.DELETE) {
                        // 删除操作:删除Redis对应的key
                        redisTemplate.delete(redisKey);
                        log.info("删除Redis key:{}", redisKey);
                    } else {
                        // 插入/更新操作:获取变更后的字段值
                        List<CanalEntry.Column> columns = eventType == CanalEntry.EventType.INSERT 
                                ? rowData.getAfterColumnsList() 
                                : rowData.getAfterColumnsList();

                        for (CanalEntry.Column column : columns) {
                            dataJson.put(column.getName(), column.getValue());
                        }

                        // 同步到Redis(设置过期时间,避免缓存雪崩,根据业务调整)
                        redisTemplate.opsForValue().set(redisKey, dataJson.toJSONString(), 86400, java.util.concurrent.TimeUnit.SECONDS);
                        log.info("同步到Redis:key={},value={}", redisKey, dataJson.toJSONString());
                    }
                }
            } catch (Exception e) {
                log.error("处理数据变更异常,entry={}", entry, e);
                // 异常时不提交offset,Canal会重新推送(容错)
                canalConnector.rollback(entry.getHeader().getBatchId());
            }
        }
    }

    // 构建Redis Key(格式:db:table:pk=value,确保唯一)
    private String buildRedisKey(String dbName, String tableName, CanalEntry.RowData rowData, CanalEntry.EventType eventType) {
        // 获取主键字段(这里假设表的主键是id,实际业务需要根据表动态获取)
        String pkValue = "";
        List<CanalEntry.Column> columns = eventType == CanalEntry.EventType.DELETE 
                ? rowData.getBeforeColumnsList() 
                : rowData.getAfterColumnsList();

        for (CanalEntry.Column column : columns) {
            if (column.getIsKey()) { // 判断是否为主键字段
                pkValue = column.getValue();
                break;
            }
        }

        // 示例:test_db:user:id=123
        return String.format("%s:%s:id=%s", dbName, tableName, pkValue);
    }

    // 重连机制(Canal Server断开后自动重连)
    private void reconnect() {
        try {
            log.info("开始重连Canal Server...");
            canalConnector.disconnect();
            Thread.sleep(5000); // 休眠5秒后重连
            startListen();
        } catch (Exception e) {
            log.error("重连Canal Server失败", e);
            reconnect(); // 重连失败继续重试
        }
    }

    // 项目关闭前关闭Canal连接和线程池
    @PreDestroy
    public void destroy() {
        if (canalConnector != null) {
            canalConnector.disconnect();
        }
        executorService.shutdown();
        log.info("Canal客户端关闭成功");
    }
}
4.4 代码关键说明(生产级细节)
  1. 批量拉取消息canalConnector.getWithoutAck(100) 每次拉取 100 条,避免频繁网络交互,提升性能;

  2. Redis Key 设计:采用「db:table:pk=value」格式,确保唯一,比如 test_db:user:id=123,便于后续排查和删除;

  3. 容错机制

    • 异常时不提交 offset,Canal 会重新推送消息;
    • 断开连接后自动重连,避免服务中断;
  4. 线程池设计:用单线程处理,避免并发同步 Redis 时出现数据错乱(如果需要更高吞吐量,可按表名分片,用多线程);

  5. 过期时间:Redis key 设置过期时间,避免缓存雪崩和内存溢出。

三、生产级优化:从 “能用” 到 “好用” 的关键技巧

1. 幂等性处理(避免重复同步)

Canal 可能会重复推送消息(比如重连后),导致 Redis 数据重复写入,需要做幂等性:

  • 基于主键唯一性:Redis Key 用主键构建,重复写入会覆盖,天然幂等;
  • 乐观锁:如果是更新操作,可在 MySQL 表加version字段,同步时判断版本号,避免旧数据覆盖新数据。

2. 分库分表场景适配

如果 MySQL 是分库分表(比如 Sharding-JDBC),需要:

  • 每个分库部署一个 Canal instance,分别监听;
  • Redis Key 中加入分库标识,比如 test_db_01:user:id=123
  • 客户端按分库分片处理,避免并发冲突。

3. 性能优化(支撑高并发)

  • 批量处理:Canal 拉取批量消息,Redis 用pipeline批量同步(减少网络往返);
  • 异步同步:用线程池异步处理同步逻辑,提升 Canal 消息消费速度;
  • 过滤无效字段:只同步 Redis 需要的字段,减少 JSON 体积和网络传输。

4. 监控告警(生产必备)

  • 监控 Canal 客户端状态:是否连接正常、消息消费延迟、同步失败次数;
  • 监控 Redis 状态:内存使用率、key 数量、同步 QPS;
  • 告警机制:同步失败次数超过阈值(比如 10 次)、Canal 断开连接,触发短信 / 钉钉告警。

四、八年踩坑实录:这些坑千万别踩!

1. 坑 1:MySQL binlog 格式不是 ROW 模式

  • 后果:Canal 无法解析具体数据变更(只能拿到 SQL,拿不到字段值);
  • 解决方案:必须设置 binlog_format=ROW,重启 MySQL 生效。

2. 坑 2:Canal 过滤规则配置错误

  • 场景:配置 canal.instance.filter.regex=test_db.user,但同步不到数据;
  • 原因:正则表达式错误,应该是 test_db\.user(转义符);
  • 解决方案:用正确的正则,或直接同步整个库 test_db\..*

3. 坑 3:Redis Key 设计不合理导致缓存雪崩

  • 场景:所有 Redis Key 同时过期,大量请求穿透到 MySQL;
  • 解决方案:Key 过期时间加随机值(比如 86400±3600 秒),避免集中过期。

4. 坑 4:Canal 客户端重连失败后停止监听

  • 原因:重连逻辑没写好,异常后线程终止;
  • 解决方案:重连方法中递归调用自己,确保持续重试。

5. 坑 5:分库分表场景下主键重复

  • 场景:不同分库的同一张表有相同主键,Redis Key 重复覆盖;
  • 解决方案:Redis Key 中加入分库标识,比如 test_db_01:user:id=123

五、总结:Canal 同步的核心价值与适用场景

作为八年 Java 老兵,我总结 Canal 这套方案的核心价值:非侵入式、低延迟、高可靠,彻底解决了 MySQL 与 Redis 的数据一致性问题,解放了业务代码。

适用场景:

  • 实时缓存同步(比如用户信息、商品信息缓存);
  • 数据异构(MySQL→Redis、MySQL→Elasticsearch);
  • 分布式事务最终一致性保障;
  • 数据备份与监控。

不适用场景:

  • 强一致性要求(比如金融交易,需要用分布式事务);
  • 超大规模数据同步(比如 TB 级数据,建议用离线同步工具)。