缓存一致性难题破解:Redis如何保证缓存与数据库的数据一致性?
缓存一致性难题破解:Redis如何保证缓存与数据库的数据一致性?
- 前言
- 一、问题是怎么产生的?
- 1.1 理想情况
- 1.2 问题场景
- 1.3 常见错误做法
- 二、三大经典策略对比
- 策略一:Cache Aside Pattern(旁路缓存)—— **最推荐**
- 策略二:Read/Write Through(读写穿透)
- 策略三:Write Behind(写回)
- 策略对比总结
- 三、Cache Aside 的并发问题与解决方案
- 3.1 问题场景:先删缓存 vs 后删缓存
- 3.2 方案 B:先更新数据库,再删缓存(✅ 推荐)
- 3.3 极端并发:二次删除(延迟双删)
- 四、终极方案:订阅 MySQL Binlog(Canal)
- 4.1 原理架构
- 4.2 核心步骤
- 4.3 优点
- 4.4 快速上手示例
- 五、各种方案的最终一致性对比
- 六、生产环境最佳实践
- 6.1 标准写流程
- 6.2 缓存穿透、雪崩、击穿的应对
- 6.3 重试机制保障缓存删除成功
- 七、常见面试题
- Q1:为什么是删除缓存而不是更新缓存?
- Q2:先更新数据库还是先删除缓存?
- Q3:缓存删除失败怎么办?
- Q4:Canal 方案会不会有延迟?
- Q5:如果读请求很强,删除缓存瞬间又有大量读怎么办?
- 总结
🌺The Begin🌺点点关注,收藏不迷路🌺 ⬇ ⬇ 底部 ⬇ ⬇ |
前言
在互联网后端架构中,Redis 作为高性能缓存被广泛应用。但一个经典难题始终困扰着开发者:
数据库中的数据更新后,如何保证 Redis 缓存中的数据也是最新的?
这就是缓存与数据库一致性问题。
如果处理不当,会导致:
- 用户看到脏数据
- 库存数据错误引发超卖
- 订单状态混乱
今天这篇文章,我们从问题根源 → 三大经典策略 → 最终一致性方案 → 生产实践,彻底讲透缓存一致性问题。
一、问题是怎么产生的?
1.1 理想情况
用户请求 → 读缓存 → 命中 → 返回 ↓ 未命中 → 读数据库 → 写缓存 → 返回1.2 问题场景
当写操作发生时,缓存和数据库可能不一致:
时间线: 1. 线程 A 更新数据库:将商品价格从 100 改为 80 2. 线程 B 读缓存:仍读到旧的 100(脏数据) 3. 线程 A 删除缓存(如果采用删除策略)问题根源:更新数据库和操作缓存是两个独立的操作,无法保证原子性。
1.3 常见错误做法
| 做法 | 问题 |
|---|---|
| 先更新数据库,再更新缓存 | 并发写导致缓存脏数据 |
| 先更新缓存,再更新数据库 | 缓存成功,数据库失败 → 永久不一致 |
| 先删除缓存,再更新数据库 | 删除后、更新前,其他线程读到旧数据并回写缓存 |
| 先更新数据库,再删除缓存 | 删除失败 → 缓存永久为脏数据 |
二、三大经典策略对比
策略一:Cache Aside Pattern(旁路缓存)——最推荐
读流程:
┌─────────┐ │ 读请求 │ └────┬────┘ ▼ ┌─────────┐ │ 查缓存 │ └────┬────┘ 命中 / \ 未命中 ▼ ▼ 返回数据 查数据库 │ ▼ 写缓存 │ ▼ 返回数据写流程:
┌─────────┐ │ 写请求 │ └────┬────┘ ▼ ┌─────────┐ │ 更新数据库│ └────┬────┘ ▼ ┌─────────┐ │ 删除缓存 │ └─────────┘核心:先更新数据库,再删除缓存。
为什么是删除而不是更新缓存?
- 更新缓存需要知道新值,可能需要复杂计算
- 删除后等下次读时再加载,懒加载更简单
策略二:Read/Write Through(读写穿透)
缓存作为唯一数据源,应用只和缓存交互,缓存负责与数据库同步。
写请求 → 缓存 → 数据库 读请求 → 缓存 → 数据库(未命中时)优点:应用层无感知
缺点:缓存层实现复杂
策略三:Write Behind(写回)
先写缓存,异步批量写数据库。
优点:写入性能最高
缺点:数据可能丢失(缓存宕机)
适用场景:高并发写入,对一致性要求不高(如点赞数、浏览量)
策略对比总结
| 策略 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 高 | 高 | 低 | 大多数业务(推荐) |
| Read/Write Through | 高 | 中 | 中 | 需要缓存抽象层 |
| Write Behind | 低 | 最高 | 高 | 日志、计数类 |
三、Cache Aside 的并发问题与解决方案
3.1 问题场景:先删缓存 vs 后删缓存
方案 A:先删缓存,再更新数据库(❌ 不推荐)
时间线: 线程 A(写) 线程 B(读) 1. 删除缓存 2. 读缓存 → 未命中 3. 读数据库(旧值) 4. 写缓存(旧值) 5. 更新数据库(新值)结果:缓存中是旧值,数据库是新值 →不一致
3.2 方案 B:先更新数据库,再删缓存(✅ 推荐)
时间线: 线程 A(写) 线程 B(读) 1. 更新数据库(新值) 2. 读缓存(旧值命中) 3. 删除缓存问题:步骤 2 读到的是旧值,但仅持续几十毫秒,下次读就正常了。这是最终一致性,绝大多数业务可接受。
3.3 极端并发:二次删除(延迟双删)
针对极小概率的异常场景(读线程在写线程删除缓存前写入了旧数据):
publicvoidupdate(Stringkey,ObjectnewValue){// 1. 第一次删除缓存redis.del(key);// 2. 更新数据库db.update(newValue);// 3. 休眠一段时间(比读操作耗时稍长)Thread.sleep(500);// 4. 第二次删除缓存redis.del(key);}为什么有效:确保步骤 3 期间任何回写缓存的旧数据都被第二次删除清除。
缺点:引入固定延迟,影响写性能。
四、终极方案:订阅 MySQL Binlog(Canal)
4.1 原理架构
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ 业务应用 │ → │ MySQL │ → │ Binlog │ → │ Canal │ └─────────┘ └────┬────┘ └─────────┘ └────┬────┘ │ │ │ ▼ ┌─────┴─────┐ ┌─────────┐ │ 数据写入 │ │ 消息队列 │ └───────────┘ └────┬────┘ │ ▼ ┌─────────┐ │ 消费者 │ └────┬────┘ │ ▼ ┌─────────┐ │ 删除缓存 │ └─────────┘4.2 核心步骤
- 业务应用:只更新数据库,不操作缓存
- MySQL:生成 Binlog
- Canal:伪装成 MySQL Slave,拉取 Binlog
- 消息队列:Canal 将变更事件发送到 MQ
- 消费者:收到事件后删除对应缓存
4.3 优点
| 优势 | 说明 |
|---|---|
| 彻底解耦 | 业务代码无需关心缓存 |
| 保证一致性 | 只要 Binlog 被消费,缓存一定被删除 |
| 支持异构 | 可同时更新 ES、HBase 等其他存储 |
4.4 快速上手示例
启动 Canal Server(docker-compose):
version:'3'services:canal:image:canal/canal-server:v1.1.6environment:canal.instance.master.address:mysql:3306canal.instance.dbUsername:canalcanal.instance.dbPassword:canalcanal.instance.filter.regex:mydb\\..*ports:-"11111:11111"Java 客户端消费:
@CanalEventListenerpublicclassCacheSyncListener{@InsertListenPoint@DeleteListenPoint@UpdateListenPointpublicvoidonEvent(CanalEntry.Entryentry){StringtableName=entry.getHeader().getTableName();// 解析出变更的主键 IDStringid=parseId(entry);// 删除缓存redis.del(tableName+":"+id);}}五、各种方案的最终一致性对比
| 方案 | 不一致窗口 | 实现复杂度 | 是否侵入业务代码 | 推荐度 |
|---|---|---|---|---|
| 先删缓存,再更新 DB | 几百毫秒~几秒 | 低 | 是 | ⭐⭐ |
| 先更新 DB,再删缓存 | 几十毫秒 | 低 | 是 | ⭐⭐⭐⭐ |
| 延迟双删 | 500ms 固定延迟 | 低 | 是 | ⭐⭐⭐ |
| Binlog 订阅(Canal) | 毫秒级 | 中 | 否 | ⭐⭐⭐⭐⭐ |
| 设置缓存过期时间 | 过期前都不一致 | 低 | 否 | ⭐⭐(兜底) |
结论:
- 一般业务:先更新 DB,再删缓存 + 设置合理过期时间(如 30 秒)—— 简单够用
- 一致性要求高:Canal + 消息队列方案 —— 彻底解耦,可靠
六、生产环境最佳实践
6.1 标准写流程
@ServicepublicclassProductService{@AutowiredprivateRedisTemplateredis;@AutowiredprivateProductDaoproductDao;@TransactionalpublicvoidupdateProduct(Productproduct){// 1. 更新数据库productDao.updateById(product);// 2. 删除缓存(而非更新)StringcacheKey="product:"+product.getId();redis.delete(cacheKey);// 3. 可选:发送 MQ 消息,异步删除其他关联缓存mq.send("cache.delete",cacheKey);}publicProductgetProduct(Longid){StringcacheKey="product:"+id;// 1. 查缓存Productproduct=(Product)redis.opsForValue().get(cacheKey);if(product!=null){returnproduct;}// 2. 缓存未命中,查数据库product=productDao.selectById(id);if(product!=null){// 3. 写缓存,设置合理过期时间redis.opsForValue().set(cacheKey,product,300,TimeUnit.SECONDS);}returnproduct;}}6.2 缓存穿透、雪崩、击穿的应对
| 问题 | 解决方案 |
|---|---|
| 缓存穿透(查询不存在的数据) | 布隆过滤器 / 缓存空对象(过期时间短) |
| 缓存雪崩(大量缓存同时过期) | 过期时间加随机值 / 多级缓存 |
| 缓存击穿(热点 key 过期瞬间) | 互斥锁 / 逻辑过期 |
6.3 重试机制保障缓存删除成功
publicvoiddeleteCacheWithRetry(Stringkey,intmaxRetries){for(inti=0;i<maxRetries;i++){try{redis.del(key);return;}catch(Exceptione){// 重试前短暂等待Thread.sleep(100*(i+1));}}// 最终失败 → 发送告警 + 写入重试队列alarm.send("缓存删除失败: "+key);retryQueue.offer(key);}七、常见面试题
Q1:为什么是删除缓存而不是更新缓存?
- 更新缓存需要知道新值,可能涉及复杂计算
- 如果缓存被多个线程频繁更新,浪费计算资源
- 懒加载(删除后下次读时加载)更简单可靠
Q2:先更新数据库还是先删除缓存?
先更新数据库,再删除缓存。原因:
- 先删缓存再更新 DB,并发读可能回写旧数据
- 先更新 DB 再删缓存,不一致窗口更短
Q3:缓存删除失败怎么办?
- 增加重试机制(3-5 次)
- 失败后写入消息队列,异步重试
- 设置兜底的过期时间(如 30 秒),即使删除失败也会自动失效
Q4:Canal 方案会不会有延迟?
Binlog 消费延迟通常在毫秒级,比业务代码主动删除缓存多了网络 + 消费开销(10-50ms),但换来的是业务代码零侵入和更高的可靠性。
Q5:如果读请求很强,删除缓存瞬间又有大量读怎么办?
这就是缓存击穿。解决方案:
// 互斥锁,只允许一个线程更新缓存synchronized(key.intern()){// 双重检查if(redis.get(key)==null){dbData=db.query();redis.set(key,dbData);}}总结
| 核心结论 | 说明 |
|---|---|
| 首选策略 | Cache Aside:先更新数据库,再删除缓存 |
| 兜底保障 | 缓存设置合理过期时间(如 30 秒~5 分钟) |
| 终极方案 | Canal 订阅 Binlog,异步删除缓存 |
| 并发优化 | 延迟双删(应对极端情况) |
| 失败处理 | 重试 + MQ 异步队列 |
| 性能防护 | 互斥锁防击穿,随机过期防雪崩 |
最终一句话:
99% 的场景:先更新 DB + 后删缓存 + 设置过期时间 = 简单够用
极致一致性:Canal 监听 Binlog + 异步删除 = 彻底解耦
🌺The End🌺点点关注,收藏不迷路🌺 ⬆ ⬆ 顶部 ⬆ ⬆ |
