零点预热的那批 key,两小时后集体反水
接着讲大促的事。上次击穿事故之后,我们给爆款详情上了防护,安稳了一阵。直到又一次大促:运营晚上八点批量预热了两万个商品 key,TTL 图省事统一设成了 2 小时。晚上十点,第二场秒杀准点开场,流量蜂拥而入——而这两万个 key,恰好在开场前的最后一秒集体到期。缓存同时清空,请求同时落库,数据库再一次躺进了 ICU。
这就是缓存雪崩。它和击穿的区别一目了然:击穿是一个热点 key 失效,是「点」的故障;雪崩是一大批 key 同时失效、甚至缓存本身整体宕机,是「面」的灾难。防线要铺得宽得多。
雪崩的两种姿势:集体过期与整体宕机
先把雪崩拆成两类,成因不同,药方也不同:
| 类型 | 成因 | 典型症状 | 主治药方 |
|---|---|---|---|
| 集体过期 | 大批 key 设了相同 TTL,同一秒集中到期 | Redis 活着,DB 出现瞬时压力尖峰 | 过期时间加随机值、预热错峰 |
| 缓存宕机 | Redis 实例或集群整体不可用 | 流量绕过缓存直接砸向 DB,压力持续 | 多级缓存、限流降级、集群高可用 |
集体过期是「自己人」闹的,改改 TTL 策略就能根治;宕机是「天灾」,得靠架构冗余和兜底方案硬顶。下面按防御纵深一层层说。
第一层:过期时间加随机值,把到期时刻摊开
对付集体过期,解法简单到不像话:给每个 key 的 TTL 叠加一个随机偏移量,让到期时刻从「一条直线」摊成「一片缓坡」。
public void cacheProduct(Product product) {
String key = "product:detail:" + product.getId();
// 基础 30 分钟,再叠 0~10 分钟的随机偏移,把到期时刻摊开
long base = TimeUnit.MINUTES.toSeconds(30);
long jitter = ThreadLocalRandom.current().nextLong(TimeUnit.MINUTES.toSeconds(10));
redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(base + jitter));
}两万个 key 的到期时刻被打散在 30~40 分钟里,数据库感受到的就不是一堵墙同时倒下,而是细水长流。两个工程细节:
- 批量预热更要错峰:运营一键预热两万个 key 时,写入循环里同样要加随机间隔,别让「写入时刻」先整齐划一了。
- 定时刷新的任务要打散:后台刷新缓存的 job 如果每次都整点跑,效果等于没加随机值,把执行时刻打散到周期内的随机点。
随机窗口开多大,参考标准是「单位时间内过期的 key 数量」不超过数据库的承受能力,不必追求精确。批量 key 越多,窗口要开得越大。
第二层:多级缓存,本地缓存当减震器
随机 TTL 治不了「宕机」这种天灾。第二层防御是在业务服务和 Redis 之间垫一层本地缓存:请求先问本地(Caffeine 这类进程内缓存,微秒级),未命中再问 Redis,最后才是数据库。Redis 一旦挂了,本地缓存里那点「余粮」能替数据库挡掉相当一部分流量。
// 一级:进程内缓存,容量小、速度极快、短 TTL
Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(10))
.build();
public Product getProduct(Long id) {
String key = "product:detail:" + id;
// 先问本地:微秒级,命中直接返回
Product p = localCache.getIfPresent(key);
if (p != null) {
return p;
}
// 再问 Redis:毫秒级,命中后回填本地
p = redisTemplate.opsForValue().get(key);
if (p != null) {
localCache.put(key, p);
return p;
}
// 都没有:查库,回填两级缓存
p = productMapper.selectById(id);
if (p != null) {
redisTemplate.opsForValue().set(key, p, randomTtl());
localCache.put(key, p);
}
return p;
}代价也要心里有数:
- 短暂不一致:本地缓存通常只设秒级 TTL,多实例之间各缓存各的,数据改动生效会有几秒窗口。详情类数据无所谓,强一致数据别放本地。
- 内存有限:本地缓存只能装下最热的一小撮 key,本质是「减震器」,不是「第二个 Redis」。
第三层:限流降级,给数据库装根保险丝
前两层都失效了呢?比如 Redis 挂了,本地缓存也全部过期。这时最后的选择不是硬扛,而是认怂:在数据库前面装一个限流阀,只放行数据库扛得住的流量,多出来的请求快速失败、返回兜底数据。体验会降级,但系统活着——挂掉的系统,连「差体验」都给不了。
// 数据库保险丝:只放行数据库扛得住的流量
RateLimiter dbGuard = RateLimiter.create(2000); // 每秒最多 2000 次查库
public Product getProductWithGuard(Long id) {
String key = "product:detail:" + id;
Product p = localCache.getIfPresent(key);
if (p != null) {
return p;
}
p = redisTemplate.opsForValue().get(key);
if (p != null) {
return p;
}
if (!dbGuard.tryAcquire()) {
// 拿不到配额:快速失败,返回兜底快照或友好提示,绝不让流量裸奔进库
return degradedSnapshot(id);
}
return productMapper.selectById(id);
}限流是「挡住多余的量」,熔断则是「发现下游不健康就主动断开」:连续超时或报错达到阈值后直接短路,过一会儿再放几个请求试探恢复。生产上直接用 Sentinel、Resilience4j 这类成熟组件就好,别手搓。
别忘了治本:缓存自己要高可用
前面三层都是「Redis 挂了怎么办」的补救,更根本的问题是让 Redis 尽量不挂:
- 主从 + 哨兵:主挂了自动选主,分钟级恢复,中小规模的稳妥选择。
- Cluster 集群:数据分片到多节点,单点故障只影响部分槽位,容量和吞吐都能横向扩。
- 持久化:RDB/AOF 让宕机重启后能恢复数据,避免「冷启动全是未命中」引发的二次雪崩。
雪崩防御从来不是单点方案,而是一套组合拳:随机 TTL 摊平过期,多级缓存缓冲冲击,限流降级兜底保命,集群高可用减少宕机概率。四件事一起做,数据库才算真正穿上盔甲。
到这里,「缓存三问」正式收官:穿透防「查无此数」,击穿防「单点失效」,雪崩防「集体沦陷」。三篇连起来,就是一份缓存高可用的攻防手册。下一篇起换个战场,聊聊更日常也更磨人的问题——缓存与数据库的一致性:先改库还是先删缓存?延迟双删靠不靠谱?监听 binlog 异步刷新又是什么玩法?下篇见。
咖啡凉了,记得趁热喝。
评论 (0)