缓存一致性问题
在分布式环境下,由于数据库和缓存无法保证原子性(操作 DB 和 操作 Cache 要么同时成功,要么同时失败 )同步更新,导致数据变更时极易出现**缓存中残留旧数据(脏读)**的现象。
解决方案:Cache Aside Pattern (旁路缓存模式)
读的时候先读缓存,没有则读库并回写;写的时候先更库,再删缓存。
用其他策略(如"先删缓存再更DB"或"先更缓存再更DB")都有一个共同问题是:
极易在并发场景下产生"脏数据",且无法自动恢复。
为什么是"删除"而不是"更新"缓存?
- 节省资源:如果写操作很频繁但读操作很少,频繁更新缓存是浪费。
缓存穿透:
用户请求查询一个根本不存在的数据(例如 ID 为 -1 或随机乱码的 ID),此时:
- 缓存中自然没有该数据。
- 数据库中也没有该数据。
所以每次请求都会直接穿透缓存,直达数据库进行查询。
如果有恶意攻击者利用大量不存在的 Key 发起高并发请求,数据库将承受巨大压力,甚至导致宕机
解决方案
方案一:缓存空对象 (Cache Null Values) ------ 最常用,实现简单,占内存
- 逻辑 :
- 当 DB 查询结果为空时,依然写入缓存。
- Value 设为特殊值(如
null、""或特定占位符)。 - 设置一个较短的过期时间(如 5 分钟),避免占用过多内存。
- 下次请求命中该 Key,发现是空值,直接返回,不再查 DB。
- 优点:实现简单,代码侵入性小,能有效保护数据库。
- 缺点 :
- 占用额外内存空间(存储大量空 Key)。
- 存在短暂的不一致窗口(如果 DB 中随后插入了该数据,需等待缓存过期才能读到)。
方案二:布隆过滤器 (Bloom Filter) ------ 高性能,省内存
- 逻辑 :
- 在缓存前加一层布隆过滤器(一种概率型数据结构)。
- 将所有可能存在的 Key(如所有商品 ID)预先加载到过滤器中。
- 请求进来先问过滤器:"这个 Key 存在吗?"
- 说不存在 :一定不存在 -> 直接拦截,返回错误,绝不查 DB。
- 说存在 :可能存在(有误判率)-> 继续查缓存/DB。
- 优点 :
- 内存占用极小,查询效率极高。
- 从源头拦截非法请求,彻底保护数据库。
- 缺点 :
- 有误判率(可能把不存在的说成存在,但绝不会把存在的说成不存在)。
- 维护成本:数据变更时需同步更新过滤器(通常通过异步消息或定时任务)。
实践中可以两者结合使用(布隆过滤器挡掉大部分非法请求,漏网的少量请求用缓存空对象兜底)
缓存雪崩
大量缓存 Key 在同一时间集中过期 ,或者缓存服务整体宕机 。
导致瞬间所有请求直接涌向数据库,造成 DB 压力激增甚至崩溃。
解决方案
- 随机过期时间 (最常用):
- 在原定过期时间基础上,增加一个随机值(如 1-5 分钟)。
- 效果:让 Key 分散过期,避免"集体自杀"。
- 高可用架构 :
- 搭建 Redis 集群/哨兵模式,防止单点故障导致整体宕机。
- 限流与降级 :
- 当检测到流量异常激增时,触发限流 (拒绝部分请求)或降级(返回默认值/缓存旧数据),保护数据库。
- 缓存预热 :
- 在大促或活动前,提前将热点数据加载到缓存中,并设置不同的过期策略。
- 多级缓存:
- 浏览器缓存->nginx缓存->微服务代码里自定义缓存操作->redis缓存->db缓存
缓存击穿 (Cache Breakdown)
某一个热点 Key (如爆款商品、突发新闻)在高并发 访问下突然过期,并且这个key重建业务较复杂,,不能快速更新。
导致瞬间大量请求直接穿透缓存,直击数据库,造成 DB 瞬时压力过大。
解决方案
方案一:互斥锁 (Mutex Lock) ------ 强一致性,最常用,可用性相对差
- 逻辑 :
- 发现缓存过期。
- 获取分布式锁(如 Redis
setnx)。 - 只有拿到锁的线程去查 DB 并回写缓存。
- 其他很多线程没拿到锁就休眠重试或等待,直到缓存重建完成。
- 优点:保证数据强一致性
- 缺点:用户需等待
方案二:逻辑过期 (Logical Expiration) ------ 可用性强,一致性相对差
- 逻辑 :
- 永不过期:Redis 中的 Key 不设物理过期时间(TTL = -1)。
- 内部标记 :在 Value 内部包含一个逻辑过期时间戳(如
expire_time)。 - 异步重建 :
- 请求发现逻辑已过期。
- 立即返回旧数据(保证用户体验)。
- 同时开启一个独立线程去查 DB 更新缓存。
- 优点:无需加锁,响应极快,系统可用性高。
- 缺点:会短暂返回旧数据(弱一致性),消耗额外线程资源。
总结:
穿透 是查不存在的数据(DB也没有)
击穿 是单个热点Key过期
雪崩 是大量 Key同时过期或服务宕机。