缓存雪崩是Redis使用过程中最容易被忽视、但破坏力最大的一类问题。想象这样一个场景:你有一个电商商品缓存服务,几百万个商品的缓存都在凌晨三点集中写入,并统一设置了24小时的过期时间。到了第二天凌晨三点,这几百万个key在几秒内集中失效,所有请求瞬间穿透到数据库,数据库连接池被打满,服务雪崩式连锁崩溃。而这个悲剧的根源,可能只是因为设置TTL时少写了一行随机数。本文就来详细拆解缓存雪崩的成因,并重点讲清楚随机过期时间这个最实用的预防手段。

缓存雪崩的成因:固定过期时间为什么危险
缓存雪崩的定义很直接:同一时间段内大量缓存key集中失效,导致请求全部落到数据库,数据库压力激增甚至宕机。注意这里的关键词是“大量”和“集中”。如果只是零散的key过期,请求打到数据库后重建缓存,系统完全能扛住,这不叫雪崩。
那什么情况下会出现集中失效?最常见的就是批量写入加固定TTL。比如下面的代码:
// 危险写法:所有key都设置固定3600秒过期
public void cacheProducts(List<Product> products) {
for (Product p : products) {
String key = "product:" + p.getId();
redisTemplate.opsForValue().set(key, toJson(p), 3600, TimeUnit.SECONDS);
}
}
这段代码本身没有语法问题,逻辑也正确,但埋下了一个定时炸弹。假设批量导入10万个商品,所有key在同一毫秒写入,又在同一个毫秒过期。过期后的第一波请求会把10万次查询直接砸向数据库。如果这个批量任务每天定时执行,那么每天同一个时间点都会上演一次“精准爆破”。
另一个成因是Redis实例本身不可用,比如主节点宕机且还没完成主从切换,这段时间内所有缓存都“失效”了,效果等同于全量key同时过期。不过这种情况要靠高可用架构解决,和随机过期时间关系不大,后面会单独提一下应对思路。
随机过期时间:把过期时刻打散的核心方案
既然问题是“集中”,那解法自然是“打散”。具体做法是:在设置TTL时,叠加一个随机偏移量,让每个key的过期时间分布在一个时间窗口内,而不是精确落在同一个时间点。
比如基础过期时间为1小时,再叠加0到30分钟的随机数,最终的TTL在3600秒到5400秒之间随机分布。这样即使几十万个key同时写入,它们的过期时刻也会分散在长达30分钟的时间段里,数据库的瞬时压力被摊平了。代码改造非常简单:
import java.util.concurrent.ThreadLocalRandom;
public void cacheProduct(Product p) {
String key = "product:" + p.getId();
// 基础过期时间1小时,随机增加0~30分钟
int baseTtl = 3600;
int randomTtl = ThreadLocalRandom.current().nextInt(0, 1800);
redisTemplate.opsForValue().set(key, toJson(p), baseTtl + randomTtl, TimeUnit.SECONDS);
}
用Python实现也是同样的思路:
import random
import redis
import json
r = redis.Redis(host='127.0.0.1', port=6379)
def cache_product(product):
key = f"product:{product['id']}"
base_ttl = 3600 # 基础过期时间1小时
jitter = random.randint(0, 1800) # 随机偏移0~30分钟
r.set(key, json.dumps(product), ex=base_ttl + jitter)
关于随机偏移量的取值,有几个实践经验可以参考。偏移量一般设置为基准TTL的10%到50%。偏移太小,打散效果不明显;偏移太大,则缓存平均有效期变短,命中率会受影响。比如基准TTL是10分钟,偏移量给1到5分钟就比较合适。另外,随机范围的下界不必从0开始,可以写成base + random(min, max),根据业务对数据新鲜度的容忍度灵活调整。
还有一种进阶做法是在写入时间上做随机,而不是在TTL上做随机。比如批量任务写入时,每个key之间随机sleep几十毫秒,把写入时刻错开。两种方式效果等价,TTL随机的方式实现更简单,也更容易控制整体的时间窗口,一般推荐前者。
随机过期时间只解决一半问题:完整的雪崩防御体系
必须明确一点:随机过期时间只能解决“key集中过期”这一种雪崩场景。如果Redis实例整体挂掉,所有请求照样会砸向数据库,这时候需要的是其他配套手段,一个健壮的缓存体系应该多层防御。
第一层是熔断降级。当数据库压力达到阈值时,主动熔断对数据库的查询,返回默认值或降级数据。比如商品详情页在缓存不可用时,返回一个简化版的静态页面,虽然信息不全,但至少服务不死。可以借助Sentinel或Hystrix这类熔断组件实现,也可以自己在数据库访问层加并发限流,比如同一时刻最多允许50个线程在查库,其余请求快速失败。
第二层是多级缓存。在Redis前面再加一层本地缓存,比如Caffeine或Guava Cache。Redis挂掉时,本地缓存还能顶一段时间,为故障恢复争取窗口。本地缓存的过期时间同样要设置随机值,否则只是把问题从Redis层挪到了JVM层:
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
Caffeine<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
// 过期时间同样叠加随机值,避免本地缓存集中失效
.expireAfterWrite(Duration.ofSeconds(600 + ThreadLocalRandom.current().nextInt(300)))
.build();
第三层是缓存永不过期加异步更新。对更新频率低的数据,可以不设置TTL,逻辑过期时间存到value里。读取时发现逻辑上已过期,就返回旧数据,同时异步起一个线程去重建缓存。这样任何时刻数据库都不会承受突发读压力,代价是可能读到略旧的数据,需要业务能容忍。
最后从架构层面看,Redis本身的高可用也不能省。主从加哨兵,或者直接上Cluster集群,保证单点故障时能在秒级完成切换。随机过期时间解决的是“过期风暴”,高可用架构解决的是“实例宕机”,两者结合才算是完整的雪崩防御。
几个容易踩的坑和验证方法
第一个坑是setnx和expire分开写。有些老代码先set再expire,两步不是原子的,中间如果进程挂了,就会留下永不过期的key,长期占用内存。正确做法是用set key value ex ttl一条命令搞定,Redis 2.6.12之后的版本都支持。
第二个坑是随机数用了固定种子。比如Java里自己new一个Random(123)传死种子,所有进程生成的随机序列完全相同,等于没有随机。直接用ThreadLocalRandom.current()就好,它会自动用熵源初始化。
第三个坑是只对部分key做了随机。系统里可能有十几处设置缓存的地方,只改了一处,其余还是固定TTL,雪崩隐患依然存在。建议把缓存写入封装成一个统一的工具方法,随机偏移量在工具方法内部强制加上,从代码层面杜绝遗漏。
验证方面,可以通过redis-cli --bigkeys配合监控观察,也可以直接用Lua脚本统计某个前缀的key剩余TTL分布。更简单的办法是接入Redis的监控面板,观察数据库的QPS曲线:如果每天固定时间点都出现一个陡峭的尖刺,大概率就是集中过期导致的,这时候去检查对应key的TTL设置,往往能直接定位到问题代码。
总结一下,随机过期时间是防御缓存雪崩中性价比最高的一招,改造成本几乎为零,却能消除最常见的集中过期风险。但它不是全部,搭配熔断降级、多级缓存和高可用架构,才能真正让缓存层稳如磐石。