缓存与 DB 一致性:先删缓存 / 先更 DB / 延迟双删,什么场景选哪个
一句话速记
最推荐方案:先更 DB,再删缓存(Cache-Aside 写模式),大多数场景够用;延迟双删解决”写操作中途有读操作缓存了旧值”的并发窗口问题;先删缓存存在”删缓存成功但 DB 更新失败”的问题,不推荐。生产中通过 Canal + MQ 异步删缓存 实现最终一致性是最稳的方案。
通俗解释
三种写策略:
策略 1:先删缓存,再更 DB(不推荐)
步骤:del(cache) → update(db)
问题:del 成功,update 失败 → 缓存被删但 DB 没更新 → 不一致
并发问题:A 删缓存 → B 读缓存未命中 → B 读 DB 旧值写入缓存 → A 更新 DB
→ 缓存里是旧值,DB 是新值(不一致,且持续到缓存过期)
策略 2:先更 DB,再删缓存(推荐,Cache-Aside 标准模式)
步骤:update(db) → del(cache)
优点:DB 更新成功才删缓存,DB 失败则缓存不动,一致性好
问题:del(cache) 失败怎么办?→ 重试(消息队列保证)
策略 3:延迟双删
步骤:del(cache) → update(db) → sleep(N ms) → del(cache) 再次
解决:防止并发写-读的脏缓存,但 sleep 时间难以确定
关键细节
1)“先更 DB,再删缓存”的一致性分析
正常流程:
写请求:update DB(id=1, val=B) → del cache(id=1)
读请求(之后):cache miss → read DB → cache(id=1, val=B)
→ 最终一致 ✓
并发异常场景(极低概率):
1. 读请求:cache miss(缓存失效)
2. 读请求:read DB(读到旧值 A)
3. 写请求:update DB(更新为 B)
4. 写请求:del cache(缓存已不存在,del 无效)
5. 读请求:write cache(写入旧值 A!)
条件:缓存恰好在 update 之前失效 + 读操作在 update 和 del 之间
→ 脏缓存,持续到 TTL 过期
→ 实际非常低概率,大多数业务可接受
2)延迟双删(应对上述并发场景)
def update_with_delayed_double_delete(key, new_value):
# 第一次删:清除可能的旧缓存
redis.delete(key)
# 更新 DB
db.update(new_value)
# 延迟删:sleep 后再删一次,清除并发读写入的脏缓存
time.sleep(0.1) # 100ms,需根据业务读 DB 的最大时间调整
redis.delete(key)
# 问题 1:sleep 阻塞当前线程(可以用异步任务替代)
# 问题 2:sleep 时间难以确定(太短不够,太长影响性能)
# 适用:写操作少、一致性要求不极高的场景异步版本(不阻塞):
async def update_with_async_double_delete(key, new_value):
redis.delete(key)
db.update(new_value)
# 投递延迟消息(如 RocketMQ 延迟消息)
mq.send_delayed(
topic="cache_invalidation",
body={"key": key},
delay_ms=500 # 500ms 后投递
)
# Consumer 端处理:
def handle_cache_invalidation(msg):
redis.delete(msg["key"]) # 再次删除3)Canal + MQ 方案(生产最稳方案)
架构:
应用层 → 只更新 DB
Canal(监听 MySQL Binlog)→ 捕获行变更
Canal → 发送到 MQ(Kafka/RocketMQ)
Consumer → 删除/更新 Redis
优点:
1. 应用层不需要关心缓存删除(解耦)
2. 即使 Canal 延迟,DB 已更新,读缓存 miss 后读 DB 得到新值
3. 天然支持重试(MQ + Consumer 重试机制)
4. 适合多个服务写同一 DB 的场景
缺点:
延迟(通常 < 100ms),对强一致性要求高的场景不适用
Canal 工作原理:
Canal 伪装成 MySQL Slave
接收 Master 的 Binlog
解析出 INSERT/UPDATE/DELETE 的表和行数据
→ 转发给消费端处理
4)场景选型决策
场景 推荐方案
────────────────────────────────────────────────────────────
读多写少,允许短暂不一致(秒级) 先更 DB + 删缓存 + 设置 TTL(兜底)
写多读多,一致性要求较高 Canal + MQ 异步删缓存
金融/支付,强一致性要求 不用缓存(直接读 DB)或加分布式锁
多服务写同一 DB Canal + MQ(全局统一)
简单业务,并发低 直接设置短 TTL(缓存自然失效)
5)缓存更新 vs 缓存删除
为什么推荐删缓存,而不是更新缓存?
情况 1:缓存的值需要计算(如用户积分 = 多表 JOIN 结果)
更新缓存 = 立即重新计算(可能是 N 次写操作,每次都触发昂贵计算)
删缓存 = 懒加载,下次读时才计算(大多数写操作不需要立即重新加载)
情况 2:并发写
事务 A 更新缓存 value=10
事务 B 更新缓存 value=20
乱序(B 先写,A 后覆盖)→ 缓存 value=10,DB value=20(不一致)
删缓存没有这个问题(都是 del,幂等)
结论:Write-Around(更新 DB + 删缓存)> Write-Through(同时更新)
延迟追问
- Q:缓存删除失败怎么办(del 失败)? → 引入重试机制:失败后投递到 MQ 消息队列,Consumer 重试删除,设置最大重试次数(如 5 次)+ 指数退避。如果最终失败,依赖 TTL 兜底(设置合理的过期时间,如 5 分钟)。
- Q:Read-Through 和 Cache-Aside 有什么区别? → Cache-Aside:应用层负责读缓存、写缓存、更新 DB——逻辑在应用代码里;Read-Through:缓存层负责从 DB 加载数据,应用只和缓存交互——逻辑在缓存框架里(如 NCache、Ehcache 的 Read-Through 模式)。Cache-Aside 更灵活,Read-Through 更简洁但框架侵入性强。
- Q:强一致性场景(金融)如何处理? → 核心账务直接读 DB,不走缓存;查询接口(如余额展示)可以缓存,但设置极短 TTL(1~5s)+ 写操作主动删缓存;或者用数据库乐观锁 + 版本号,确保读到的是最新版本。
我的记法
- 推荐:先更 DB,再删缓存(Cache-Aside,简单有效)
- del 失败兜底:MQ 重试 + TTL 兜底
- 延迟双删:解决并发写-读的脏缓存窗口,但 sleep 时间难定
- Canal + MQ:生产最稳,解耦,支持重试
- 删缓存 > 更新缓存:幂等,避免并发写乱序
- 一句话:「先更 DB 再删缓存,删失败靠 MQ 重试,极端一致性用 Canal」
状态
- 已背速记
- 能解释先删 vs 先更的区别
- 能画出 Canal + MQ 方案架构