大家好,我是小富。
《十万个why》系列持续更新中
聊缓存一致性的时候,总有人会问:"Canal 监听 MySQL binlog 做缓存同步,数据库一变 Redis 就自动更新,这不比手动删缓存优雅多了吗?为什么大部分公司还是用 Cache Aside(先更新库再删缓存)这种看起来很土的方案?"
这个问题的答案不是技术上做不到,而是 binlog 方案的代价在很多场景下不值得。
Canal + Binlog 方案长什么样
先简单说一下这个方案的架构:
业务服务 → 更新 MySQL
↓
MySQL 写 binlog
↓
Canal Server 监听 binlog
↓
Canal Client / MQ 消费
↓
更新 / 删除 Redis 缓存
Canal 是阿里开源的一个组件,它伪装成 MySQL 的从库,订阅 binlog 的变更事件。当数据库发生增删改时,Canal 能实时捕获到变更的行数据,然后你可以在消费端把变更同步到 Redis。
听起来确实很完美:业务代码只管写数据库,缓存的更新完全由独立的 Canal 链路来处理,业务和缓存解耦,代码也干净。
但实际跑起来就没那么美好了。
第一个问题:多了一整套中间件要运维
引入 Canal 意味着你的技术栈里多了:
- Canal Server:需要部署、监控、容灾。Canal Server 挂了怎么办?需要高可用方案(Canal + ZooKeeper)
- MQ(通常还需要接一个消息队列做缓冲):Canal 直连客户端的模式在高并发下撑不住,一般会把 binlog 事件投递到 Kafka / RocketMQ,再由消费者处理
- 消费者服务:专门消费 binlog 变更事件并操作 Redis 的服务
这就是从一个简单的"业务代码里加一行 redis.del(key)",变成了运维三个组件。对于中小团队来说,这个成本太高了。
第二个问题:延迟不可控
binlog 同步到 Redis 不是即时的,中间经过的链路:
MySQL 写入 → binlog 落盘 → Canal 拉取 → 投递到 MQ → 消费者消费 → 操作 Redis
正常情况下这条链路的延迟在毫秒到秒级,看起来还行。但在以下场景下延迟会飙升:
- 大批量数据变更:比如跑了个批量更新 SQL,一瞬间产生几万条 binlog 事件,Canal 和 MQ 的消费速度跟不上,积压了
- Canal Server 重启:重启后需要从 binlog 的某个位点重新追,如果期间 binlog 写入量大,追上的过程可能要好几分钟
- MQ 消费端故障:消费者服务挂了或者处理慢了,消息在队列里排队
在这些场景下,数据库已经更新了,但 Redis 还是旧值,用户看到的就是不一致的数据。而且这种不一致的持续时间你无法预测。
第三个问题:binlog 的数据和缓存的数据模型可能不匹配
binlog 记录的是行级别的变更,比如 user 表的某一行 id=123 的 name 字段从 "张三" 变成了 "李四"。
但你的 Redis 缓存存的可能不是简单的行数据,而是聚合数据:
// 缓存的是一个复杂的聚合结果
String cacheKey = "user_profile:" + userId;
UserProfile profile = new UserProfile();
profile.setBasicInfo(userMapper.getById(userId)); // user 表
profile.setOrderCount(orderMapper.countByUserId(userId)); // order 表
profile.setLastLogin(loginLogMapper.getLatest(userId)); // login_log 表
redis.set(cacheKey, JSON.toJSONString(profile));
这个缓存涉及三张表的数据。当 user 表变更时,你从 binlog 里知道 user 表变了,需要更新缓存。但更新缓存时,你还需要重新查 order 表和 login_log 表来重新组装 UserProfile。
这意味着你的 binlog 消费者不是简单地"把变更的值写到 Redis",而是需要理解业务逻辑,知道哪些表的变更会影响哪些缓存 Key,以及如何重新构建缓存数据。消费者的复杂度直接上去了。
相比之下,Cache Aside 模式就简单得多:不管什么变了,我就删掉缓存,下次读的时候重新从数据库查并回填。不需要在缓存更新逻辑里理解业务。
第四个问题:binlog 格式和过滤的坑
MySQL 的 binlog 有三种格式:
- STATEMENT:记录 SQL 语句本身。Canal 没法直接用,因为你从 SQL 语句里没法直接提取变更后的数据
- ROW:记录每一行的变更前后数据。Canal 需要这种格式
- MIXED:混合模式
用 Canal 就必须确保 binlog 格式是 ROW。但 ROW 格式的 binlog 体积比 STATEMENT 大得多,因为每一行变更都要完整记录。一条 UPDATE user SET status = 1 WHERE create_time < '2024-01-01' 如果影响了 10 万行,ROW 格式的 binlog 里就是 10 万条记录。
这不仅增加了磁盘开销和主从复制的带宽,还会让 Canal 的消费量暴增。
另外,你还需要在 Canal 端配置过滤规则,只监听你关心的表。如果配置不当,所有表的变更都会被推送,消费端会被无关数据淹没。
那什么时候用 Canal + Binlog?
Canal 方案不是不好,而是适合特定场景:
| 场景 | 是否适合 Canal |
|---|---|
| 普通业务的缓存更新 | 不适合,Cache Aside 就够了 |
| 数据异构同步(MySQL → ES / HBase) | 非常适合 |
| 跨服务的数据同步 | 适合 |
| 数据变更审计 / 日志 | 适合 |
| 缓存数据结构简单,就是表的行数据 | 可以考虑 |
Canal 的核心价值是数据变更的事件驱动,它最擅长的是"我不关心谁改了数据库,我只关心数据变了"这种场景。用来做 MySQL 到 Elasticsearch 的数据同步,或者做数据变更的审计日志,比用来做缓存更新合适得多。
为什么大部分公司选了"先更新库再删缓存"
归根结底就四个字:简单够用。
// 整个缓存一致性逻辑就这么几行
public void updateUser(User user) {
userMapper.updateById(user); // 1. 先更新数据库
redis.del("user:" + user.getId()); // 2. 再删除缓存
}
- 不需要额外的中间件
- 不需要单独的消费者服务
- 不需要理解 binlog 格式
- 代码简单,出了问题好排查
- 极少数场景下的短暂不一致,加个 TTL 兜底就行
技术选型不是选最先进的,而是选最适合的。对于 90% 的业务场景,Cache Aside + TTL 兜底已经能很好地工作了。只有当你的场景确实需要事件驱动的数据同步时,再考虑 Canal。
不要用大炮打蚊子。
我是小富,下期见。
