上一篇我们深入讲解了Redis事务与锁机制,搞定了单Redis场景下的原子操作和并发冲突。但在实际生产中,Redis作为缓存层、MySQL作为持久化存储层的双库架构极为常见,缓存与数据库的数据不一致就成了高频痛点:要么读到老数据,要么缓存脏数据,轻则影响业务体验,重则导致数据错乱。
很多同学简单认为“更新完数据库再删缓存就行”,却忽略了并发场景、网络延迟、服务宕机带来的一致性风险。本篇就从一致性问题根源、经典方案对比、生产级落地实现、异常兜底处理四个维度,彻底讲透Redis与MySQL双写一致性的全套解决方案,让缓存和数据库数据始终保持同步。
核心前提:强一致性成本极高,缓存架构追求最终一致性即可,在可控时间内让数据达成一致,兼顾性能与准确性。
一、为什么会出现双写不一致?
Redis缓存的核心设计是读多写少,读流程遵循:读缓存→缓存未命中→读数据库→写入缓存,本身不会出现不一致;问题全出在写操作,一旦写库和更删缓存的顺序、原子性出问题,就会产生脏数据。
典型不一致场景
- 并发更新冲突:线程A更新数据库,线程B后更新数据库但先删除缓存,导致A的旧数据写入缓存,形成脏数据
- 读写并发冲突:线程A更新数据库后,还没来得及删缓存,线程B读取到旧缓存数据
- 操作失败:数据库更新成功,缓存删除/更新失败(网络波动、Redis宕机)
- 延时问题:主从同步延迟、消息队列延迟,导致缓存与数据库数据错位
解决一致性问题的核心思路:让缓存失效优先于数据写入,或保证缓存操作的最终执行,杜绝旧数据覆盖新数据的情况。
二、经典双写一致性方案对比
市面上主流的一致性方案各有优劣,我们按复杂度、一致性、性能逐一拆解,明确生产选型。
| 方案 | 执行流程 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 先更缓存,再更数据库 | 更新Redis→更新MySQL | 实现简单 | 极易不一致:Redis更新成功,MySQL更新失败,缓存永久脏数据 | 严禁使用,无业务适配性 |
| 先更数据库,再更缓存 | 更新MySQL→更新Redis | 数据实时性高 | 并发写场景下,后更新的库先更缓存,旧数据覆盖新数据 | 写请求极少、无并发的单线程场景 |
| 先更数据库,再删缓存 | 更新MySQL→删除Redis | 实现简单、并发冲突概率低、性能高 | 极端读写并发会出现脏数据,需兜底 | 绝大多数业务首选,通用缓存场景 |
| 延时双删 | 更库→删缓存→延时→再删缓存 | 解决读写并发冲突,一致性更高 | 需控制延时时间,依赖延时组件 | 读多写少、对一致性要求较高的业务 |
| Canal监听binlog | 更新MySQL→Canal监听binlog→异步删更缓存 | 业务解耦、一致性强、无侵入 | 部署复杂、有一定延时 | 大数据量、高并发、强最终一致性需求 |
避坑结论:禁止使用“先更缓存再更库”,普通业务优先选用先更库再删缓存+延时双删,高并发复杂业务选用Canal异步同步方案。
三、生产级方案落地:先更新数据库,再删除缓存
这是性价比最高、落地最简单的方案,核心逻辑是:不更新缓存,只删除缓存,让下一次读请求主动加载最新数据到缓存,从根源避免旧数据覆盖问题。
1. 核心执行流程
- 客户端发起更新请求,先成功更新MySQL数据库
- 数据库更新完成后,立即删除Redis对应的缓存Key
- 后续读请求发现缓存未命中,查询MySQL最新数据,重新写入Redis
2. 代码实操(Java版)
/**
* 更新用户信息 + 缓存删除
*/
@Transactional(rollbackFor = Exception.class)
public boolean updateUserInfo(UserInfo userInfo) {
// 1. 优先更新数据库
boolean updateSuccess = userMapper.updateById(userInfo) > 0;
if (updateSuccess) {
// 2. 数据库更新成功,删除对应缓存
String cacheKey = "user:info:" + userInfo.getUserId();
try {
redisTemplate.delete(cacheKey);
} catch (Exception e) {
// 缓存删除失败,打印日志+告警,后续通过定时任务兜底
log.error("缓存删除失败,key:{}", cacheKey, e);
// 可将失败Key存入MQ/数据库,异步重试
asyncRetryDelete(cacheKey);
}
}
return updateSuccess;
}
/**
* 读请求:缓存+数据库双读
*/
public UserInfo getUserInfo(Long userId) {
String cacheKey = "user:info:" + userId;
// 查缓存
UserInfo cacheUser = redisTemplate.opsForValue().get(cacheKey);
if (cacheUser != null) {
return cacheUser;
}
// 缓存未命中,查数据库
UserInfo dbUser = userMapper.selectById(userId);
if (dbUser != null) {
// 写入缓存,设置过期时间
redisTemplate.opsForValue().set(cacheKey, dbUser, 2, TimeUnit.HOURS);
}
return dbUser;
}
3. 极端场景兜底:延时双删
针对读写并发冲突(更新数据库后,读请求先加载旧数据到缓存,再执行删缓存),在基础方案上增加延时双删:
- 更新MySQL数据库
- 立即删除Redis缓存
- 延时500ms~1s(覆盖数据库主从同步、网络延迟时间)
- 再次删除Redis缓存,彻底清理脏数据
延时可通过线程池、MQ延时队列、定时任务实现,避免阻塞主线程。
四、高并发强一致方案:Canal监听binlog
对于秒杀、商品详情等高并发场景,业务代码耦合缓存操作会影响性能,推荐使用Canal异步同步方案,实现业务与缓存同步解耦。
1. 核心原理
- Canal伪装成MySQL从节点,监听MySQL的binlog日志
- 解析binlog获取数据变更记录,发送到消息队列(Kafka/RocketMQ)
- 消费队列消息,异步删除/更新Redis缓存,不影响主线程业务
2. 方案优势
- 无业务侵入:业务代码只关注数据库操作,无需管缓存
- 高一致性:基于binlog有序消费,数据同步精准
- 高可用:支持异步重试、失败告警,不阻塞业务流程
3. 落地要点
- 开启MySQL binlog,设置为row模式(记录数据变更详情)
- 部署Canal服务,配置监听的库表
- 消费消息时做幂等处理,避免重复执行
- 同步失败重试,死信队列兜底告警
五、缓存一致性兜底保障措施
无论选用哪种方案,都需要兜底机制,应对网络波动、服务宕机、组件故障等异常情况:
1. 缓存过期时间兜底
所有缓存必须设置合理过期时间(如1~2小时),即便出现脏数据,也会在过期后自动失效,保证最终一致性。
2. 异步重试机制
缓存删除/更新失败时,将失败Key存入MQ或数据库,启动异步任务重试,直到执行成功。
3. 定时任务校验
定时扫描热点数据,对比Redis与MySQL数据,发现不一致立即修复缓存。
4. 分布式锁控制并发
对同一Key的读写操作,加分布式锁串行执行,彻底杜绝并发冲突。
六、方案选型总结
- 普通业务(后台管理、用户中心):选用先更库再删缓存,简单高效,满足90%场景
- 读多写少、一致性要求高:选用先更库再删缓存+延时双删
- 高并发、大数据量(电商、秒杀):选用Canal binlog异步同步
核心口诀:写请求不更缓存只删除,读请求主动加载新数据,异常情况靠重试,过期时间做兜底,最终一致稳无忧。
结语与下篇预告
Redis与MySQL双写一致性没有银弹,核心是在性能、复杂度、一致性之间做取舍,选用适合业务的方案并做好兜底,就能彻底杜绝脏数据问题。