跳至主要內容

程序员小富大约 6 分钟

大家好,我是小富。

《十万个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 意味着你的技术栈里多了:

  1. Canal Server:需要部署、监控、容灾。Canal Server 挂了怎么办?需要高可用方案(Canal + ZooKeeper)
  2. MQ(通常还需要接一个消息队列做缓冲):Canal 直连客户端的模式在高并发下撑不住,一般会把 binlog 事件投递到 Kafka / RocketMQ,再由消费者处理
  3. 消费者服务:专门消费 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=123name 字段从 "张三" 变成了 "李四"。

但你的 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。

不要用大炮打蚊子。


我是小富,下期见。

上次编辑于: