1. 先说结论
Redis 和 MySQL 做缓存双写时,不存在成本可控的强一致缓存方案。工程上常用的是最终一致性:
- MySQL 是准确信息源。
- Redis 是派生出来的缓存,可以理解为基于 MySQL 数据构建的 materialized view。
- 写请求先落 MySQL,再删除 Redis 缓存。
- 删除失败要有重试、消息队列或 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. 为什么不是更新缓存
更新缓存看起来更直观,但实际项目里经常会出问题。
比如用户详情缓存可能来自:
- user 表基本信息。
- user_level 表等级。
- user_tag 表标签。
- 一些运行时配置。
这时写 user 表时直接更新缓存,就要复制一遍组装逻辑。后续其他表更新也要知道这个缓存的结构,最终会把缓存维护逻辑散落在多个写路径里。
删除缓存则简单得多:只要数据变了,把派生缓存删掉,下次读请求重新构建。
并发写场景下,更新缓存还会遇到覆盖顺序问题。两个请求先后写 DB,但缓存更新顺序反过来时,后写入 DB 的新值可能被旧请求覆盖。这类 race condition 很难靠简单代码约束彻底消除。
5. 延迟双删的使用边界
延迟双删常见写法:
删除缓存
更新数据库
等待一小段时间
再次删除缓存
或者:
更新数据库
删除缓存
等待一小段时间
再次删除缓存
它解决的是并发读写窗口里旧值被重新写入缓存的问题。问题是这个“等待一小段时间”没有通用答案,取决于数据库响应时间、业务读耗时、网络延迟和并发规模。
sleep 时间没有标准答案,只能基于业务读耗时、事务提交耗时和 P99 延迟评估。高并发下延迟双删仍然可能失效,因为等待期间可能继续出现新的回源写缓存。
所以我把延迟双删当作补充手段,不当作核心一致性方案。核心仍然应该是:
- 写 DB 提交成功后删除缓存。
- 删除失败进入重试队列。
- 对关键表通过 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 主从复制的大致流程是:
- 从库向主库请求 binlog。
- 主库 dump binlog 给从库。
- 从库读取 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 变更最终能可靠地传播到缓存失效链路。