Redis 系列 10. 缓存双写一致性与 Canal 落地


1. 先说结论

Redis 和 MySQL 做缓存双写时,不存在成本可控的强一致缓存方案。工程上常用的是最终一致性:

  1. MySQL 是准确信息源。
  2. Redis 是派生出来的缓存,可以理解为基于 MySQL 数据构建的 materialized view。
  3. 写请求先落 MySQL,再删除 Redis 缓存。
  4. 删除失败要有重试、消息队列或 binlog 订阅兜底。

如果有人说“更新数据库再更新缓存就一定没问题”,这个结论是不完整的。缓存里经常不是数据库单字段原样拷贝,而是聚合结果、转换结果或跨表组装结果,直接更新缓存很容易把复杂度推高。

缓存双写问题

2. 缓存读取的标准流程

读路径通常是 Cache Aside:

读请求
  -> 查 Redis
  -> 命中,直接返回
  -> 未命中,查 MySQL
  -> MySQL 有数据,写入 Redis 并设置 TTL
  -> 返回结果

伪代码:

public UserDTO getUser(Long userId) {
    String key = "user:" + userId;
    UserDTO cache = redisTemplate.opsForValue().get(key);
    if (cache != null) {
        return cache;
    }

    UserDO user = userMapper.selectById(userId);
    if (user == null) {
        return null;
    }

    UserDTO dto = convert(user);
    redisTemplate.opsForValue().set(key, dto, 10, TimeUnit.MINUTES);
    return dto;
}

如果热点 key 并发回源,需要加本地互斥、Redis 锁或逻辑过期,这部分后面缓存击穿单独讲。

这里的风险通常叫 cache miss stampede 或缓存击穿:同一个热点 key 失效后,大量请求同时打到 MySQL。本地锁适合同一 JVM 内合并回源,Redis 分布式锁适合多实例合并回源,逻辑过期适合允许短暂读旧值但要求高可用的场景。

3. 更新缓存还是删除缓存

写路径有两类思路:

策略 问题
更新数据库,再更新缓存 并发写时可能后写 DB 的请求先更新缓存,缓存变旧
更新缓存,再更新数据库 DB 写失败会造成缓存里有脏数据
删除缓存,再更新数据库 删除后有读请求回源旧 DB 并写回缓存,产生旧缓存
更新数据库,再删除缓存 常用方案,但删除失败仍要补偿

推荐方案是:先更新 MySQL,再删除 Redis。更准确地说,应该在 MySQL 事务提交成功后删除 Redis。

@Transactional(rollbackFor = Exception.class)
public void updateUser(UserUpdateCommand command) {
    userMapper.updateById(command.toEntity());

    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            redisTemplate.delete("user:" + command.getUserId());
        }
    });
}

不要在事务还没提交时就提前删除缓存。否则可能出现这样的窗口:缓存先被删除,读请求回源读到事务提交前的旧数据并写回缓存,随后事务提交成功,Redis 里反而留下旧值。

这个方案不是绝对强一致。它只是把绝大多数并发问题收敛到“事务提交后删除缓存是否成功”和“极短窗口内读旧数据”这两个可治理问题上。这个窗口就是 eventual consistency window,业务需要能接受短时间读到旧缓存。

如果事务提交后删除缓存失败,必须进入 retry 或 binlog 补偿链路。否则脏缓存可能长期存在,形成 cache poison。

4. 为什么不是更新缓存

更新缓存看起来更直观,但实际项目里经常会出问题。

比如用户详情缓存可能来自:

  1. user 表基本信息。
  2. user_level 表等级。
  3. user_tag 表标签。
  4. 一些运行时配置。

这时写 user 表时直接更新缓存,就要复制一遍组装逻辑。后续其他表更新也要知道这个缓存的结构,最终会把缓存维护逻辑散落在多个写路径里。

删除缓存则简单得多:只要数据变了,把派生缓存删掉,下次读请求重新构建。

并发写场景下,更新缓存还会遇到覆盖顺序问题。两个请求先后写 DB,但缓存更新顺序反过来时,后写入 DB 的新值可能被旧请求覆盖。这类 race condition 很难靠简单代码约束彻底消除。

5. 延迟双删的使用边界

延迟双删常见写法:

删除缓存
更新数据库
等待一小段时间
再次删除缓存

或者:

更新数据库
删除缓存
等待一小段时间
再次删除缓存

它解决的是并发读写窗口里旧值被重新写入缓存的问题。问题是这个“等待一小段时间”没有通用答案,取决于数据库响应时间、业务读耗时、网络延迟和并发规模。

sleep 时间没有标准答案,只能基于业务读耗时、事务提交耗时和 P99 延迟评估。高并发下延迟双删仍然可能失效,因为等待期间可能继续出现新的回源写缓存。

所以我把延迟双删当作补充手段,不当作核心一致性方案。核心仍然应该是:

  1. 写 DB 提交成功后删除缓存。
  2. 删除失败进入重试队列。
  3. 对关键表通过 binlog 订阅做最终兜底。

6. Canal 解决什么

Canal 的思路是模拟 MySQL 从库,基于 MySQL replication protocol 订阅 MySQL binlog,把数据库变更转换成事件。应用消费这些事件后,可以删除或更新 Redis 缓存。

它适合做最终一致性补偿:

业务写 MySQL
  -> MySQL 写 binlog
  -> Canal 订阅 binlog
  -> 消费端收到变更
  -> 删除对应 Redis key

要注意:Canal 不是让 Redis 和 MySQL 强一致,它只是让“数据库变更后缓存最终被清理”这件事更可靠。Canal 链路存在消费延迟,因此仍然可能出现短暂不一致;工程目标是把不一致控制在可观测、可恢复的范围内。

7. MySQL binlog 配置

Canal 依赖 MySQL binlog。常见配置如下:

[mysqld]
server-id=1
log-bin=mysql-bin
binlog_format=ROW
binlog_row_image=FULL

binlog_format 建议使用 ROW。因为 Canal 需要拿到行级变更内容,STATEMENT 模式只能看到 SQL,不适合精确识别变更行。binlog_row_image=FULL 可以让 before / after 字段更完整,便于消费端根据主键和变更列计算缓存 key。

授权示例:

CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

MySQL 8 的用户、认证插件和复制权限术语要以当前环境为准,有些新版本文档会使用 REPLICATION REPLICA 这类更中性的术语。生产密码也不能使用示例值。

8. Canal 工作原理

MySQL 主从复制的大致流程是:

  1. 从库向主库请求 binlog。
  2. 主库 dump binlog 给从库。
  3. 从库读取 relay log 并重放。

Canal 伪装成一个 MySQL 从库,向 MySQL 主库请求 binlog,然后把变更解析成应用能消费的事件。

这个机制的优点是对业务代码侵入小。缺点是链路变长了,需要处理消费失败、重复消费、顺序和监控。高写入场景下,binlog 消费可能 backlog,导致缓存删除延迟变大。

9. Spring Boot 消费 Canal 的核心逻辑

实际工程里可以使用 Canal client,也可以让 Canal 投递到 MQ 后由业务消费。MQ 的价值是削峰和解耦:Canal 只负责解析和投递变更,业务消费者按自己的节奏删除缓存。以下用伪代码说明核心逻辑:

public void handleCanalEvent(CanalEvent event) {
    if (!"user".equals(event.getTableName())) {
        return;
    }

    ColumnMap columns = event.getAfterColumns();
    if (columns == null || columns.get("id") == null) {
        columns = event.getBeforeColumns();
    }
    if (columns == null || columns.get("id") == null) {
        return;
    }

    Long userId = columns.getLong("id");
    String key = "user:" + userId;
    redisTemplate.delete(key);
}

消费端要保证幂等。删除同一个 Redis key 多次没有问题,因此“删缓存”天然比“更新缓存”更适合 binlog 消费。

真实项目里还要维护表变更和缓存 key 的映射关系。比如 user_level、user_tag 发生变化时,可能也要删除 user:{id} 这类聚合缓存,不能只盯着当前表名做简单判断。

如果消息来自 MQ,建议记录消费位点或消息 ID,避免异常重启后漏处理。失败重试建议使用指数退避,超过最大次数后进入 dead letter queue,保留人工补偿入口。

还要控制大规模失效带来的 Redis 删除风暴。批量更新或全量同步后,如果瞬间删除大量 key,Redis 可能出现 invalidation spike,业务也可能同时回源 MySQL。可以按业务维度分批删除,并配合限流和监控。

10. 最终一致性的完整方案

我会按这套方案落地:

层次 处理
读路径 Cache Aside,未命中查 DB 后回填 Redis
写路径 先写 MySQL,事务提交成功后再删除 Redis
删除失败 本地重试或投递 MQ 重试
兜底 Canal 订阅 binlog,再次删除相关缓存
监控 缓存删除失败数、Canal 延迟、MQ 堆积、Redis 命中率

关键缓存可以加版本号:

user:{id}:v{version}

也可以把 DB 的 updated_at 或版本字段放进缓存内容里,读到明显旧版本时主动丢弃。

最后要明确边界:Redis 缓存方案追求的是低延迟和最终一致。如果业务是余额扣减、订单支付、库存强一致扣减,最终正确性必须落在数据库事务、库存中心或更强一致的系统上,不能只靠 Redis 缓存维护。

可以把整体模型总结成三层:

层次 角色
DB source of truth,承载最终正确性
Cache derived state,用于降低读延迟
Binlog / MQ / Canal reconciliation layer,用于补偿删除失败和多路径写入

因此,一致性设计的重点不是让 Redis 变成事实来源,而是保证 DB 变更最终能可靠地传播到缓存失效链路。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录