需要826头像
关注
缓存穿透、击穿与雪崩:成因、方案、取舍封面图

缓存穿透、击穿与雪崩:成因、方案、取舍

缓存穿透、击穿与雪崩:成因、方案、取舍

缓存穿透、击穿与雪崩:成因、方案、取舍

关键词标签:缓存穿透 · 缓存击穿 · 缓存雪崩 · 布隆过滤器 · 逻辑过期

一个常被忽略的事实是:缓存层真正的敌人往往不是"缓存失效",而是"缓存失效那一刻,请求并没有消失"。 缓存只是把"读"从慢介质搬到快介质,它并不改变请求量本身。当大量请求在同一时间、针对同一份不存在或刚过期的数据涌向数据库时,缓存的高可用反而成了放大器。

本系列前面讲过线程池与并发工具的背景,这里我们把视角切到缓存这一层,从成因、源码级行为、方案与取舍四个维度拆开讲。下文中的 order-service 仅为讲解示例,非真实系统。


一、三个问题,本质是三种"失配"

很多人把穿透、击穿、雪崩混为一谈,其实它们对应三种不同的失配:

问题触发条件失配点
穿透查询根本不存在的数据缓存永远不命中,请求直落 DB
击穿单个热点 key 过期大量并发同时重建同一份缓存
雪崩大量 key 同时过期 / 缓存整体不可用请求量整体压到 DB

穿透的根因是"缓存无法表达不存在"——null 默认不写缓存,于是每次请求都穿透。击穿的根因是"重建动作没有互斥"。雪崩的根因是"过期时间同质化"或"缓存层单点"。

理解这三者,关键看请求命中路径。以 Spring 的 Cache 抽象为例,Cache.get(key) 未命中时会走 CacheLoader(Spring 的 @Cacheable 默认是同步加载),而 Redis 场景下通常手写:

public Order getOrder(Long id) {
    String key = "order:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        return JSON.parseObject(cached, Order.class);
    }
    // 未命中:查库
    Order order = orderMapper.selectById(id);
    if (order != null) {
        redis.set(key, JSON.toJSONString(order), 300, TimeUnit.SECONDS);
    }
    return order;
}

这段代码有两个经典缺陷:order == null 时不写缓存(穿透),redis.set 没有互斥(击穿)。


二、穿透:让"不存在"也能被缓存

2.1 空值缓存

最简单的方式是把 null 也缓存起来,但用一个短 TTL:

private static final String NULL_MARK = "__NULL__";

public Order getOrder(Long id) {
    String key = "order:" + id;
    String cached = redis.get(key);
    if (NULL_MARK.equals(cached)) {
        return null;
    }
    if (cached != null) {
        return JSON.parseObject(cached, Order.class);
    }
    Order order = orderMapper.selectById(id);
    if (order == null) {
        // 空值缓存,TTL 短一些,避免长期占用内存
        redis.set(key, NULL_MARK, 60, TimeUnit.SECONDS);
        return null;
    }
    redis.set(key, JSON.toJSONString(order), 300, TimeUnit.SECONDS);
    return order;
}

边界情况:如果攻击者用随机 id 持续请求,空值缓存会被大量无意义 key 填满,反而消耗内存。所以空值缓存只适合"可枚举但有上限"的查询,不适合完全开放的 id 空间。

2.2 布隆过滤器

布隆过滤器的价值在于:它能在常数空间内回答"这个 key 一定不存在"。注意措辞——它只能确定"不存在",不能确定"存在"(存在假阳性)。

Redis 从 4.0 开始提供 BF.ADD / BF.EXISTS 指令(RedisBloom 模块),Java 侧可用 Redisson 的 RBloomFilter

RBloomFilter<Long> bloom = redisson.getBloomFilter("order:bloom");
bloom.tryInit(1_000_000L, 0.01); // 预计元素数、误判率

public Order getOrder(Long id) {
    if (!bloom.contains(id)) {
        return null; // 一定不存在,直接返回
    }
    // 走正常缓存逻辑
    return loadFromCacheOrDb(id);
}

取舍:布隆过滤器不支持删除(标准实现),数据删除后需要重建或使用计数布隆过滤器(Counting Bloom Filter)。它适合"写入后很少删除"的场景,比如订单 id、用户 id。


三、击穿:给重建动作加互斥

热点 key 过期瞬间,N 个线程同时发现未命中,同时查库。解决方案是只让一个线程重建,其余等待

3.1 分布式锁 + 双重检查

public Order getOrder(Long id) {
    String key = "order:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        return JSON.parseObject(cached, Order.class);
    }
    String lockKey = "lock:order:" + id;
    // 尝试加锁,等待 100ms,持有 3s
    boolean locked = redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
    if (!locked) {
        // 没抢到锁,短暂自旋后重试读缓存
        sleepQuietly(50);
        return getOrder(id);
    }
    try {
        // 双重检查:可能已被其他线程重建
        cached = redis.get(key);
        if (cached != null) {
            return JSON.parseObject(cached, Order.class);
        }
        Order order = orderMapper.selectById(id);
        if (order != null) {
            redis.set(key, JSON.toJSONString(order), 300, TimeUnit.SECONDS);
        }
        return order;
    } finally {
        redis.delete(lockKey);
    }
}

注意 setIfAbsent 的原子性:Redis 的 SET key value NX PX 是原子的,不要用 if (!exists) set 这种两步操作。

误区:递归重试没有深度限制,极端情况下可能栈溢出。生产代码里通常用循环 + 最大重试次数。

3.2 逻辑过期:永不物理过期

另一种思路是不给 key 设 TTL,而是把过期时间写进 value

class CacheEntry {
    long expireAt;   // 逻辑过期时间戳
    Order data;
}

读到时判断 expireAt,若已过期则异步触发重建,当前请求返回旧值。这样所有请求都不会阻塞在锁上,代价是短时间返回脏数据

public Order getOrder(Long id) {
    String key = "order:" + id;
    String cached = redis.get(key);
    if (cached == null) {
        return loadFromCacheOrDb(id); // 首次加载
    }
    CacheEntry entry = JSON.parseObject(cached, CacheEntry.class);
    if (entry.expireAt < System.currentTimeMillis()) {
        // 逻辑过期:异步重建,当前返回旧值
        asyncRebuild(id);
    }
    return entry.data;
}

取舍:逻辑过期适合"能容忍短暂不一致"的场景,比如商品详情页;不适合"必须强一致"的场景,比如库存扣减。


四、雪崩:打散过期时间 + 多级缓存

4.1 过期时间加随机扰动

最直接的方式是给 TTL 加一个随机偏移:

long baseTtl = 300;
long jitter = ThreadLocalRandom.current().nextLong(0, 60);
redis.set(key, value, baseTtl + jitter, TimeUnit.SECONDS);

这样即使同一批数据同时写入,过期时间也会分散在一个区间内。

4.2 多级缓存

本地缓存(Caffeine)+ Redis 是常见组合。本地缓存扛住热点,Redis 扛住容量。但要注意本地缓存的一致性问题:多实例部署时,本地缓存更新不同步。

一种常见做法是通过消息队列广播失效事件:

// 更新数据时
redis.set(key, value, ttl, TimeUnit.SECONDS);
mq.send("cache:invalidate", key);

// 各实例监听
@EventListener
public void onInvalidate(String key) {
    caffeineCache.invalidate(key);
}

取舍:多级缓存提升性能,但引入了一致性复杂度。如果业务对一致性要求高,宁可只用 Redis。

4.3 缓存高可用

雪崩的极端形式是缓存层整体不可用。Redis 的 Sentinel、Cluster 是基础设施层面的答案,但熔断降级同样重要:当 Redis 不可用时,不能让请求直接压垮 DB。Hystrix(已停止维护)或 Resilience4j 的 CircuitBreaker 可以做这层保护。


五、什么时候别用 / 别踩的坑

  1. 别对完全开放的 id 空间用空值缓存。攻击者可以用随机 id 填满你的 Redis,反而制造了内存雪崩。
  2. 布隆过滤器不支持删除。数据删除场景要么重建,要么用计数布隆过滤器,要么干脆不用。
  3. 分布式锁的 TTL 要大于重建耗时。否则锁提前释放,互斥失效。
  4. 逻辑过期的"异步重建"要限流。如果重建任务本身把线程池打满,等于换了个地方雪崩。
  5. 多级缓存的失效广播不是强一致。消息丢失或延迟会导致本地缓存长期脏读,需要兜底 TTL。
  6. 别把所有 key 的 TTL 设成同一个值。这是雪崩最常见的成因,没有之一。

六、小结与预告

穿透、击穿、雪崩的解决方案本质上都在做同一件事:把"请求量"和"数据库压力"解耦。穿透靠布隆过滤器或空值缓存拦截,击穿靠互斥或逻辑过期,雪崩靠打散 TTL 和多级缓存。每一种方案都有代价,没有银弹。

本系列前面讲过线程池、AQS、CompletableFuture 的并发基础,接下来我们会继续深入缓存与一致性方向,比如分布式锁的 Redlock 争议、缓存与数据库的双写一致性策略。如果你对这类源码级、带取舍分析的硬核内容感兴趣,欢迎关注后续更新。

参考链接

  • Redis 官方文档(Bloom Filter 指令):https://redis.io/docs/latest/commands/bf.add/
  • Spring Framework Cache 抽象:https://docs.spring.io/spring-framework/reference/integration/cache.html

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_73201411/article/details/165472484

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--