连载中 6/16

缓存雪崩:大批 key 集体失效时的三层防御

2026-05-17 · 6801 阅读 · 0 评论 · 0 赞

零点预热的那批 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 异步刷新又是什么玩法?下篇见。

咖啡凉了,记得趁热喝。

503

10 年全栈工程师 · 503咖啡馆主理人

#Redis#缓存雪崩#多级缓存#限流降级#高可用

评论 (0)

相关推荐

连载中 12/20

排查四件套:jstack、jmap、jstat、jcmd 的实战分工

jstack 看线程在干什么,jmap 看堆里装了什么,jstat 看运行时在变什么,jcmd 是统一入口。四把刀各管一段,配合着用没有查不动的现场。

#jstack#jmap#jstat#jcmd#排查工具
2026-09-16 · 4 阅读 · 0 评论 · 0 赞
连载中 11/20

GC 日志:把回收过程翻译成人话

一行 GC 日志里塞着七种信息:谁触发的、收了哪、停了多久、活了哪些。加上 -Xlog 配置,再加上日志分析工具,GC 不再是只能盯监控曲线的黑盒。

#GC日志#Xlog#日志分析#GC监控#Full GC排查
2026-09-16 · 13 阅读 · 0 评论 · 0 赞
连载中 10/20

ZGC:亚毫秒停顿是怎么炼成的

百 G 大堆停顿不到一毫秒,靠的是把搬家全部挪到并发阶段——着色指针让引用自带状态,读屏障让搬运中的对象依然可访问。代价是吞吐与内存,收益是停顿与堆大小解耦。

#ZGC#着色指针#读屏障#亚毫秒停顿#分代ZGC
2026-09-15 · 8 阅读 · 0 评论 · 0 赞