缓存与数据库的一致性:四种方案和它们的取舍

米阳 2023-11-11 4 11/11

“先更新数据库还是先删缓存”这个问题,面试常问,实际项目里也真的会出错。这篇把几种方案摊开说,重点是各自的失败场景。

方案一:先更新 DB,再删缓存

最常用的 Cache Aside。

def update(id, data):
    db.update(id, data)
    redis.delete(f"item:{id}")

失败场景:DB 更新成功,删缓存失败(网络抖动、Redis 挂了),缓存里是旧数据,会一直旧到过期。

缓解:删除失败重试(扔消息队列),或者给缓存设一个不太长的 TTL 兜底。

方案二:先删缓存,再更新 DB

并发下更容易出问题:

1. A 删缓存
2. B 读缓存未命中,读 DB 得到旧值,写回缓存
3. A 更新 DB 为新值
结果:缓存里是旧值,DB 是新值

概率不高但会发生,尤其是读请求量大的时候。除非加延迟双删,但延迟多久不好定。

方案三:延迟双删

redis.delete(key)
db.update(id, data)
time.sleep(0.5)
redis.delete(key)

思路是第二次删除把并发期间写回的旧值清掉。问题是 sleep 阻塞线程,放异步线程里又不好控制时序;0.5 秒是拍的,读请求慢一点就失效。

能解决一部分问题,但不能保证。

方案四:订阅 binlog

用 Canal / Debezium 解析 MySQL binlog,异步删缓存。优点是业务代码零侵入,不管谁改了数据库都能触发;缺点是链路长了,多了组件要维护,延迟从毫秒到秒级。

中大型项目常见做法,小项目上这个属于过度设计。

根本问题:这是最终一致,不是强一致

上面所有方案都是最终一致。要强一致,只能:

  1. 读写都走数据库(缓存只做只读、可过期的场景,比如文章列表)
  2. 用分布式锁把读写串行化(性能代价大)
  3. 把“缓存”做成数据库的一部分(比如 Redis 作为主存储 + AOF 持久化,但这又引入新的问题)

我的实际做法

按数据类型分开:

  • 能容忍几秒不一致:文章列表、统计数据 → Cache Aside + 短 TTL + 删除失败进重试队列
  • 不能容忍:余额、库存、订单状态 → 不缓存,或只缓存不可变的快照
  • 热点但少变:配置、分类 → 本地缓存 + 定时刷新 + 手动刷新入口

最忌讳的是“所有数据都往 Redis 里塞一层”,然后花大量精力解决一致性问题。先问一句:这个数据真的需要缓存吗?多数时候答案是不需要。

- THE END -

米阳

11月11日09:55

最后修改:2023年11月11日
0

非特殊说明,本博所有文章均为博主原创。

共有 0 条评论