这个标题看起来平平无奇,但做过缓存一致性的人都知道,里面全是坑。我先讲个真实场景:某天凌晨两点,线上订单状态显示异常,用户付了款页面还停在"待支付"。查了一圈发现是业务代码里更新完 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())
