Canal 数据同步:MySQL 到 Redis 实时同步方案
作为深耕 Java 后端八年的老兵,我太懂日常开发中「MySQL 与 Redis 数据一致性」的痛点了:手动双写代码冗余还容易漏写、定时任务同步有延迟导致查询错乱、分布式场景下缓存与数据库不一致引发线上故障…… 这些问题轻则影响用户体验,重则导致数据错乱,排查起来耗时耗力。
直到把 Canal 这套实时同步方案用透,才算彻底解放双手 —— 它能模拟 MySQL 主从复制机制,实时捕获数据库变更,再通过客户端将数据同步到 Redis,全程无需侵入业务代码,延迟控制在毫秒级。今天就把这套经过生产验证的方案掰开揉碎,从原理到实战,从配置到踩坑,全是可直接抄作业的干货,新手也能半小时上手!
一、先搞懂:Canal 为什么能实现实时同步?(人话版原理)
很多新手觉得 Canal 很神秘,其实核心原理超简单 ——模拟 MySQL 主从复制过程,一句话讲清流程:
- MySQL 开启 binlog(二进制日志),所有数据变更(增删改)都会记录到 binlog 里;
- Canal 伪装成 MySQL 的从库,向主库发送 dump 请求,获取 binlog 日志;
- Canal 解析 binlog 日志,提取变更数据(表名、操作类型、字段值等),以统一格式发送给客户端;
- 我们写的 Java 客户端监听 Canal 消息,拿到变更数据后同步到 Redis。
对比传统方案的优势一眼看穿:
| 方案 | 延迟 | 侵入性 | 一致性 | 维护成本 |
|---|---|---|---|---|
| 业务代码双写 | 无延迟 | 高 | 差(易漏写) | 高 |
| 定时任务同步 | 分钟级 | 低 | 差(有延迟) | 中 |
| Canal 同步 | 毫秒级 | 无 | 高 | 低 |
一句话总结:Canal 就是「非侵入式、低延迟、高可靠」的 MySQL 数据变更监听工具,天生适配 MySQL→Redis 的实时同步场景。
二、实战部署:从 0 到 1 搭建同步环境(生产级配置)
1. 环境准备(先把基础环境搭好)
| 组件 | 版本要求 | 核心作用 |
|---|---|---|
| MySQL | 5.7+/8.0+ | 源数据库,需开启 binlog |
| Canal | 1.1.7(稳定版) | 解析 binlog,推送变更消息 |
| Redis | 6.0+ | 目标缓存,存储同步后的数据 |
| Java | 8+/11+ | 开发 Canal 客户端,处理同步逻辑 |
| Spring Boot | 2.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 代码关键说明(生产级细节)
-
批量拉取消息:
canalConnector.getWithoutAck(100)每次拉取 100 条,避免频繁网络交互,提升性能; -
Redis Key 设计:采用「db:table:pk=value」格式,确保唯一,比如
test_db:user:id=123,便于后续排查和删除; -
容错机制:
- 异常时不提交 offset,Canal 会重新推送消息;
- 断开连接后自动重连,避免服务中断;
-
线程池设计:用单线程处理,避免并发同步 Redis 时出现数据错乱(如果需要更高吞吐量,可按表名分片,用多线程);
-
过期时间: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 级数据,建议用离线同步工具)。