Skip to content
Go back

缓存设计——穿透、击穿、雪崩的完整防御体系

缓存三大问题的完整防御体系

一句话结论(30s)

缓存三大问题的本质是区分「缓存没挡住」的三种场景并各配主防线:因为穿透是非法 key 打库、击穿是热点 key 过期瞬间并发回源、雪崩是大批 key 同时过期或 Redis 宕机,所以分别用布隆过滤器拦截、互斥锁单线程回源、TTL 随机化 + 高可用 + 多级缓存来防。

核心原理(2min)

穿透:预加载合法 ID 到布隆过滤器,非法请求百纳秒级拦截(Guava 本地 / Redisson RBloomFilter 基于 Bitmap 分布式),缓存空值短 TTL 兜底。击穿:热点 key 过期时用 SET NX 互斥锁保证只有一个请求回源写缓存、其余等待重查,或用逻辑过期/永不过期策略。雪崩:过期时间加随机偏移(30min + random(0,5))分散过期点;Redis 宕机则靠哨兵/集群高可用、本地 Caffeine 多级缓存兜底、Hystrix/Sentinel 熔断降级快速失败。

底层深入(5-10min)

缓存穿透:请求了不存在的 key

攻击者用随机 ID 发请求 → Redis 每次 miss → 全打到数据库 → 数据库满载。

方案一:缓存空值

if (data == null) {
    redisTemplate.opsForValue().set("shop:" + id, "", 5, TimeUnit.MINUTES);
}

缺点:攻击者每次换新 ID → 缓存无限膨胀 → 防御效果有限。空值 TTL 要短(5 分钟)。

方案二:布隆过滤器(终极方案)

if (!bloomFilter.mightContain(shopId)) {
    // 一定不存在,直接返回
    return null;
}

预加载所有合法 ID → 非法请求在百纳秒内被拦截。Guava BloomFilter + Redisson RBloomFilter 两种实现,后者基于 Redis Bitmap 支持分布式。

想一想:为什么布隆过滤器比”缓存空值”更抗穿透? 因为缓存空值只能挡住”重复出现的同一个非法 key”,攻击者每次换新 ID,空值缓存就会被无限膨胀甚至撑爆;而布隆过滤器用固定大小的位数组一次性预装所有合法 ID,任何非法 key 在百纳秒内被判定”一定不存在”,不依赖”这个 key 之前来没来过”,从根本上堵住了随机 key 的穿透。

缓存击穿:热点 key 过期瞬间大量请求打到数据库

一个热点数据过期 → 同一瞬间几千个请求同时去数据库查同一个 key → 数据库被”击穿”。

方案:互斥锁(SET NX)

String data = (String) redisTemplate.opsForValue().get("shop:hot:1");
if (data == null) {
    // 加互斥锁,只有一个请求回源
    boolean locked = redisTemplate.opsForValue()
        .setIfAbsent("lock:shop:hot:1", "1", 5, TimeUnit.SECONDS);
    if (locked) {
        try {
            data = db.getShop(1);
            redisTemplate.opsForValue().set("shop:hot:1", data, 30, TimeUnit.MINUTES);
        } finally {
            redisTemplate.delete("lock:shop:hot:1");
        }
    } else {
        Thread.sleep(50);  // 其他请求等一小会,此时锁持有者正在查数据库写 Redis
        return getFromCache();
    }
}

也可采用”永不过期”策略——热点 key 不设 TTL,改用逻辑过期(value 中带过期时间戳,读取时判断)。

想一想:为什么互斥锁能保证”只有一个请求回源”? 因为 SET NX 是原子操作——多个请求同时尝试 setIfAbsent(lockKey),只有一个能成功拿到锁,其余拿到 false 就进入等待重查分支。这样数据库的”回源”动作被串行化成一次,其他几千个请求只做轻量的”等锁 + 重查缓存”,而不是一起涌向数据库。

缓存雪崩:大量 key 同时过期或 Redis 宕机

场景 A:大量 key 同时过期 → 同时回源打挂数据库。

方案:TTL + 随机偏移(30 分钟 + random(0, 5)),让过期时间分散。

场景 B:Redis 宕机 → 所有请求都打数据库。

方案

  1. Redis 哨兵/集群高可用
  2. 多级缓存(本地 Caffeine + Redis):Redis 宕机时本地缓存兜底
  3. 熔断降级:Hystrix/Sentinel 检测到数据库被打满 → 快速失败不等待

想一想:为什么给 TTL 加随机偏移就能防雪崩? 因为雪崩的根源是”大量 key 在同一时刻过期、同一时刻回源”。给每个 key 的过期时间加一个随机数,就把”同一点集中过期”打散成”一段时间内陆续过期”,回源压力被摊平到时间轴上,数据库就不会被瞬时峰值打挂。

总结

问题根因主防线备用防线
穿透非法 key布隆过滤器缓存空值(短 TTL)
击穿热点 key 过期互斥锁(SET NX)永不过期/逻辑过期
雪崩大量过期/Redis 宕机TTL 随机化 + 哨兵集群多级缓存 + 熔断降级

章末提问

Q1:穿透、击穿、雪崩的本质区别是什么? 结论先行:穿透是”非法 key 打库”(缓存本来就没有),击穿是”单个热点 key 过期瞬间并发回源”,雪崩是”大量 key 同时过期或 Redis 宕机”。 因为三者的根因不同:穿透源于请求了根本不存在的 key,缓存永远 miss;击穿源于一个热点 key 恰好过期、几千请求同时回源同一条数据;雪崩源于过期时间集中或缓存整体不可用,导致回源压力大面积爆发。所以防线也不同:布隆过滤器、互斥锁、TTL 随机化 + 高可用。

Q2:击穿的互斥锁方案有什么问题?怎么优化? 结论先行:问题是”未拿到锁的请求要等待/重查”,有额外延迟和线程开销;优化可用”逻辑过期/永不过期”。 因为 SET NX 保证单线程回源,但其他请求要么 Thread.sleep 等待、要么重查缓存,等待期间体验略差。对真正的热点 key,更彻底的做法是不设物理 TTL,改在 value 里带过期时间戳、读取时判断,后台异步刷新,这样永远不会出现”过期瞬间的真空期”。

Q3:雪崩的两个场景分别怎么防? 结论先行:大量 key 同时过期用”TTL + 随机偏移”分散过期点;Redis 宕机靠”高可用 + 多级缓存 + 熔断降级”兜底。 因为同时过期是”时间维度”的问题,加随机数把集中过期打散即可;而 Redis 宕机是”存储整体不可用”,要靠哨兵/集群保证 Redis 本身高可用,本地 Caffeine 多级缓存兜底,再配合 Hystrix/Sentinel 熔断快速失败,避免数据库被打满。


Share this post on:

Previous Post
RocketMQ ConsumeQueue 索引:20 字节条目如何精准定位消息
Next Post
布隆过滤器的双重哈希技巧