本文目录导读:

缓存击穿是一个非常经典的缓存问题,要彻底解决它,首先需要理解它的核心矛盾:某个热点 key 在缓存失效的瞬间,大量并发请求同时穿透到数据库。
需要明确一点:不存在 100% 物理意义上的“彻底”(因为数据库本身也可能挂掉),我们的目标是在工程上无限趋近于 0 的击穿概率,并且即便发生,对数据库的压力也完全可控。
以下是从防和治两个维度,结合多种策略的“彻底”解决方案:
核心方案:永不失效 + 主动更新
这是最接近“彻底”的方案,思路是:不让缓存 key 物理失效,而是逻辑上失效。
实现方式:
- 缓存中设置的热点 key,不设置 TTL(生存时间),或者设置一个非常大的 TTL(30 天)。
- 后台启动一个定时任务或异步线程,定期(例如每 10 分钟)查询数据库,更新缓存数据。
- 如果数据有变更,通过消息队列主动通知服务更新缓存。
为什么能解决?
- 缓存永远存在,永远不会有“失效瞬间”,因此不存在击穿窗口期。
- 数据库压力完全由后台任务控制,是平滑的、低并发的。
缺点:
- 需要额外的后台维护逻辑。
- 如果后台更新失败,缓存会是旧数据(需要降级策略)。
中级方案:互斥锁(Mutex)
这是最经典、最通用的方案,核心思想是:第一个请求发现缓存失效后,加锁去查库,其他请求等待或快速失败。
实现方式(以 Redis + Setnx 为例):
- 查缓存:如果命中,直接返回。
- 缓存 Miss:尝试获取分布式锁(
SETNX lock_key 1 EX 10 NX)。 - 拿到锁的线程:查询数据库,回写缓存,释放锁。
- 没拿到锁的线程:
- 最佳实践:自旋等待(sleep 50ms),再次查缓存,如果反复查不到,设置一个超时时间(如 200ms),超时后返回默认值或兜底数据。
- 简单做法:直接返回空或提示稍后重试。
为什么不能“彻底”?
- 如果锁的粒度是全局的,会导致所有请求串行化,高并发下性能下降。
- 如果持有锁的线程执行时间过长(如数据库慢查询)或死锁,其他线程会长时间阻塞或空转。
优化:
- 锁的粒度细化:只锁那个热点 key(
lock:hot_key),不影响其他 key。 - 使用“乐观锁”替代:利用 Redis 的原子操作,不阻塞,直接让请求去查 DB,但限制并发数。
高级方案:预先热更新 + 二级缓存
这是目前大型互联网公司(如微博、抖音)处理明星婚礼、开奖等高热度事件的标准做法。
关键点: 在缓存即将失效之前,提前启动更新。
实现方式(“异步探测”模式):
- 设置缓存 TTL:60 秒。
- 后台守护线程:当发现某个 key 的 TTL 小于一个阈值(20 秒)时,不等它失效,主动去更新缓存。
- 实现方式:可以在缓存中额外存一个“逻辑过期时间”,或者利用 Redis 的 Keyspace Notification(键空间通知)监听 key 的过期事件。
为什么更“彻底”?
- 它从根本上避免了“失效瞬间”的出现。
- 即使缓存真的失效了,由于后台线程一直在守护,再次更新也非常快。
兜底方案:熔断、降级与限流
无论上述方案多完善,总有极端情况(如数据库宕机、网络抖动的雪崩),此时需要保护数据库。
-
布隆过滤器:
- 作用:在缓存之前加一道屏障,如果请求的 key 肯定不存在于数据库(如一个不存在的用户ID),直接拦截,返回空,防止无效请求打穿缓存去查库。
- 局限性:只能解决“不存在”的 key,对“存在但缓存失效”的 key 无效,需要结合其他方案。
-
本地缓存(JVM Cache / Guava Cache):
- 作用:在第一层 Redis 缓存之外,再增加一层内存缓存。
- 流程:应用启动时,将极热点的数据(如热搜榜单)加载到本地内存,请求先查本地缓存,命中则直接返回,完全不经过网络和 Redis。
- 优势:速度极快,彻底避免了对 Redis 和 DB 的冲击。
-
限流与降级:
- 对于热点 key 的查询接口,设置分布式限流(如 Sentinel、Hystrix),当并发超过阈值时,直接返回默认值(如“系统繁忙”)或兜底数据。
- 原则:宁可返回旧数据,也不让数据库被打死。
如何“彻底”解决?
没有一个方案是万能的,最“彻底”的方案是 分层组合:
| 层级 | 策略 | 目标 |
|---|---|---|
| 第一层(预防) | 永不失效 + 主动更新 | 让缓存始终保持有效,从根本上消除失效窗口。 |
| 第二层(兜底) | 本地缓存(JVM Cache) | 为最热点 key 提供保险,即使 Redis 缓存失效,也不查 DB。 |
| 第三层(并发控制) | 互斥锁(Mutex) | 二层失效时,只允许一个请求查 DB,其余等待。 |
| 第四层(防护) | 布隆过滤器 + 限流 | 过滤无效请求,并对数据库访问进行硬性限流。 |
最终答案:
想要 “彻底” 解决缓存击穿,最稳妥的方案是 “永不过期 + 主动更新” 作为主要策略,搭配 “本地缓存” 作为快速兜底,并在代码层面用 “互斥锁” 处理极低概率的并发穿透,如果还担心,再加一层 “限流” 保护数据库,通过这种分层防御,基本可以做到工程意义上的 100% 防御。