缓存雪崩这事儿,但凡在大厂扛过线上流量的人多少都遇到过几回。它不像缓存穿透那样单个key打过来,也不像击穿那样只集中在一个热点key上,雪崩是大量key在同一时段集体失效,流量像决堤一样直接灌到数据库上,轻则接口超时、重则整个服务被打挂。这篇东西我不打算讲太多理论,就围绕实际场景把预防、处理、恢复这条链路完整拆一遍——从为什么会发生,到怎么设计缓存过期策略,再到真出事了怎么止损,最后附上一套可以直接抄的落地代码。
如果你正在做缓存治理、Redis相关开发,或者面试前想把这部分知识理清楚,这篇值得通读一遍。别的不说,把雪崩按“事前预防、事中兜底、事后恢复”三层思路走,基本不会慌。
1. 缓存雪崩的完整画像:从现象到底层逻辑
1.1 雪崩是怎么发生的:先理解缓存过期的连锁反应
缓存雪崩的核心触发条件就是:大批key在同一时间段内集中过期。举个案例,某电商平台每年大促零点开场,运营把一批热门商品信息都设置了统一的过期时间,比如0点整点过期。到了0点那一刻,Redis里几千上万个key同时过期,缓存命中率瞬间从95%掉到接近0,所有查询请求直接穿透到MySQL。
更麻烦的是,这种冲击不是一次性的。数据库连接池被打满后,其他正常查询也开始排队,Redis本身虽然没有挂,但应用层因为等待数据库响应超时,线程被一个个占满,最后整个应用集群的可用性跟着崩了。这才是“雪崩”二字的真实含义:不是缓存挂了,而是缓存能力瞬间归零,连带整个系统发生连锁故障。
从底层来看,雪崩和两个因素强相关:过期时间设置和并发请求量。过期时间越整齐、并发量越大,冲击越大。如果只是少量key过期,数据库还能扛住,但一旦数量级上来,数据库写入、查询、锁竞争全都会被放大。
1.2 为什么说缓存雪崩是“最贵”的故障类型
我做过的故障复盘里,雪崩属于罕见的能让整套系统“全红”的问题。缓存穿透是打一个打不中,数据库还能硬扛;击穿是只打一个热点key,加锁就能挡住;但雪崩是整个缓存层失效,相当于防线直接撤了。
最贵的不是性能损耗,而是故障恢复时间。雪崩之后不是简单把缓存重建就行——因为重建过程中新的请求还在不断穿过缓存打向数据库,你一边重建、一边被打,数据库很难从高负载中恢复。这就需要一个“一边挡流量、一边慢慢回填”的复杂过程,而这个过程中稍有不慎就是二次雪崩,导致故障时间被无限拉长。
我见过一次真实案例:缓存重建方案没设计好,直接全量扫描数据库重建key,结果重建任务本身把数据库CPU打满了,线上故障持续了近一个小时。所以处理雪崩,不能“莽”,要“柔”。
1.3 穿透、击穿、雪崩:三者到底怎么区分
很多人面试的时候被问这三个概念,脑子里有印象但说不透。为了后面几节讲预防方案时大家能对号入座,先花一小段把三者彻底分清:
- 缓存穿透:查询一个根本不存在的数据,缓存和数据库都没有,每次请求都打到数据库。特点是“查了个寂寞”。
- 缓存击穿:某个热点key在过期的瞬间,大量并发请求同时落库。特点是“热点key失效”。
- 缓存雪崩:大量key同时过期,或者Redis节点整体宕机。特点是“大面积失效”。
三个问题的解法各有侧重。穿透用布隆过滤器或者缓存空值;击穿用互斥锁或者逻辑过期;雪崩则是从过期策略、缓存架构、系统兜底三个层次去解决。这里先把概念对齐了,下面讲方案才不会串。
2. 预防方案一:从过期策略上“拆掉”雪崩的引信
2.1 过期时间加随机因子:最基础也最有效的一招
既然雪崩的根源是key同时过期,那最简单的做法就是让过期时间错开。具体来说,在统一过期时间的基础之上,给每个key的过期时间加上一个随机数,让过期时间在某个区间内均匀散开。
比如原本全部设置10分钟过期,现在设置为10分钟加上0到300秒的随机值。这样从全局角度看,每分钟过期的key数量被摊薄,数据库每分钟承受的穿透量也在可控范围内。
代码层面其实就一行:
// 基础过期时间 + 随机偏移量,避免缓存同时失效 long baseTtl = 600L; long randomOffset = ThreadLocalRandom.current().nextLong(300L); String cacheKey = "product:detail:" + productId; redisTemplate.opsForValue().set(cacheKey, productJson, baseTtl + randomOffset, TimeUnit.SECONDS);别小看这个随机因子,它不需要引入任何额外组件,就是一行代码的量级,却能消除掉80%的雪崩风险。唯一要稍微注意的,是对一些需要精确控制过期时间的场景(比如定时任务依赖缓存过期来触发),随机化会带来一点不确定性,但这种场景属于少数,可以单独特殊处理。
2.2 热点数据不设过期:用逻辑过期代替物理过期
随机因子能解决“批量同时过期”的问题,但解决不了“热点key过期瞬间被打穿”的问题。对于真正的热点数据,我比较推荐另一种方案——热点key不设置物理过期时间,而是把过期时间作为value里的一个字段存起来,由应用层在读取的时候自行判断。
这就是常说的“逻辑过期”。具体做法:
- 缓存写入时,TTL字段存的是过期时间戳,而不是让Redis直接控制过期。
- 读取时,如果发现TTL已经到了,不直接删除key,而是先返回旧数据给调用方,同时异步去更新缓存。
- 更新期间,其他请求读到的还是旧值,数据库压力被严格限制住。
这种方案的收益非常明显:物理上key永不过期,就永远不会发生“集中过期”的情况;逻辑上数据又是“新鲜”的,最多就是窗口期内读到毫秒级的旧数据。对于商品详情、配置信息这类对实时性要求不那么苛刻的场景,这招堪称完美。
有自己的体会是:逻辑过期方案唯一的坑是异步更新任务的并发控制。如果多个请求同时发现缓存过期,不能每个请求都去触发异步更新,否则热点key的更新压力还是会被放大。需要在更新逻辑里加一个分布式锁或者用一个简单的状态标记,保证只有第一个发现过期的人负责更新,其他人继续读旧值。
2.3 多级缓存兜底:给Redis前面再加一道防线
过期时间随机化是“降低单一时刻的冲击量”,但面对极端流量还是不够。再往上走一层,考虑做多级缓存。
所谓多级缓存,就是在Redis之前再加一层本地缓存(比如Caffeine或者Guava Cache)。热点的数据同时存在于本地缓存和Redis中。当Redis大批量过期时,请求至少还能打到本地缓存上,不会全部涌入数据库。而且本地缓存的过期时间可以设置得比Redis更长,形成时间差上的双层保护。
大致的读取路径:
- 先查本机本地缓存,命中则直接返回。
- 未命中则查Redis,命中则回填本地缓存。
- Redis未命中则查数据库,回填两级缓存。
把这种结构画出来看,整体链路里有一半的流量在本地缓存就被拦截了。Redis失效的影响不再是灾难性的,只是退化成“本地缓存+数据库”的降级模式。
要注意的是,本地缓存有几个天生的坑:一是数据一致性弱,更新DB后需要主动淘汰或者容忍短时间不一致;二是内存有限,不适合放大体量数据。解决方案一般是只对真正的热点数据做本地缓存,且设置合理的最大容量和淘汰策略。
3. 预防方案二:让系统在极端情况下的“底牌”更厚
3.1 数据库侧限流与熔断:扛不住的时候先保命
不管缓存策略设计得多好,总会有极端场景突破预期。比如Redis节点整个宕机,或者某个抢购活动热度远超预估,直接打穿全部缓存层。这时候最后一道防线就是保护数据库。
常规做法是三个词:限流、熔断、降级。
一个是Redis节点整个宕机。针对这种情况,Redis侧至少要保证高可用架构:
- 主从模式做数据冗余,主节点挂了立刻提升从节点;
- 哨兵模式(或云厂商的托管高可用方案)负责自动故障转移;
- 实在要求高,就上Redis Cluster,分片存储 + 多副本,单节点故障对业务影响被限制在很小的范围内。
很多时候雪崩确实是从“cache层集体失效”开始的,但现实中我也见过“Redis进程还活着、连接却大量超时”的情况——CPU被打满、内存不够触发淘汰、慢查询堆积,表现和雪崩几乎一模一样。所以主从和哨兵不仅是数据安全的需求,更是缓存可用性的底线。
3.3 压测与预案:不演练的防护等于没有防护
最后讲一个很多人忽略的预防步骤:故障演练。很多团队把过期时间随机化、多级缓存、熔断降级都做了,但真出事的时候还是手忙脚乱,为什么?因为没有演练过,操作手册是纸上谈兵。
我比较推荐每季度做一次缓存故障演练,场景就选“Redis集群不可用5分钟”或者“缓存命中率降到10%”。演练过程中重点验证:
- 限流规则是否生效,数据库连接池是否还在安全水位;
- 降级接口是否返回了兜底数据而不是报错;
- 报警通知链路是否及时;
- 运维同学的执行链路是否符合SOP。
只有真正演练过,你才知道哪些环节是链路里最脆弱的部分。我见过有团队在演练时发现“降级白名单配置错了导致业务直接报错”的经典案例,这种问题如果不演练,就是等着线上出事。
4. 雪崩发生之后的止损与恢复:手把手全流程
4.1 第一步:快速止损,先止血再治病
就算所有预防都做到位了,也得准备一套发生雪崩后的处理流程。第一个原则是:不要先想怎么恢复缓存,先想怎么保住数据库。
止损动作按优先级排列:
- 开启接口限流,把超出处理能力的请求直接丢弃或返回降级提示;
- 开启服务降级,比如商品详情页切换到静态版本、推荐列表返回默认数据;
- 必要时关闭非核心功能,比如评论、收藏这类流量,把资源让给主链路;
- 如果数据库连接池已经告警,立即扩容数据库只读实例,或者把写操作摘掉。
这里有个经验:损坏一旦开始,数据库的连接数会以指数速度增长,止损每多花一分钟,恢复时间就会多花十分钟。所以止损指令下达要快,动作宁可“过度”也不要“不足”。
4.2 第二步:缓存重建,不要“一把梭”全量回填
缓存失效之后必然要重建。很多人的第一反应是“写一个任务把所有key重新刷一遍”,这恰恰是最大的坑。全量回填引发的压力,可能比业务穿透带来的压力还大。
正确做法是“渐进式回填”:
- 先把流量限到数据库能承受的阈值之下;
- 再开启回填任务,按照key的优先级和访问热度分批重建;
- 先回填访问量最高的前10%的热点key,让这部分流量先从数据库切回Redis;
- 每回填一批,观察数据库负载在下降,再继续下一批。
这个过程像什么呢,就像给一个贫血的病人输血,得慢慢输,不能一袋全倒进去。我操作过的恢复流程,20万key的回填通常分5到10批,每批间隔几十秒,全程盯着数据库QPS和连接数曲线。
另外,如果使用了第一节说的“逻辑过期”方案,恢复会更从容:旧数据还在Redis里,直接异步把新数据刷进去就行,业务甚至感知不到这段故障。
4.3 第三步:根因复盘,别让同一条河淹第二次
故障恢复只是“治标”,如果没有根因复盘,下次大概率换个姿势再来一次。复盘环节,我建议一定要回答四个问题:
- 为什么这些key会同时过期?
- 为什么限流/降级没有更早介入?
- 为什么数据库没有扛住这个量级?
- 下次如何在更短时间内发现并处理?
复盘不要搞成追责会,重点是找出系统性的漏洞。很多时候,雪崩的根因并不在缓存本身,而是业务侧把“缓存过期时间”当成了一个随意填写的参数,没有人审核和约束,导致大量key都填了同样的值。这种问题就需要在研发规范层面解决,而不是在故障现场硬扛。
5. 一套可落地的完整缓存雪崩防护代码
聊了这么多分析和理论,把关键落地方案用代码串一遍。我基于Spring Boot + RedisTemplate的场景,给出一个缓存读取的标准模板,里面集成了多级缓存、逻辑过期、互斥锁重建和限流熔断的关键逻辑。
5.1 多级缓存 + 逻辑过期的核心读取逻辑
@Service public class ProductCacheService { @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private ProductMapper productMapper; // 本地缓存:最大1万条,expire 5分钟 private final Cache<String, Product> localCache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); private static final String CACHE_KEY_PREFIX = "product:detail:"; private static final long LOGIC_TTL_MS = 10 * 60 * 1000L; public Product getProductById(Long productId) { String cacheKey = CACHE_KEY_PREFIX + productId; // 1. 查本地缓存 Product localProduct = localCache.getIfPresent(cacheKey); if (localProduct != null) { return localProduct; } // 2. 查Redis,反序列化 String json = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(json)) { CacheWrapper<Product> wrapper = JSON.parseObject(json, new TypeReference<CacheWrapper<Product>>() {}); // 3. 逻辑过期判断:没过期直接返回 if (wrapper.getExpireAt() > System.currentTimeMillis()) { localCache.put(cacheKey, wrapper.getData()); return wrapper.getData(); } // 4. 已经逻辑过期,触发异步重建,先返回旧数据 asyncRebuildCache(cacheKey, productId); return wrapper.getData(); } // 5. 缓存未命中,互斥锁控制重建 return loadFromDbAndRebuild(cacheKey, productId); } private Product loadFromDbAndRebuild(String cacheKey, Long productId) { String lockKey = "lock:" + cacheKey; boolean locked = tryLock(lockKey); if (!locked) { // 拿不到锁说明别人正在重建,短暂sleep后重试 sleep(50); return getProductById(productId); } try { // 二次查缓存,防止重复田库 String json = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(json)) { return JSON.parseObject(json, new TypeReference<CacheWrapper<Product>>() {}.getData()); } Product product = productMapper.selectById(productId); if (product != null) { CacheWrapper<Product> wrapper = new CacheWrapper<>(product, System.currentTimeMillis() + LOGIC_TTL_MS); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(wrapper)); localCache.put(cacheKey, product); } else { // 缓存空值,防止穿透 redisTemplate.opsForValue().set(cacheKey, "", 60, TimeUnit.SECONDS); } return product; } finally { unlock(lockKey); } } private void asyncRebuildCache(String cacheKey, Long productId) { // 用线程池 + 状态标记,保证同key只有一个异步重建任务 rebuildExecutor.execute(() -> { String lockKey = "rebuild:" + cacheKey; if (tryLock(lockKey)) { try { Product product = productMapper.selectById(productId); if (product != null) { CacheWrapper<Product> wrapper = new CacheWrapper<>(product, System.currentTimeMillis() + LOGIC_TTL_MS); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(wrapper)); localCache.put(cacheKey, product); } } finally { unlock(lockKey); } } }); } }这套代码的核心点总结一下:
- 本地缓存挡掉了很大一部分重复请求;
- 逻辑过期让key永远不会物理消失,旧数据永远兜底;
- 互斥锁保证缓存未命中时只有一个线程重建,避免击穿;
- 异步重建不阻塞主链路,过期瞬间用户无感知。
注意RedisTemplate和Caffeine的依赖需要提前在工程里配好,这里不再赘述。
5.2 并发场景下的缓存手动清理与防抖动
代码之外的配套手段也不能少。一个是缓存手动清理通道,当运营批量修改商品价格、库存后,能主动删除对应缓存,不需要等TTL自然过期。清理时带上版本号更稳妥:
// 生成新版本号,让旧缓存立即失效,并且不会出现并发写导致的覆盖 String version = UUID.randomUUID().toString(); redisTemplate.opsForValue().set("product:version:" + productId, version); redisTemplate.delete("product:detail:" + productId);另一个是防止“缓存击穿”在重建瞬间造成的抖动,可以在网关层针对热点接口设置限流阈值,比如商品详情接口单机QPS超过2000就进入排队模式,超过3000直接返回默认缓存快照。这块用Sentinel或者网关自带的限流策略都能实现。
5.3 缓存监控大盘:用指标驱动治理
最后,代码和策略到位了,不代表可以高枕无忧。一定要建立一套缓存健康度监控大盘,至少包含以下指标:
| 指标 | 说明 | 建议阈值 |
|---|---|---|
| 缓存命中率 | 全局命中率,下降说明缓存效益变差 | 低于85%触发报警 |
| 过期key数量曲线 | 单位时间内过期key数量 | 出现陡增需关注 |
| Redis内存使用率 | 接近上限会触发淘汰策略 | 超过80%预警 |
| 数据库QPS变化 | 缓存失效直接反映为DB流量上升 | 超过日常1.5倍报警 |
| 接口平均响应时间 | 雪崩前兆,RT会提前变长 | 连续3分钟超阈值报警 |
监控的价值在于,很多雪崩在正式爆发前几分钟是有征兆的,比如过期key曲线开始陡增、DB的QPS开始爬坡。如果能提前一两分钟发现问题,就有机会在流量完全冲垮之前把限流打开。这也是“预防”和“止损”之间最重要的一道缓冲。
6. 常见问题与避坑经验:这些坑我替你们踩过了
6.1 高频排查速查表
把日常工作中经常遇到的问题整理成表格,方便大家排查时快速对照。
| 故障现象 | 可能原因 | 排查思路 |
|---|---|---|
| 缓存命中率骤降 | 大量key集中过期 | 检查过期时间分布,是否用了统一TTL |
| Redis连接数暴涨 | 缓存失效引发大量重建 | 看DB QPS曲线,确认是否已穿透到数据库 |
| 接口超时但Redis正常 | 数据库连接池被占满 | 查连接池监控,看活跃连接数是否打满 |
| 异步重建任务堆积 | 热点key过多,更新跟不上 | 看线程池队列长度,增加重建并发度 |
| 缓存数据不一致 | 逻辑过期窗口期内读到旧值 | 评估业务容忍度,必要时采用Binlog订阅同步更新 |
6.2 我踩过的几个真坑
第一个坑:随机过期时间被“随机”掉了。当初上线随机TTL方案时,有同学把随机区间写成了ThreadLocalRandom.current().nextLong(300),但因为没加种子范围的意识,导致随机出来的值分布不均,大量key还是集中在几个时间点过期。后来排查才发现,JVM的随机数生成在高并发下短时间内的分布并没那么理想,更好的做法是用nextLong(min, max)显式指定区间,并且区间范围拉大到TTL的50%以上。
第二个坑:重建线程池直接打满。做异步重建时,第一版用的是普通线程池,结果大量热点key同时过期,重建任务瞬间几万个堆积,线程池里的任务排起长队。后来改成“重建任务合并”:同一个key只保留一个重建任务,新触发请求发现已有重建任务就直接丢弃。这招对线程池压力缓解非常明显。
第三个坑:降级预案误伤了正常流量。一次演练时把商品详情页降级切成了静态页,结果大部分正常请求也拿到了旧数据,用户侧开始投诉。后来给降级加了一个前置判断:只有系统处于“雪崩状态”(比如DB QPS超过阈值持续1分钟)才降级,正常状态下不做任何干预,宁可慢一点,也不能乱降级。
第四个坑:Redis侧限流和业务限流搞混了。限流的对象应该是“对数据库的访问”,而不是“对Redis的访问”。曾经有同学把限流加在Redis命令上,导致缓存穿透没挡住,业务流量倒是被限没了。正确的限流位置,是缓存未命中之后、访问数据库之前。
6.3 最后分享一个实用的小技巧
除了上面所有方案,个人强烈建议给缓存Key的命名加一个“版本号”或者“业务标识”。比如商品详情从v1改到v2,缓存前缀从product:detail:升级为product:detail:v2:。这样上线新逻辑时可以直接切换新前缀,旧前缀的缓存等待自然过期就行,不用手动清理,也避免了新旧数据格式不一致导致的兼容问题。
这个习惯在微服务架构里尤其重要,因为不同服务可能同时读写同一个Redis,一旦数据结构变更,没有版本隔离大概率出线上事故。
从我做缓存治理的经验来看,雪崩不是运气问题,它是“过期策略不合理 + 兜底措施缺失 + 应急预案不落地”这三者的组合体。把过期时间摊开、把缓存层级做厚、把限流熔断练熟、把重建节奏放稳,这套组合打下来,不敢说永远不出事,但出事也一定是最小代价的那一种。