本文目录导读:

这是一个非常经典且具有挑战性的分布式系统问题,开源缓存(如 Redis、Memcached)的失效问题,核心在于数据一致性和缓存穿透/雪崩的防护。
解决思路分为两大层面:缓存本身的失效策略 和 应对失效后的并发/流量冲击。
下面梳理出主流的解决方案和最佳实践。
核心问题:缓存为什么会失效?
- 内存淘汰:缓存满了,根据 LRU(最近最少使用)、LFU(最不经常使用)等算法主动删除。
- 过期删除:设置了 TTL(生存时间),时间到了自动删除。
- 主动失效:数据更新时,为了保持一致性,主动删除或更新缓存。
- 服务宕机:缓存服务挂了,所有缓存丢失。
针对不同失效场景的解决方案
场景1:缓存雪崩(大量缓存同时失效 + 高并发流量)
表现:大量缓存集中在同一时间过期,或缓存服务宕机,所有请求瞬间打到数据库,导致数据库崩溃。
解决方案:
- 过期时间加随机值:避免大量 key 在同一时间过期,比如原本设置 1 小时,实际设置为
60min + random(0, 5min)。 - 多级缓存:使用本地缓存(如 Caffeine、Guava Cache)作为第一道防线,Redis 作为第二道防线,数据库作为最后防线,本地缓存即使失效,也能拦截掉一部分流量。
- 缓存高可用:使用 Redis Sentinel 或 Redis Cluster,避免单点故障。
- 限流 & 降级:在缓存失效后,对访问数据库的请求进行限流(如使用令牌桶、漏桶算法),部分请求直接降级返回默认值或错误提示(如“活动火爆,请稍后再试”)。
场景2:缓存穿透(查询一个根本不存在的数据)
表现:大量请求查询一个在数据库和缓存中都不存在的 key,每次都会绕过缓存直接打到数据库,恶意攻击常用手段。
解决方案:
- 缓存空对象:如果数据库查询结果为空,也把这个“空结果”缓存起来(TTL 设置短一些,5 分钟),这样后续相同的请求会直接返回空值,保护数据库。
- 缺点:占用内存,且可能导致短期数据不一致(真实的 key 被写入后,空缓存还未过期)。
- 布隆过滤器:在缓存前端加一层布隆过滤器,如果一个 key 不存在,布隆过滤器直接返回不存在,请求不再穿透到缓存和数据库,这是最有效的方案。
- 缺点:有一定误判率(不存在的可能误判为存在),需要维护布隆过滤器的数据同步。
场景3:缓存击穿(一个热点 key 过期)
表现:一个非常热点的 key 在过期的一瞬间,大量并发请求同时发现这个 key 失效了,全部去重建缓存,瞬间打垮数据库。
解决方案:
- 互斥锁(Mutex Lock):当缓存失效时,只有第一个请求能获得锁并去数据库查询,其他请求等待,第一个请求查询成功后,将数据写回缓存并释放锁,后续请求直接从缓存读取。
- 实现方式:Redis 的
SETNX(SET if Not eXists)命令,或 Redisson 的分布式锁。 - 优点:保证数据强一致性。
- 缺点:可能引入死锁风险,效率降低(请求串行化)。
- 实现方式:Redis 的
- 逻辑过期:在缓存对象中额外存储一个逻辑过期时间,而不是让 Redis 物理过期。
- 开启一个后台线程,定期检查逻辑时间是否过期,如果过期则异步去更新缓存。
- 实现方式:
String value, Long timer,后台线程使用互斥锁控制只更新一次。 - 优点:不会阻塞用户请求,用户体验好。
- 缺点:数据在逻辑过期和后台更新完成之间存在短暂的不一致(最终一致性)。
数据库与缓存数据一致性问题(缓存更新策略)
除了失效,更重要的是如何更新,这是最难的部分,因为涉及分布式事务。
常用策略(推荐组合)
-
Cache Aside Pattern(旁路缓存)—— 最常用
- 读:先读缓存,有则返回;没有则读数据库,写入缓存,返回。
- 写:先更新数据库,再删除缓存。
- 为什么不用“先更新缓存”? :因为并发写可能导致脏数据;数据库和缓存是两个不同的事务,难以保证原子性。
- “先删缓存,再更新数据库”的坑:高并发下,A 删缓存后,B 查到旧数据并写入缓存,导致缓存中是旧数据。
- 最佳实践:先更新数据库,再删除缓存,即使删除失败,后续读请求会从数据库拉取并更新(有一定的短暂不一致),可以使用延时双删(更新前删一次,更新后等几百毫秒再删一次)来降低风险,但建议用 binlog(二进制日志)监听。
-
Read/Write Through Pattern(读写穿透)
- 缓存层封装了数据源的逻辑,应用程序只和缓存交互,缓存负责与数据库同步。
- 优点:对应用层透明。
- 缺点:实现复杂,需要改造缓存中间件。
-
异步缓存更新(最终一致性方案)
- 方案:应用只更新数据库,不直接操作缓存,通过监听 MySQL 的 binlog(如使用 Canal 中间件),解析变更日志,然后异步更新 Redis。
- 优势:
- 解耦:应用无需关心缓存逻辑。
- 可靠:即使更新缓存失败,可以通过消息队列重试,保证最终一致性。
- 高性能:写操作只需一次数据库写入。
- 劣势:引入 Canal 和 MQ(消息队列),系统复杂度增加;存在短暂的最终不一致(秒级延迟)。
最佳实践组合拳
在实际的生产环境中,通常不会只用一种方案,而是组合使用。
| 问题 | 解决策略 | 核心工具/思想 |
|---|---|---|
| 雪崩 | 过期时间加随机值 + 多级缓存 + Redis 高可用 + 限流 | 打散过期时间、分层、冗余 |
| 穿透 | 缓存空对象 + 布隆过滤器 | 防止无效请求直达 DB |
| 击穿 | 互斥锁 或 逻辑过期 | 防止热点 key 重建风暴 |
| 一致性 | 更新 DB 后删除缓存 + 延时双删 / 异步 binlog 监听 | 保证最终一致性 |
一个推荐的“防失效能打满”架构:
- 前端:使用布隆过滤器拦截掉绝大多数穿透请求。
- 网关/应用层:配置限流(如基于漏桶的 Nginx + Lua 或 Sentinel)。
- 应用层:
- 使用本地缓存(Caffeine)作为一级缓存,TTL(生存时间)极短(如秒级)。
- 使用 Redis 作为二级缓存。
- 热点 key 防击穿:使用逻辑过期或互斥锁(建议逻辑过期,因为不阻塞)。
- 缓存更新:使用 Canal + MQ 异步监听 binlog 更新缓存(保证最终一致性)。
- 底层:MySQL 数据库 做好读写分离、连接池、慢查询优化。
这套组合拳可以在大多数场景下,将缓存失效对后端数据库的冲击降低到最小。