这个标题看起来平平无奇,但做过缓存一致性的人都知道,里面全是坑。我先讲个真实场景:某天凌晨两点,线上订单状态显示异常,用户付了款页面还停在"待支付"。查了一圈发现是业务代码里更新完 MySQL 后"顺手"清了一下 Redis 缓存,但清缓存的时候 Redis 超时了,异常被吞掉,缓存里存的还是旧状态。这种问题用双写、用延迟双删都只能缓解,根治的办法是想办法让缓存跟着数据库走,而不是让每个开发都记得写那几行缓存更新代码。
我的解决方案就是今天要聊的主角:Canal,配合 MySQL 的 binlog 做增量订阅,把数据变更实时推出去,再落地到 Redis。这套方案能解决什么?本质上就一句话:数据库里改了数据,Redis 在一秒内自动跟着变,不用业务代码去管缓存。它适合谁?适合所有用了 MySQL + Redis 但还在为缓存一致性头疼的团队,尤其是订单、库存、用户信息这类不能容忍脏数据的场景。下面我按自己实际落地时的思路,完整走一遍。
1. 缓存不一致的根源:别再把脏数据甩给业务代码
1.1 双写与手动清理的"三天两头救火"
大多数团队最初的做法是 Cache Aside Pattern:读的时候先读缓存,读不到读 DB 再回填;写的时候先更新 DB,然后删除缓存。逻辑上没问题,但落地就变味了。最常见的是先更新 DB 再删缓存,网络抖动删缓存失败,或者中间有并发读,删完缓存瞬间有请求把旧数据回填进去了。也有团队选择更新完 DB 直接更新缓存,这更麻烦,一个 UPDATE 语句可能涉及好几个字段的拼装,每个更新点都要写一遍,漏一个就是事故。
我见过最夸张的项目,更新用户昵称这个动作,业务代码里散落着七八处缓存清理逻辑,有的清的是 user:{id} 这个 key,有的清的是 user_profile:{id},还有一处清的是整个 user: 前缀。加需求的人越来越不知道缓存到底有几个 key 依赖这张表,后面的人不敢动,前面的人不敢删。这种模式不是技术问题,是工程管理问题,本质上你把缓存和数据库的一致性责任全压在了业务开发身上。
1.2 binlog 才是数据库变更的"唯一事实来源"
转机在哪?答案其实 MySQL 早就告诉我们了。数据库里每一次 INSERT、UPDATE、DELETE,只要开启了 binlog,都会被记录下来。binlog 是 MySQL 自己的"账本",任何数据变更都有据可查,而且是顺时序的。既然业务代码不靠谱,那就别让业务代码来管缓存,让一个独立的组件盯着这个"账本",账本上记了什么,就原样把变化同步到 Redis。这就是订阅 binlog 的思路,跟 MySQL 主从复制的本质一模一样。
这个思路带来的最大好处是解耦。业务代码只写数据库,缓存的事由独立的同步链路负责。新增一个缓存依赖,不需要改业务代码;某个缓存挂了,只需要重新跑一次同步,不会影响主流程。Canal 就是干这个的中间件,它把自己伪装成 MySQL 的一个从库,让 MySQL 以为它在做复制,实际上它把 binlog 字节流解析成结构化数据,再交给下游消费。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Canal 的底层原理:伪装成从库,把 binlog 变事件流
2.1 它其实就是个"不落地的 MySQL 从库"
理解 Canal 的原理,先理解 MySQL 主从复制。主从复制分三步:主库把变更写入 binlog,从库通过 IO 线程把 binlog 拉到自己这边存成 relay log,再从 relay log 里读取并执行 SQL。Canal 做的事情很巧妙,它只模拟了前两步里的"拉取",但拉了数据之后不解执行,而是解析成事件。所以它不需要真正成为从库,只需要在 MySQL 那边注册一个假的 slave 身份,让 MySQL 觉得"有个从库在消费 binlog",实际上 Canal 拿到 binlog 后就自己处理了。
这一步里有个很容易踩的坑,如果服务器上已经有一个真实的从库在复制,Canal 再注册一个假的 slave,需要确保 server_id 不能和真实从库以及 MySQL 主库本身重复。官方建议 Canal 的 slaveId 设置成一个范围内的随机值,默认配置是 12345,如果线上有多套 Canal 实例,每个实例的 slaveId 也不能一样,不然 MySQL 会踢掉旧的连接。我觉得这个细节特别容易忽略,建议启动前专门检查一遍。
Canal 有两种订阅位点的管理方式:一种是基于 binlog 的文件名加偏移量(position),另一种是基于 GTID。单机部署时默认把位点存在本地 meta.dat 文件里,如果要保证高可用,可以开启 ZK 模式,让多台 Canal Server 共享位点信息。我实际使用中更推荐用 GTID 的方式,因为它天然是全局唯一的,不会因为 binlog rotate 导致位点错乱,排查问题的时候也方便。
2.2 一条 UPDATE 在 Canal 内部经历了什么
从 MySQL 到 Canal Server,再到下游消费者,一条 UPDATE 语句要经过以下几个环节:
第一步,MySQL 写 binlog。 在 ROW 格式下,binlog 会记录"哪一行变了"以及变更前后的值。比如执行 UPDATE users SET nickname='张三' WHERE id=1,binlog 里会记录这一行的主键 id=1,变更前 nickname 是"李四",变更后是"张三"。这里需要强调,如果更新没匹配到任何行,binlog 不会产生记录,所以 Canal 也不会收到事件。
第二步,Canal Server 拉取并解析。 Canal 的解析模块将 binlog 字节流按 MySQL 协议解码,还原成结构化的事件对象,内部叫做 Entry。对一条 row 级别的变更,会生成对应的 RowChange,里面包含事件类型(INSERT、UPDATE、DELETE)和行数据,行数据又分 BeforeColumns 和 AfterColumns 两种,分别对应变更前和变更后的各列值。这些列值还是 Binlog 里的原始格式,比如数字就是数字,字符串就是字节数组。
第三步,消费者 get 事件流。 Canal 封装了 Client 模式,通过 TCP 长连接从 Canal Server 拉取消息。拉取的时候用的是"先拿不确认"的机制,拿到一批 message 后,业务处理完再调用 ack 告诉 Canal 这批消息可以丢弃了。如果业务崩溃没 ack,Canal 会在下次连接时重新推送这批消息。这种机制一旦用了,就要求下游处理必须是幂等的,Redis 的 SET 操作天然满足,重复执行不会有副作用,这也是选 Redis 做同步目标的一个优势。
用 Java 代码看,一条 UPDATE 事件解析后大概是这样的结构:
java复制RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
EventType eventType = rowChange.getEventType();
for (RowData rowData : rowChange.getRowDatasList()) {
List<Column> afterColumns = rowData.getAfterColumnsList();
for (Column column : afterColumns) {
System.out.println(column.getName() + " = " + column.getValue());
}
}
2.3 为什么要强制 ROW 格式 + FULL 镜像
Canal 能不能拿到完整的前后值,取决于 MySQL 的 binlog 格式配置。如果 binlog_format=STATEMENT,binlog 里记的是 SQL 语句本身,Canal 解析出来没有直接的行数据,同步到 Redis 就无从下手。如果 binlog_format=MIXED,MySQL 会根据语句类型自动切换,大部分 DML 还是走 ROW,但不可控。所以做 Canal 同步,第一件事就是把 binlog 格式固定为 ROW。
格式定了,还有一个参数容易被漏掉:binlog_row_image。在 MySQL 5.6 之后这个参数默认是 FULL,意思是 binlog 里记录整行的所有列;如果被调成 MINIMAL,binlog 只记录被修改的列和主键列。从同步效率看 MINIMAL 更省空间,但做缓存同步时,拿不到未修改的其他列值,想刷新整个缓存对象就不方便。比如用户表有 20 个字段,只改了昵称,MINIMAL 模式下 AfterColumns 里可能只有 nickname 和 id,如果你的 Redis 里存了完整的用户对象 JSON,就没办法只靠这次 binlog 事件刷新整个对象。所以我的建议是 binlog_row_image=FULL,多花的磁盘空间远小于排查问题时流的泪。
3. 部署前必须抠的细节:binlog 开关、账号权限与容器化启动
3.1 MySQL 侧的三个关键参数
很多 MySQL 实例默认没有开启 binlog,或者开了但格式不对。我建议在配置文件的 [mysqld] 段里检查下面几个参数:
ini复制server-id = 1
log-bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL
expire_logs_days = 30
其中 server-id 是必须的,MySQL 从 5.7 开始开启 binlog 时如果没有 server-id 会直接报错。log-bin 指定 binlog 文件的前缀,实际生成的文件名会像 mysql-bin.000001 这样递增。expire_logs_days 是 binlog 的保留天数,太小会导致 Canal 追不上时 binlog 被清理,太大占磁盘,一般 7 到 30 天看业务容忍度。MySQL 8.0 里 expire_logs_days 已经被 binlog_expire_logs_seconds 取代,用新版本的时候要注意参数名变了,别照着旧文档配完发现不生效。
改完配置要重启 MySQL 吗?看情况。server-id、log-bin 这类参数必须重启才能生效,但 binlog_format 和 binlog_row_image 可以动态修改:
sql复制SET GLOBAL binlog_format = 'ROW';
SET GLOBAL binlog_row_image = 'FULL';
动态修改的坑在于只对新的 binlog 事件生效,而且 MySQL 重启后会还原成配置文件里的值。所以正式环境还是建议把配置写死在配置文件里,动态修改只是应急手段。
3.2 给 Canal 创建最小权限账号
Canal 要拉取 binlog,但不需要业务表的增删改权限,所以建账号的时候给最小权限就够了:
sql复制CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_pass';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
这里 SELECT 权限用于 Canal 处理某些特殊场景(比如解析 DDL 时需要读取表结构),REPLICATION SLAVE 和 REPLICATION CLIENT 是拉取 binlog 的核心权限,缺一不可。如果缺了 REPLICATION SLAVE,Canal 启动时会出现 Access denied for user 'canal' 的错,排查半天还以为是密码问题。
这里必须单独提醒一个高频坑,MySQL 8.0 默认的认证插件是 caching_sha2_password,早期版本的 Canal 根本连不上,报错信息是 Client does not support authentication protocol requested by server。遇到这个问题,要么升级 Canal 版本到支持该插件的版本,要么在创建用户时强制指定老插件:
sql复制CREATE USER 'canal'@'%' IDENTIFIED WITH mysql_native_password BY 'canal_pass';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
用 mysql_native_password 兼容性最好,但要注意这是 MySQL 8.0 里已经被标记为废弃的认证插件,如果公司有安全合规要求,优先选支持 caching_sha2_password 的新版 Canal。
3.3 用 Docker 十秒拉起 Canal Server
Canal Server 的部署最简单的方式是用官方镜像。先拉镜像,再写一个 instance 配置文件。我习惯把配置分两层:canal.properties 是全局配置,instance.properties 是单个数据通道的配置。一个 Canal Server 可以管理多个 instance,每个 instance 对应一个 MySQL 实例或一个库。
用 Docker 启动前,先把 instance 配置准备好:
properties复制canal.instance.mysql.slaveId=12345
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal_pass
canal.instance.connectionCharset=UTF-8
canal.instance.filter.regex=test_db\\..*
canal.instance.defaultDatabaseName=test_db
filter.regex 是表过滤规则,语法是 库名.表名,多个规则用逗号分隔,比如 test_db\\.users,test_db\\.orders。注意点号在正则里要转义成 \\.,配置文件里本身是 Java 字符串,所以写的时候要双反斜杠。这一块特别容易配错,配错了的表现是 Canal 能连上 MySQL,但下游一直收不到数据,因为事件在源头就被过滤掉了。
然后启动容器:
bash复制docker run -d --name canal-server \
-p 11111:11111 \
-p 11110:11110 \
-v /opt/canal/conf:/admin/canal-server/conf \
canal/canal-server:v1.1.7
11111 是 Canal Server 与 Client 通信的端口,11110 是管理口。记住改完配置文件要重启容器,Canal 不会热加载 instance 配置。
3.4 验证链路是否真正打通
启动之后别急着写代码,先确认链路通没通。最简单的验证方式是看日志。Canal 启动后如果正常连上 MySQL,日志里会出现"start successful"这类的字样。如果要确认是不是真的订阅到了 binlog 事件,可以在 MySQL 里手动执行一条插入语句,然后看 Canal Server 日志有没有输出解析到的内容。
另一个办法是用 Canal 自带的命令行工具。canal.adapter 或早期版本里有个 canalClient 示例,可以直接从 Canal Server 拉取 message 并打印。我经常用这个做冒烟测试,一次就能确认是 MySQL 配置问题还是 Canal 配置问题:
java复制CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("127.0.0.1", 11111), "example", "", "");
connector.connect();
connector.subscribe("test_db\\.users");
Message message = connector.getWithoutAck(100);
如果这里能拉到数据,说明 MySQL 到 Canal Server 这一段是通的,后面要做的就是怎么消费并写入 Redis 了。
4. 真正把数据写进 Redis:两条路线的选型与落地
4.1 路线对比:自研 Client 和 Canal Adapter 各适合谁
数据从 Canal Server 出来后,有两条主流路线。一条是用 Canal 官方提供的 Client API 自己写消费程序,另一条是用 Canal Adapter 这个现成的同步组件,通过配置映射规则把数据直接写进 Redis。
两者怎么选,我直接说结论:如果你对代码可控性和灵活性要求高,比如要拼接复杂的 Redis key、要同时更新多个缓存结构、要加业务过滤逻辑,就自研 Client。如果你只是做简单的"表数据整行映射到 Redis"的同步,比如把 user 表每行变成一个 Redis String,value 是整行 JSON,用 Adapter 配置一下就行,半小时能搞定。
我做过的项目大多选了自研 Client,原因是团队对 Redis 的 value 结构有很强的定制要求,比如用户信息要拆成 Hash 存,某个字段更新时只更新 Hash 里的对应 field,而不是整个覆盖。这种需求 Adapter 虽然也能做,但配置起来费劲,不如直接用代码控制。
| 对比项 | 自研 Client | Canal Adapter |
|---|---|---|
| 灵活性 | 高,所有逻辑代码控制 | 低,依赖映射规则能力 |
| 上手成本 | 需要写代码 | 改配置文件 |
| 维护成本 | 需要自己维护消费程序 | 官方组件,但配置规则有学习成本 |
| 适合场景 | 需要定制 Redis 结构/拼接 key | 简单整行同步、快速验证 |
4.2 自研 Consumer:核心代码骨架与 Redis 命令组装
自研 Client 的核心逻辑就是三件事:连接 Canal Server、拉取消息、遍历事件并组装 Redis 命令。下面是一个精简版的骨架,基于 Java 和 Canal Client 1.1.x:
java复制CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("127.0.0.1", 11111), "example", "", "");
connector.connect();
connector.subscribe("test_db\\.users,test_db\\.orders");
while (true) {
Message message = connector.getWithoutAck(1000); // 每次最多拉 1000 条
long batchId = message.getId();
if (batchId == -1 || message.getEntries().isEmpty()) {
Thread.sleep(200);
continue;
}
for (Entry entry : message.getEntries()) {
if (entry.getEntryType() != EntryType.ROWDATA) {
continue;
}
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
String tableName = entry.getHeader().getTableName();
if ("users".equals(tableName)) {
handleUsers(rowChange);
} else if ("orders".equals(tableName)) {
handleOrders(rowChange);
}
}
connector.ack(batchId);
}
处理单表的逻辑,以 user 表为例。我建议提前想清楚 Redis 里缓存的价值结构,有两种选择:String 存整行 JSON,key 是 user:{id};或者 Hash 存字段,key 是 user:{id},field 是列名,value 是列值。前者简单,查出来反序列化就能用;后者可以只更新单个字段,节省带宽。我这边给一个 String + JSON 的示例:
java复制private void handleUsers(RowChange rowChange) {
for (RowData rowData : rowChange.getRowDatasList()) {
Map<String, Object> afterMap = columnsToMap(rowData.getAfterColumnsList());
String userId = String.valueOf(afterMap.get("id"));
String redisKey = "user:" + userId;
if (rowChange.getEventType() == EventType.DELETE) {
jedis.del(redisKey);
} else {
String json = JSON.toJSONString(afterMap);
jedis.set(redisKey, json);
}
}
}
这段代码里有个容易被忽略的点:afterMap 里包含所有列,如果某个列是二进制或者大文本,直接转 JSON 进 Redis 会非常浪费内存。所以真正投产时,我会在 columnsToMap 里加一个白名单,只保留需要的字段。另外,DELETE 事件只有 BeforeColumns,AfterColumns 是空的,做删除缓存时要用 Before 里的主键值去拼 key,不要在删除事件里取 after。
如果你用 Python 团队,其实处理套路一模一样,只是换了一个 Canal Client 库。我自己在内部工具里试过用 Python 写,binlog 解析和消息拉取都是走 Canal 的 TCP 协议,核心逻辑就是连接、订阅、循环、拼 Redis 命令,语言不影响思路。
4.3 用 Canal Adapter 快速落地(附配置要点)
如果不打算写代码,Canal Adapter 是个不错的选择。它本身是一个独立运行的服务,从 Canal Server 订阅数据,再按照你配置的映射规则写进 Redis。我用 Adapter 做过快速原型,记录几个配置要点。
Adapter 的 Redis 同步配置文件里,需要指定数据源、目标 Canal 服务、Redis 连接信息,以及具体的表映射规则。表映射规则是核心,它决定了一张表变更后操作哪个 Redis key。以 user 表为例,配置思路大致是这样的:
yaml复制dataSourceKey: defaultDS
destination: example
outerAdapterKey: redis
redis:
host: 127.0.0.1
port: 6379
database: 0
mirrorDb:
tables:
- tableName: users
key: id
value: all
# 或者用 sql 模式,自定义查询语句把数据转成目标结构
我没法给一个能直接照抄的完整配置,因为 Canal Adapter 的配置格式在 1.1.4 到 1.1.7 之间改过好几次。这里只能给一个思路:先明确你用的是哪个版本,再去翻对应版本的官方配置文档。我给这个"版本差异"单独标出来,是因为我在 1.1.5 版本上写好的配置文件,升级到 1.1.7 之后因为格式调整整个启动失败,这种坑纯浪费时间。
Adapter 最大的优点是省事,最大的缺点是出了奇怪问题时不好排查。它内部把 binlog 事件做了好几层转换,一旦映射结果和预期不符,你需要层层去查它日志里的原始事件,还得去理解它内部的转换规则。如果你只同步几张表,可以试试 Adapter;如果以后要同步几十张表,建议早点走自研路线,后面维护成本更低。
4.4 存量数据与增量数据的衔接
Canal 只解决增量问题,Redis 里一开始是空的,线上存量数据怎么进去?这是很多第一次做同步的人最容易卡住的地方。
标准做法是分两步:先用全量同步工具把当前 MySQL 数据导入 Redis,然后立即启动 Canal 增量同步。但这两个操作之间有一个时间窗口,比如先导全量数据,导完发现用户表又新增了 10 条数据,这时候启动 Canal,从当前位点开始订阅,那 10 条增量会被正常消费,覆盖掉 Redis 里可能已经过期的旧数据,因为 set 是幂等覆盖,所以问题不大。反过来,如果先启动 Canal 再导全量,就可能出现全量导出的旧数据覆盖掉 Canal 刚写入的新数据,这才是真的坑。
所以我的建议顺序是:先启动 Canal 并从当前位点开始监听,再执行全量导数据。这样即使全量导出的数据有点旧,也会被后面 Canal 的增量事件修正。全量导数据最简单的方式就是写一个一次性脚本,把 MySQL 数据查出来,拼好 Redis key,批量 pipeline 写进去。一次同步一张表,别忘了 Redis 的过期时间要按照业务设计设好,否则缓存永远不过期,MySQL 变更导致的删除事件也清不掉它。
5. 线上最常踩的坑:类型、主键、延迟与故障续跑
5.1 Long 精度丢失与时间字段反序列化
这套链路里最容易出事故的是类型问题。MySQL 的 bigint 在 Java 里对应 Long,但如果 Redis 里存的是 JSON,前端拿到数字后 JavaScript 的 Number 精度不够,超过 2^53 的 id 就会四舍五入变了个数。我遇到过用户 id 是雪花算法生成的 19 位 bigint,前端用这个 id 去查详情,查出来另一条数据。解决方法是序列化成 JSON 时把 bigint 字段转成字符串,或者尽可能避免把超长 id 直接暴露给前端。
第二个容易出问题的是时间字段。MySQL 的 timestamp 和 datetime 在 binlog 里的表现不同,timestamp 存的是 UTC 时间,Canal 解析时如果时区没配对,同步到 Redis 后的时间会差 8 个小时。Canal 的 instance 配置里有个 canal.instance.connectionCharset,只影响字符集,不影响时区。处理时间字段更安全的做法是在 SQL 映射层面就把时间转成字符串,或者存时间戳数字,不要在链路里依赖默认时区。
5.2 主键更新、批量 UPDATE 和 DELETE 的处理
表设计时通常会约定主键不可变,但 MySQL 并不强制,总有人手滑执行了 UPDATE users SET id=100 WHERE id=1。在 ROW 格式下,这条变更的 Before 记录 id=1,After 记录 id=100,Canal 会作为一个 UPDATE 事件推给下游。如果同步逻辑只看了 After columns 拿着 id=100 去写缓存,那 Redis 里 user:1 这条旧缓存永远不会被删,下次访问 uid=1 就会读到脏数据。处理方案是在 UPDATE 事件里对比 Before 和 After 的主键,如果不一致,先删旧 key 再写新 key。
批量 UPDATE 也要注意。一条 UPDATE users SET status=1 WHERE status=0 更新了 1000 行,binlog 会产生 1000 条 row 事件。如果 Redis 操作是逐条同步执行的,就相当于串行写了 1000 次 Redis,延迟会明显上升。这时候要充分利用批量,把这一批事件先收集起来,过滤出最终要写的 key 集合——如果对同一 key 有多条变更,只需要保留最后一条,然后一次性 pipeline 写到 Redis,性能能提升一个数量级。
DELETE 事件的处理也有讲究。如果你的缓存 key 用的是联合字段,比如 order:{userId}:{orderId},删除事件里只有 Before columns,可以从 Before 里拿 userId 和 orderId 拼出 key。但如果删数据的时候没有记录完整的主键信息,比如 Before 里只有 id,而 key 需要 userId,那就必须做一次反查。我建议在业务上约束:凡是参与 Redis key 拼装的字段,永远不允许更新和置空,否则这条同步链路的纠错成本极高。
5.3 消费延迟治理:批量、并行与 Pipeline
Canal 本身的数据投递延迟很低,我实测在局域网内一般几十毫秒就能把变更推到 Client。真正让延迟变大的是下游消费逻辑太慢。最常见的瓶颈是逐条操作 Redis,一个事务更新 1000 行,Redis 就要 1000 个 RTT,每 RTT 按 0.5 毫秒算也要 500 毫秒,性能一下子就上去了。
治理手段有三个层次。第一层是在 Canal Client 里增大 getWithoutAck 的 batch 大小,我一般配 1000 条。第二层是消费端用 pipeline 批量写 Redis,Java 里可以这样:
java复制Pipeline pipeline = jedis.pipelined();
for (String command : commands) {
pipeline.set(command.getKey(), command.getValue());
}
pipeline.sync();
第三层是按表维度或按 key 分片做并行消费。但并行时要非常小心顺序问题,同一行数据的变更事件如果被两个线程并发处理,可能出现旧值后写覆盖新值先写的情况。所以并行只适合不同 key 之间并行,同一个 key 必须串行。我建议建立"按主键取模分组"的消费者模型,同一个主键永远进同一个线程,这样既并行又不乱序。
延迟监控的问题,可以定期对比"当前时间"和"最新一条 binlog 事件里记录的执行时间",差值就是消费延迟。我在线上用这个指标做告警,超过 5 秒就预警,效果很直接。
5.4 幂等机制与宕机恢复:ack、位点与 binlog 过期
Canal Client 的 ack 机制前面提过,这里展开说坑。先 getWithoutAck 取数据,业务处理成功后再 ack。如果处理失败但没有 ack,Canal 在持有该 batch 期间不会重新投递;但如果 Client 进程直接崩溃,Canal 会在下次客户端重连后把未 ack 的数据重新投递。这就意味着下游处理函数必须做到幂等。Redis 的 SET 是天然幂等的,DELETE 也是,但如果你在消费逻辑里做了"先读出来再改再写回"这种操作,就不是幂等的了,两个重复事件可能互相覆盖。我的经验是同步 Redis 的代码里永远不要做"读改写",只做基于事件的覆盖或删除。
还有更隐蔽的坑:Canal Server 自己保存位点。默认单机模式把位点存在本地 meta.dat 里,如果 Canal Server 所在的容器被删了,或者磁盘坏了,位点就丢了。重启后 Canal 会重新从头开始消费 binlog,如果 binlog 已经被 MySQL 清理,就会报找不到位点的错误,此时只能重置位点重新全量同步。要避免这种情况,一是把 binlog 保留时间调长一点,二是对 Canal Server 做持久化存储,或者直接上 ZK 保存位点。
5.5 DDL 事件的处理策略
同步链路里最容易被忽略的是 DDL。有人改了表结构,加了一列,如果消费端只处理 ROWDATA 事件,DDL 事件被直接 continue 跳过,那 Redis 里的数据还是旧结构,等下次行变更时 After columns 里可能已经带上了新列,但 Redis 的 key 里的旧 JSON 没有对应字段,前端就出现字段缺失。
Canal 会把 DDL 也作为一个 Entry 推送下来,entry.getEntryType() 不是 ROWDATA,而是对应的事件类型。生产环境里我的做法是:建一个 DDL 通知通道,检测到感兴趣的表的 DDL 事件后,给群里发一条告警,由专人确认是否需要重新同步。另外,很多 DDL 执行期间 MySQL 会短暂锁表,binlog 里对应时间点的行变更事件可能会堆积,等 DDL 完成后一次性续传,这也会造成短暂的缓存延迟,要心里有数。
6. 如果让我再来一遍,我会怎么设计这套同步
写到最后,说点真实的个人体会。整个同步链路跑通不难,难的是让它在线上稳定运行。如果我现在要在一个新项目里再落一遍这套方案,我会在动手前做这几个决定:
第一,只同步核心热数据表。不要因为"反正 Canal 都接入了"就把所有表都同步到 Redis,同步的表越多,消费端分支越多,类型转换和主键拼接的坑越多。我一般会先列出真正的热点查询,反推需要哪些表进缓存,其余表保持走 DB。
第二,Redis 的 value 结构在设计阶段就要定死。到底是 String 存 JSON 还是 Hash 存字段,不是上线前拍脑袋定的。String 简单,但要更新单个字段就得整个覆盖;Hash 省流量,但消费端逻辑复杂,还要处理字段删除。如果字段很少、变化频繁且要精确更新个别字段,用 Hash;如果字段很多、读多写少,用 String 反而简单。
第三,永远留一个手动开关。Canal 同步链路加了一个开关,切到 OFF 后消费端只读消息不写 Redis,但业务服务还能正常读缓存。这个开关在同步程序出 bug 或者 Redis 抖动时特别有用,不要等到线上事故了才着急改代码,先把流量切走,再慢慢排查。
第四,监控要提前到位。除了消费延迟之外,我会对"Canal Server 到 MySQL 的连接断开次数"和"ack 失败重投次数"做告警,这两个指标能提前反映很多问题。同步链路不像业务接口那样有明确的调用量指标,它出问题的时候往往是静默的——Redis 里慢慢积累了脏数据,直到用户反馈才被发现。监控是唯一的兜底。
Canal 这套方案的真正价值,是把缓存一致性的责任从业务代码里抽了出来,交给一个可以回溯、可以重放、可以监控的独立链路。虽然要处理类型、位点、DDL 这些琐碎细节,但相比在业务代码里到处找漏掉的缓存清理逻辑,这套方案的可维护性完全是两个量级。希望这些踩坑经验能帮你少走一段弯路。
