那次缓存雪崩,我们在过期时间上加了一层随机抖动才稳住

有一次零点整,我们的缓存集群同时打满了 Redis 和数据库,导致服务中断 12 分钟。

当时业务刚接入了一个新的活动页,所有商品信息的缓存过期时间都设成了秒杀结束的那一刻——也就是当晚 24:00。运营同学设的结束时间是精确到 0 点的,而我们缓存的 TTL 正好跟这个时间对齐。结果零点一到,几十万个 key 在同一秒内全部过期,下一波请求直接穿透到数据库,连接池瞬间耗尽。

这就是最典型的缓存雪崩场景:大量缓存在同一时刻集中失效,请求全部落到后端存储上,把服务直接打垮。

事后复盘时我们发现,代码里的过期时间是这样设的:

redis.set(key, value, expireSeconds);

所有 key 的 expireSeconds 都按业务截止时间减当前时间算出来,毫秒级对齐。这导致了一个确定性的问题——只要知道截止时间,就能精确推算出雪崩发生的时刻。

随机抖动不是随便加个随机数就行

第一反应当然是给过期时间加个随机值。很多人会这么写:

int ttl = baseTtl + random.nextInt(300);  // 基础TTL加0-300秒随机

这个方案在大多数场景下够用,但仔细想想,它其实隐含了一个假设:所有 key 的创建时间大致均匀分布在时间轴上。如果这个假设不成立,问题依然存在。

我们遇到过一个更隐蔽的场景:某个商品列表页有 200 个 sku,每个 sku 的缓存 key 都在用户第一次访问时创建。但用户访问是有模式的——大部分人会在活动开始的同一分钟内涌入,这 200 个 key 几乎是在几秒内全部创建出来的。如果基础 TTL 是 1800 秒(30 分钟),即使加了 0-300 秒的随机抖动,这 200 个 key 的过期时间还是集中在 1800-2100 秒之后的一个相对窄的窗口里。30 分钟后,它们仍然会集中失效。

真正的随机抖动需要满足两个条件:一是抖动范围足够大,二是跟 key 的创建时间无关。

我们后来的做法是把抖动因子绑定到 key 本身:

int baseTtl = 1800;
int shake = Math.abs((key.hashCode() % 600) - 300);  // -300 到 +300 的偏移
int ttl = baseTtl + shake;
if (ttl < 300) ttl = 300;  // 保底 5 分钟

key.hashCode() 做随机种子,同一个 key 每次算出来的抖动值固定,不会因为重算 TTL 而漂移;不同 key 之间的抖动值近似均匀分布。这样即使 200 个 key 在同一秒创建,它们的过期时间也会均匀展开在 1500-2100 秒这个 600 秒宽的窗口里。

多级过期时间:把雪崩拆成霰弹

单靠随机抖动解决的是"同一时间窗口内大量 key 同时过期"的问题。但还有一种更棘手的情况:热点 key。

热点 key 的问题在于,它本身就是大量请求汇聚的点。一个爆款商品的信息缓存,每秒可能被访问上万次。如果这个 key 过期,即使只有这一个 key,也能在瞬间把数据库打满。

我们的处理方式是给热点 key 单独设计一个多级过期策略:

L1: 本地缓存 (Caffeine),TTL 短,例如 10 秒
L2: Redis 分布式缓存,TTL 中等,例如 1800 秒 + 抖动
L3: 数据库

关键点在于,当 L2 的 Redis key 接近过期时,不是等到它真正过期才去加载,而是提前主动刷新。

具体实现上,我们在 Redis key 的 value 里多存了一个字段 refreshAt,表示"建议在这个时间之后刷新缓存"。这个时间比实际的过期时间早,比如过期时间是 T+1800,refreshAt 就设成 T+1500。当 L1 的本地缓存读不到数据、需要从 Redis 取时,会顺便检查 refreshAt,如果当前时间已经过了这个值,就触发一次异步刷新——但这次刷新不会阻塞当前请求,当前请求仍然用 Redis 里还没过期的旧数据返回。

// 伪代码
CacheEntry entry = redis.get(key);
if (entry != null) {
    if (System.currentTimeMillis() > entry.refreshAt) {
        asyncRefresh(key);  // 异步刷新,不阻塞
    }
    caffeine.put(key, entry, 10, TimeUnit.SECONDS);
    return entry.data;
}
// Redis 真过期了才同步加载
return loadFromDbAndCache(key);

这样做的好处是,即使热点 key 的 Redis 缓存真的过期了,在过期之前的 300 秒窗口内,已经有多个异步刷新任务尝试过重新加载,大概率不会出现"Redis 里刚好没有"的情况。只有极端情况下(比如数据库本身挂了导致刷新失败),请求才会真正穿透到数据库。

我们实际落地的一个配置

说一个具体的配置,给同样用 Redis + 本地缓存的团队参考。

我们线上一个读多写少的商品详情服务,QPS 峰值大概 8000,缓存层级如下:

  • 本地缓存:Caffeine,expireAfterWrite = 10smaximumSize = 10000refreshAfterWrite = 8s(异步刷新)
  • Redis 缓存:TTL 基准值 1800s,抖动范围 key.hashCode() % 1200(即 ±600s),实际 TTL 落在 1200-2400s 之间;value 内带 refreshAt = now + 1500s
  • 数据库:MySQL 8.0,读从库,连接池 HikariCP,maximumPoolSize = 50

在这个配置下,我们做过一次压测:模拟 8000 QPS 稳态运行,然后手动 FLUSHALL 清空整个 Redis。结果本地缓存扛住了前 10 秒,异步刷新线程在 Redis 恢复后 3 秒内重新热了起来,期间数据库 QPS 从正常的 200 涨到了 1100,没有触发连接池耗尽,P99 延迟从 12ms 涨到 180ms,但没有超时。

如果没有多级缓存和提前刷新,同样的操作会让数据库 QPS 瞬间飙到 8000,连接池直接打满,P99 延迟升到 30 秒以上。

不是所有场景都值得这么搞

多说一句,这套策略有它的适用边界。

如果你的缓存 key 总量不大(比如几千个),或者数据库扛得住全量穿透(比如数据本身就在内存里),那直接用 Redis 加一个简单的随机抖动就够了,没必要引入本地缓存和异步刷新,多一层就多一个出错的可能。

我们之所以上了多级缓存 + 提前刷新,是因为业务特性决定的:一个爆款商品详情页,缓存命中率要求接近 100%,且数据库扛不住热点穿透。如果你的场景是后台管理系统、每天几百个请求,那这些优化纯属过度设计。

关键在于理解你的流量模型和故障半径。缓存雪崩的本质不是"缓存过期了",而是"缓存过期后,后端能不能扛住"。如果后端扛得住,那雪崩就不存在;如果扛不住,就得在缓存层把失效的冲击波消解掉——随机抖动是把一个大波峰摊平成小波峰,多级过期和提前刷新是让波峰压根形不成。


常见问题

随机抖动加多少合适?

没有绝对值,取决于你的 key 总量和过期时间窗口。一个经验公式:抖动范围至少是基准 TTL 的 20%-30%。比如基准 TTL 是 1800 秒,抖动范围建议在 ±300 到 ±600 秒之间。如果 key 总量特别大(百万级),抖动范围可以适当缩小,因为大数定律下自然分布已经有一定离散度。另外要注意抖动的下限,别让 TTL 变成负数或者太短导致缓存频繁失效,保底值建议不低于 300 秒。

多级缓存会不会导致数据不一致?

会。L1 本地缓存和 L2 Redis 之间天然存在时间差。我们的容忍度是 10 秒——本地缓存 TTL 设 10 秒,意味着一条数据更新后,最坏情况下 10 秒内部分节点读到旧数据。对于商品详情这类读多写少、允许短暂不一致的场景可以接受。如果你需要强一致性,要么不上本地缓存,要么在更新时主动失效所有节点的本地缓存(比如通过 Redis Pub/Sub 广播失效消息)。

提前刷新的线程池怎么配?

异步刷新任务要用独立的线程池,不要跟请求处理线程共用。线程池大小可以参考"同时过期 key 数量 / 刷新耗时"来估算。我们的配置:核心线程数 4,最大线程数 16,队列长度 200,拒绝策略用 CallerRunsPolicy(极端情况下降级为同步加载)。监控上重点关注线程池的队列长度和拒绝次数,这两个指标能提前预警刷新速度跟不上过期速度。