1. 项目概述:为什么缓存雪崩值得花一篇文章来聊
作为一个常年和数据打交道的人,缓存这个词我闭着眼都能说出一堆门道。但缓存雪崩不一样,它是那种你平时觉得“离我很远”,一旦发生就让人焦头烂额的故障。先讲个我自己的经历:某次大促活动前夜,运营同学在后台批量更新了一批商品状态,操作本身没问题,坏就坏在那批商品在缓存里的过期时间完全一致,结果第二天零点一过,几千个key同时过期,数据库瞬间被打到慢查询堆积,接口响应从几十毫秒涨到十几秒,监控告警响成一片。那种感觉怎么说呢,就像一群人约好了同时按电梯,结果电梯门一开,里面站着一头大象。
其实缓存雪崩的本质很简单:大量缓存在同一时刻失效,导致请求全部穿透到数据库,数据库扛不住压力,最终引发连锁故障。这里有个容易被混淆的概念,很多人把缓存击穿、缓存穿透、缓存雪崩混为一谈,其实它们是完全不同的场景。缓存穿透是查一个不存在的key,每次都会打到数据库;缓存击穿是某个热点key过期的瞬间,大量请求同时涌向数据库;而缓存雪崩是大量key同时过期,或者是缓存节点整体宕机,导致流量洪水般冲向存储层。这篇文章要聊的,就是针对雪崩场景的三种主流解法:随机TTL、缓存预热、降级策略。
这篇文章适合谁来读?如果你是后端开发、架构师、运维工程师,或者正在处理高并发系统的性能问题,那这篇内容基本就是为你准备的。即使你是刚入门不久的新人,只要你有Redis或者类似缓存组件的使用经验,也能从中获得完整可落地的方案。我会从原理讲到实践,从代码示例讲到排查思路,尽量让每个方案都能直接拿回去用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存雪崩到底是怎么发生的?核心成因拆解
2.1 同一时刻集体失效:最典型的雪崩触发场景
先把这个场景掰开揉碎了讲。假设你有一个商品详情页,上面有价格、库存、标题、图片等字段,通常的做法是把这些信息序列化后缓存到Redis里,设置一个统一的过期时间,比如3600秒。这里就埋下了一个隐患:如果所有key的过期时间都设置成一样的,那么这些key会在同一个时间点集体过期。
为什么会集体失效?因为业务代码里往往写成 set(key, value, 3600),这个3600是固定值。当几千个key都采用相同的过期时间时,它们就像同一批闹钟,到了时间点全部响起。这个时间点一到,Redis里查不到数据,所有请求都去查数据库,数据库的连接池瞬间被占满,CPU飙升,然后就是一连串的雪崩反应。
我加粗说一句:固定过期时间本身就是雪崩的温床。你可能会觉得,数据库加个连接池限制不就行了?但问题是,当请求量足够大时,连接池只是在排队,数据库实际上已经在承受海量查询的压力。真正解决问题的思路,是让key的过期时间错开,而不是让所有key都在同一时刻失效。
2.2 缓存节点宕机:比批量过期更危险的雪崩
第二种雪崩场景是Redis节点整体不可用。这种情况不常见,但一旦发生,杀伤力远大于key集体过期。因为key集体过期时,至少缓存里还有一部分数据可以命中,而节点宕机意味着所有key都查询不到,整个缓存层瞬间变成透明层,请求全部穿透到数据库。
这种场景通常发生在缓存集群没有做高可用部署,或者主节点故障后切换不及时的情况下。这里插一句,很多团队用了Redis的主从架构就以为万无一失,但如果你用的是单机模式或者是主从但没做哨兵,主节点挂了之后,从节点不会自动升级,服务照样不可用。所以高可用部署是基础,本文讲的随机TTL、预热、降级都是在这个基础上的优化手段。
2.3 更新操作引发的雪崩:被忽略的隐性风险
还有一类雪崩很容易被忽略,那就是批量更新缓存时引发的雪崩。举个例子,运营后台做了一次批量刷新,把一批商品的缓存全部删除,然后重建。如果重建逻辑是“删除后立即查询数据库并写入缓存”,那在删除和写入之间的窗口期,所有请求都会穿透到数据库。如果这批商品刚好是热门商品,流量巨大,数据库同样会被打垮。
这个场景的关键点在于:删除缓存和重建缓存不是原子操作,中间有一个时间窗口。你写代码的时候可能觉得这没什么,但高并发下,这个窗口会被放大成事故。后面讲缓存预热的时候,我会专门说如何通过预热来规避这个风险。
3. 随机TTL:用最小的成本打散过期时间
3.1 为什么固定TTL会导致整点集体失效
在讲随机TTL之前,我们先理解一下Redis的过期删除机制。Redis的key过期后,并不是立即从内存中删除,而是采用惰性删除与定期删除结合的方式。惰性删除是当key被访问时,发现过期就删除;定期删除是每隔一段时间,随机抽取一批过期key进行删除。
问题就出在这里:定期删除并不会在key过期的瞬间把它们全部清理掉,只是标记为过期。所以当你有大量key在同一时刻过期时,它们会滞留在内存中,但访问时又取不到值,于是请求全部落到数据库。这个时间点通常是非常集中的,比如整点、零点、业务高峰的起始点。
随机TTL的思路就是给过期时间加一个随机偏移量,让所有key的过期时间分散在一个区间内。打个比方,原计划是所有人都12点下班,结果楼下的电梯肯定不够用;那干脆改成11点50到12点10分之间陆续下班,这样电梯就不会挤爆。
3.2 代码实现:怎样加随机偏移才是正确的
先看一段最常见的错误示范:
java复制// 错误示范:所有key统一过期时间
redisTemplate.opsForValue().set("product::" + productId, product, 3600, TimeUnit.SECONDS);
这样写,所有商品key都会在同一时刻过期。正确做法是加上随机偏移:
java复制// 正确做法:基础TTL + 随机偏移
long baseTtl = 3600;
long randomTtl = baseTtl + RandomUtils.nextLong(0, 600); // 0~600秒随机偏移
redisTemplate.opsForValue().set("product::" + productId, product, randomTtl, TimeUnit.SECONDS);
这段代码的核心在于RandomUtils的范围选取。我建议偏移量设置在基础TTL的5%~20%之间。比如基础TTL是1小时,那随机偏移在0~10分钟比较合理。偏移太小,分散效果不明显;偏移太大,会让部分key过早过期,增加数据库负载。
但这里有一个细节容易被忽略:如果随机范围是0到N,且使用均匀分布,那么key的过期时间仍然会集中在基础TTL附近。因为0到N均匀分布时,期望值是N/2,所有key的过期时间集中在baseTtl + N/2附近。想要更均匀地分散,可以考虑使用更大的范围,或者用正态分布。不过实测下来,对于大多数业务场景,0~600秒的均匀分布已经足够打散过期时间了,实际效果远好于固定TTL。
3.3 随机TTL的边界:它解决不了Redis宕机
这里必须说清楚,随机TTL是一个成本极低但收益明显的优化手段,它解决的问题是“key集体过期导致的流量穿透”,但它解决不了Redis节点宕机导致的雪崩。因为节点宕机时,缓存里所有数据都不可用,跟过期时间无关。
所以随机TTL应该作为缓存优化的基础配置,在任何项目里都先加上。但如果你指望靠它解决所有雪崩问题,那就想得太简单了。它只是第一层防御,后面要讲的预热和降级,才是更靠后的防线。
3.4 实操心得:随机TTL的适用范围和注意事项
从实际项目经验来看,随机TTL不是什么场景都要用。对于访问频率低、数据量小的key,固定TTL和随机TTL差别不大;但对于热点key、批量写入的key、运营批量更新的key,必须用随机TTL。
另外,有一个坑是很多人会踩的:如果你在代码里用了封装好的缓存工具类,而这个工具类是统一设置过期时间的,那你要检查一下工具类是否支持传入TTL参数。很多团队里缓存工具类写得很死,只接受一个 set(String key, Object value),内部默认3600秒,这种就很不灵活。
还有一个注意点:随机TTL只是延迟了集体过期的问题,如果业务本身有固定的批量更新任务,比如每天凌晨定时把一批数据重新写入缓存,那即使你用了随机TTL,这批数据也会在写入时被统一重置过期时间,等于又把雪崩风险拉回来了。这种情况需要通过预热方案来解决。
4. 缓存预热:在流量到来之前,让数据先进缓存
4.1 预热的核心思想:把“临时抱佛脚”变成“未雨绸缪”
缓存预热的思路很简单:既然缓存的初始状态是空的,或者批量更新后缓存是空的,那我们就在流量进来之前,先把热点数据写入缓存,避免请求打到数据库。
听起来很简单,但真正落地的时候,复杂度在于三个方面:第一,要预热哪些数据?全量预热肯定不现实,硬件成本和初始化时间都扛不住。第二,什么时候预热?不能在业务高峰期预热,因为预热本身就是一批查询数据库的请求。第三,如何保证预热过程中缓存和数据库的数据一致?
先回答第一个问题。预热的数据应该是热点数据,而不是全量数据。热点数据的筛选可以基于历史访问日志、调用次数统计、运营的人工指定。比如电商场景下,可以根据近7天的商品访问量排名,把Top 1000的商品提前写入缓存;再比如资讯类应用,可以把首页推荐的新闻提前写入缓存。
4.2 三种主流预热方案及代码示例
方案一:启动时自动预热。适用于应用刚启动、缓存为空的情况。可以用Spring的ApplicationRunner或者CommandLineRunner来实现,在服务启动完成后执行一段预热逻辑。
java复制@Component
public class CacheWarmer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
log.info("start cache warm...");
// 查询热点商品列表
List<Long> hotProductIds = productService.queryHotProductIds(1000);
for (Long productId : hotProductIds) {
Product product = productService.queryFromDb(productId);
String key = "product::" + productId;
redisTemplate.opsForValue().set(key, product, calculateRandomTtl());
}
log.info("cache warm finished, size={}", hotProductIds.size());
}
}
方案二:定时任务预热。适用于有固定更新周期、但更新时间点不在业务高峰期的场景。比如大促前1小时执行预热,或者每天凌晨低峰期执行。
方案三:手动触发预热。适用于运营需要临时更新一批缓存的场景。比如运营在后台点击“刷新缓存”按钮,系统先手动触发预热逻辑,等缓存写入完成后再切换流量。
4.3 预热时的并发控制与失败补偿
预热看起来很简单,但实际执行的时候有几个坑。
第一个坑:预热任务本身会对数据库产生压力。如果你的预热逻辑是循环查询数据库,一次预1000条,每条查询耗时几十毫秒,单线程跑完要几十秒。但如果是大促前预热1万条数据,单线程可能要几分钟。这时候要考虑并发预热,比如用线程池,但并发数不宜过大,否则数据库照样被打垮。我实际项目中用的是10个线程,每批100条,基本能在十几秒内完成。
另一个坑:预热过程中,如果业务流量已经进来了,可能会出现“一边预热、一边有请求在查询数据库”的情况。这时候需要保证预热的数据尽快写入,否则预热就失去了意义。比较好的做法是先集中预热,等预热完成后再放开流量,或者在做发布操作时先切流量再预热,不要同时进行。
还有一个很关键的细节:预热的数据要设置合理的过期时间。很多人预热时直接用固定TTL,这等于把雪崩问题又带回来了。正确的做法是在预热时同样按需设置随机TTL。
4.4 预热与“缓存重建”的区别:什么时候用哪个
这里需要区分一个概念:缓存预热的典型场景是“缓存从无到有”,而“缓存重建”是缓存过期后重新写入。缓存重建通常发生在查询路径中,也就是网上常见的“Cache Aside Pattern”:
先查缓存,如果缓存没有,就查数据库,然后回填缓存。这种模式在低并发下没问题,但在高并发下,如果同一个key过期,大量请求同时发现缓存为空,就会同时去查数据库,造成缓存击穿。解决击穿的常见手段是互斥锁(Mutex),但互斥锁本身有性能和死锁风险。
预热和互斥锁可以结合使用。比如在某个活动开始前,通过预热把热key都写入缓存,这样即使活动开始后大量请求涌入,缓存命中也足够高,不会出现多个请求同时穿透到数据库的情况。
4.5 实操心得:预热脚本的幂等性和耗时控制
预热操作要保证幂等性,即重复执行不会产生副作用。最简单的方式是先删除已有缓存,再写入新值。但实际上更推荐的做法是直接覆盖写入,因为缓存本身无状态,直接写入不会影响一致性。
还有一点:预热任务的耗时一定要监控起来。如果预热超过预期时间,说明数据库查询效率有问题,或者需要预热的数据量超出了设计容量,这时候要主动调整预热策略。我踩过的坑是:某次运营批量更新了10万条商品状态,预热任务在后台跑了一个多小时,期间接口查询大量超时,后来才知道是预热时没有加索引,数据库全表扫。
5. 降级策略:雪崩不可能完全避免,但响应可以优雅降级
5.1 降级是一种“保命”策略,不是偷懒的借口
前面讲的随机TTL和缓存预热,是从源头减少雪崩发生的概率。但不管你怎么优化,总有一些不可控因素会引发雪崩,比如数据库本身慢查询、网络抖动、第三方服务响应超时。这时候如果没有降级策略,系统会直接崩溃,用户看到的就是500。
降级策略的本质是:当系统无法提供完整服务时,提供一个简化版或兜底版的服务,保证用户体验不是“完全不可用”。比如电商详情页加载失败时,可以返回一个商品图片+基础文字信息,而不是直接报错;再比如支付回调失败时,可以先返回“支付中”,等系统恢复后再通过异步任务完成后续流程。
5.2 读降级:返回默认值或历史缓存
读降级是最常用的降级方式。具体实现方式可以分三层:第一,从Redis查询,如果命中就返回;第二,如果Redis查询不到,先不急着查数据库,而是尝试从本地缓存或者历史快照中获取旧值;第三,如果旧值也没有,再查数据库,并设置一个较短的过期时间。
这里的关键点在于:在降级场景下,即使返回旧数据也是可以接受的,总比返回错误好。比如商品价格,短时间内变化不大,返回几个小时前的价格,用户是感知不到的。我在实际项目中就做过一个“兜底缓存”的机制,每次从数据库查询成功后,除了写入Redis,还会在本地JVM内存里保存一份旧值,当Redis不可用时,直接返回本地内存中的旧值。
这种做法的优点是响应时间极快,本地内存读取耗时基本是微秒级;缺点是本地内存容量有限,不适合存储大量数据,同时多节点部署时每个节点持有不同的旧值,可能出现数据不一致。但对于读多写少、对一致性要求不高的场景,是非常实用的方案。
5.3 写降级:限流和熔断,把压力挡在系统外
降级不仅仅是返回默认值,还包括限流和熔断。限流是指在系统过载时,对请求进行丢弃或排队,保护系统不被压垮。熔断是指当某个下游依赖连续失败达到阈值时,主动断开对该依赖的调用,并快速返回错误或降级结果。
以Java生态为例,常见的限流工具有Guava RateLimiter、Sentinel、Resilience4j等。我推荐Sentinel,因为它在控制台配置、规则持久化、流量分析方面做得比较完善,不用重复造轮子。
Sentinel配置一个最简单的熔断规则,大概是这样:
yaml复制resources:
- name: getProductInfo
fallbackBehavior: return_default_value
degradeRules:
- grade: 0 # 按慢调用比例
count: 200 # 响应时间超过200ms
timeWindow: 30 # 熔断30秒
minRequestAmount: 10 # 触发熔断的最小请求数
上面的配置表示,当 getProductInfo 这个接口的请求中,响应时间超过200ms的比例达到一定阈值,并且请求量超过10次时,触发熔断,后续请求直接走降级逻辑,返回默认值,30秒后尝试恢复。
写降级不是一句“返回默认值”就完事了,降级逻辑本身要快。如果你在降级逻辑里还要查数据库,那等于没降级。降级逻辑应该只做内存操作、返回预设值,或者返回一个静态的响应。
5.4 降级策略的使用边界:不能滥用降级
降级是最后一道防线,但它不能滥用。原因很简单:如果任何接口都设置降级,那系统就会变成“习惯性降级”,用户看到的永远不是最新数据,业务体验会严重下降。所以降级策略一定要有明确的触发条件,并且要配合监控告警,让运维和开发知道当前系统正处于降级状态,及时介入处理。
我在设计降级方案时,通常会把接口分类:核心交易链路不轻易降级,非核心链路可以优先降级。比如商品详情页可以降级,但下单接口不能降级;首页推荐可以降级,但支付回调不能降级。这些需要业务方和技术一起商量,形成一个明确的接口降级优先级矩阵。
5.5 实操心得:降级要提前演练,不要在故障时才想起
这里说句实在话,降级策略如果只在故障时才去想,基本就是纸上谈兵。因为降级逻辑本身也可能有bug,比如降级后返回的默认值可能引发其他依赖问题,或者降级条件设置得太宽导致正常流量也被降级了。所以一定要做降级演练。
演练的方式很简单:在测试环境,人为触发缓存不可用、数据库超时等故障,观察系统是否按照预期降级,用户看到的响应是否符合预期。我经历过的项目里,做过一次全链路压测+故障注入,模拟Redis集群宕机3分钟,结果发现有两个接口没有走降级逻辑,后来排查发现是代码里那个接口压根没接入降级组件。这种问题如果不演练,故障发生时才会暴露,那就是事故现场了。
6. 三层防御怎么配合:一套完整的缓存雪崩防护方案
6.1 各策略的职责边界与组合方式
随机TTL、缓存预热、降级策略,这三者不是非此即彼的关系,而是要组合使用。我给它们起的代号是“防”、“预”、“兜”:
- 防:随机TTL,防止key集体过期导致流量穿透。
- 预:缓存预热,防止缓存为空时流量直接打库。
- 兜:降级策略,在缓存、数据库都不可靠时,兜住用户体验。
在一个高并发系统中,这三层是同时存在的。正常情况下,请求走缓存,随机TTL保证缓存不会集体失效;缓存没命中时,数据库承担少量查询,同时触发缓存重建;当异常发生时,降级策略快速返回默认值,避免系统崩溃。
6.2 一个完整的代码组合示例
这里给一个综合示例,演示一个商品详情接口如何同时运用这三种策略:
java复制public ProductDetailVO getProductDetail(Long productId) {
String key = "product::" + productId;
// 第一层:查Redis
ProductDetailVO vo = redisTemplate.opsForValue().get(key);
if (vo != null) {
return vo;
}
// 第二层:查本地兜底缓存
vo = localCache.get(productId);
if (vo != null) {
// 异步重建Redis缓存,不回源数据库
asyncRebuildRedis(productId);
return vo;
}
// 第三层:降级策略,Sentinel熔断判断
if (sentinelDegradeManager.isDegrade("getProductDetail")) {
return defaultProductDetail(productId); // 返回默认商品信息
}
// 第四层:查数据库并回填缓存
try {
vo = productService.queryFromDb(productId);
// 回填缓存时使用随机TTL
redisTemplate.opsForValue().set(key, vo, calculateRandomTtl());
// 更新本地缓存
localCache.put(productId, vo);
return vo;
} catch (Exception e) {
// 数据库异常时,也走降级
log.error("query product detail failed, id={}", productId, e);
return defaultProductDetail(productId);
}
}
这个代码示例是一个典型的组合方案,你可以根据实际业务结构调整每一层的逻辑,但核心思路是:尽量让请求停留在缓存层,回源数据库是最后手段。
6.3 方案选型的取舍与适用场景
有时候你会发现团队资源有限,不可能把这三个策略全部落地。那就需要做一个优先级判断。我的建议是:
- 如果系统已经有缓存,但还没有任何防护措施,先做随机TTL,成本最低、收益最快。
- 如果有大促、秒杀、活动预热等场景,必须做缓存预热。
- 如果业务对稳定性要求极高,且能接受返回旧数据,那就做降级策略。
从成本上看,随机TTL基本是零成本;预热需要写额外的任务和脚本,成本中等;降级需要接入限流熔断组件,成本较高。你可以根据自己的项目情况,从第一层开始逐步升级。
7. 常见问题与排查技巧实录
7.1 典型问题速查表
整理几个我在实践过程中常遇到的问题和排查思路,直接做成表格,方便你对照:
| 问题现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 缓存命中率突然下降,数据库QPS飙升 | 大量key同时过期 | 查看Redis过期key数量、monitor命令观察过期key | 随机TTL |
| 某个热点key一过期,数据库瞬间被打爆 | 热点key击穿 | 查看日志,是否有大量同一key的查询 | 热点key永不过期+后台更新,或者互斥锁 |
| Redis节点宕机后,数据库直接被压垮 | 缓存层整体不可用 | 检查Redis集群监控、哨兵切换日志 | 高可用架构+降级策略 |
| 预热任务执行时,接口变慢 | 预热逻辑占用了数据库连接 | 查看慢SQL,定位预热代码 | 预热分流、降低预热并发数 |
| 降级策略触发了,但用户还是报错 | 降级逻辑本身有bug | 查看降级逻辑日志,看是否走了默认值分支 | 修复降级逻辑,增加降级演练 |
7.2 监控指标与告警配置要点
做缓存优化,没有监控等于盲人摸象。至少需要监控这几个指标:
- 缓存命中率:正常应该在90%以上,低于阈值就告警。
- Redis内存使用率:超过80%要扩容或清理。
- 数据库QPS:相比正常水平是否有突增。
- 接口平均响应时间:响应时间变长往往是雪崩的前兆。
- 缓存过期key的数量:如果某个时刻大量key集中过期,就能提前发现雪崩风险。
监控工具可以用Prometheus+Grafana,也可以直接用云厂商提供的监控能力。告警阈值要根据业务特点设置,不要设得太灵敏,不然每天报警都处理不完,也不要设得太迟钝,否则发现时已经晚了。
7.3 排障实操:一次真实的雪崩排查过程
最后分享一次实际的排查过程。当时我们的系统在每晚凌晨0点准时出现一波数据库慢查询,持续时间大概1分钟,之后就自动恢复了。一开始以为是数据库本身的问题,但排查了好几轮,数据库配置、慢SQL都没问题,最后通过监控Redis的过期key才发现,是定时任务在每晚0点批量刷新缓存,把所有key的过期时间都重置成了相同的值。
那次的修复方案很简单:把批量刷新逻辑改成逐条写入,同时给每个key加上随机TTL。另外在定时任务执行前,主动触发一次缓存预热,确保任务执行完成后缓存里已经有数据。
从那以后我就养成了一个习惯:每次上线涉及缓存变动的代码,都会先检查一下缓存key的过期时间是否合理、批量操作是否会引发雪崩,并且确保监控告警覆盖到位。
8. 最后再分享一个小技巧
关于缓存雪崩这个话题,网上已经有很多资料,但真正在线上跑过的人会告诉你,理论写得再好,不如把细节抠到位。我最后想分享一个很实用的小技巧:在写所有涉及批量操作的代码之前,先问自己三个问题——这批操作写入的缓存key有多少个?它们的过期时间是否相同?如果它们同时失效,数据库扛得住吗?这三个问题想清楚,很多雪崩故障其实是可以提前避免的。
另外,如果你现在维护的是一个老系统,缓存代码是历史代码,不敢轻易改动,我建议你可以先从监控入手,把缓存命中率、数据库QPS、Redis过期key数量的监控指标先建立起来,然后再逐步改造。有数据支撑的优化才是有底气的优化,光靠感觉去做缓存优化,迟早会踩坑。
缓存雪崩不是一个需要用到“高深算法”才能解决的问题,它更考验的是你对系统整体架构的理解,以及对每一个细节的重视程度。希望这篇内容能帮你在自己的项目里少踩几个坑。
