缓存穿透、击穿与雪崩

举报
yd_216253085 发表于 2026/11/04 08:41:44 2026/11/04
【摘要】 区分三种失效模式,给出空值缓存、布隆过滤器与逻辑过期的落地写法

缓存加在数据库前面,正常路径是"先查缓存,未命中查库,回填缓存"。一旦这个路径在某个环节失效,流量就会直接压到数据库上。穿透、击穿、雪崩描述的是三种不同的失效原因,对应的解法也完全不同。本文用一张对照表先把概念分开,再逐条给出可运行的实现。

一、三种问题的区别

| 问题 | 根因 | 特征 | 主要解法 |

| --- | --- | --- | --- |

| 穿透 | 查询不存在的数据,缓存永远不命中 | 请求量平稳但 DB QPS 异常高,key 高度分散 | 空值缓存、布隆过滤器、参数校验 |

| 击穿 | 单个热点 key 过期瞬间,大量并发回源 | 某一个 key 的 QPS 尖刺 | 互斥锁重建、逻辑过期、热点永不过期 |

| 雪崩 | 大量 key 同时失效,或 Redis 整体不可用 | 全站缓存命中率骤降 | TTL 抖动、多级缓存、熔断降级、高可用 |

三者的共同点是"缓存没挡住",但治理手段差别很大:穿透要挡非法 key,击穿要控制并发重建,雪崩要做容量与可用性设计。

二、缓存穿透

攻击者用不存在的 ID 反复请求,比如 /user/999999999。缓存不命中、数据库查不到、也不回填,下一次请求继续打到数据库。

方案一:缓存空值。 查库为空时写入一个短 TTL 的占位值,比如 60 秒。实现最简单,能挡住绝大多数重复请求。

public User getUser(long id) {
    String key = "user:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) {
        return EMPTY.equals(cached) ? null : JSON.parseObject(cached, User.class);
    }
    User user = userMapper.selectById(id);
    if (user == null) {
        redis.opsForValue().set(key, EMPTY, Duration.ofSeconds(60));
        return null;
    }
    redis.opsForValue().set(key, JSON.toJSONString(user), Duration.ofMinutes(30));
    return user;
}

方案二:布隆过滤器。 把所有合法 ID 预先放进布隆过滤器,查询前先判断。它用位数组加多个哈希函数,空间效率极高,但有 1% 左右的误判率(只会误判存在,不会误判不存在),且不支持删除。

# Redis 4.0+ 的布隆过滤器模块(RedisBloom)
BF.RESERVE user:bloom 0.001 1000000
BF.ADD user:bloom 1001
BF.EXISTS user:bloom 1001
BF.EXISTS user:bloom 999999

参数 0.001 是期望误判率,1000000 是预估元素数。误判率越低,位数组越大。

注意:布隆过滤器需要与数据库同步维护。新增数据要 BF.ADD,删除数据无法直接移除,只能重建或改用 Counting Bloom Filter。

方案三:入口校验。 ID 必须为正整数、必须符合格式、必须来自已登录用户,能在网关挡掉一部分爬虫式请求。

三、缓存击穿

一个热点 key 在过期瞬间,比如秒杀商品详情,几千个线程同时发现未命中,一起查库并回填。数据库瞬间被打满,回填的又是同一份数据。

方案一:互斥锁重建。 只允许一个线程回源,其余线程短暂等待后重试读缓存。

public Product getProduct(long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }
    String lockKey = "lock:product:" + id;
    String token = UUID.randomUUID().toString();
    Boolean locked = redis.opsForValue()
            .setIfAbsent(lockKey, token, Duration.ofSeconds(10));
    if (Boolean.TRUE.equals(locked)) {
        try {
            Product product = productMapper.selectById(id);
            redis.opsForValue().set(key, JSON.toJSONString(product),
                    Duration.ofMinutes(30));
            return product;
        } finally {
            unlock(lockKey, token);
        }
    }
    // 未抢到锁:短暂睡眠后重试,避免全部线程同时打库
    sleepQuietly(50);
    cached = redis.opsForValue().get(key);
    return cached == null ? productMapper.selectById(id)
                          : JSON.parseObject(cached, Product.class);
}

释放锁必须校验 token,避免误删别人的锁,详见分布式锁一文。这种方案的缺点是未抢到锁的线程会等待,吞吐下降,适合能接受百毫秒级延迟的场景。

方案二:逻辑过期。 key 本身不设 TTL,值里带一个 expireAt 字段。读到逻辑过期时,异步触发重建,当前请求仍返回旧值。这样所有请求都能立刻返回,代价是短时间内可能读到旧数据。

public Product getProductWithLogicalExpire(long id) {
    String key = "product:logical:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached == null) {
        return loadAndCache(id, key);
    }
    CacheWrapper<Product> wrapper = JSON.parseObject(cached,
            new TypeReference<CacheWrapper<Product>>() {});
    if (wrapper.getExpireAt() > System.currentTimeMillis()) {
        return wrapper.getData();
    }
    // 已逻辑过期:尝试拿锁异步重建,拿不到就直接返回旧值
    String lockKey = "lock:rebuild:" + id;
    if (Boolean.TRUE.equals(redis.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)))) {
        executor.execute(() -> {
            try {
                loadAndCache(id, key);
            } finally {
                redis.delete(lockKey);
            }
        });
    }
    return wrapper.getData();
}

热点商品、首页配置、榜单这类"允许短暂陈旧"的数据适合逻辑过期;账户余额、库存这类强一致数据不适合。

四、缓存雪崩

雪崩有两个来源:大量 key 同时过期,或 Redis 集群整体故障。

TTL 抖动。 在基础 TTL 上叠加随机偏移,把过期时间打散:

long ttl = 1800 + ThreadLocalRandom.current().nextLong(300);
redis.opsForValue().set(key, value, Duration.ofSeconds(ttl));

多级缓存。 本地缓存(Caffeine)挡一层,Redis 挡第二层,数据库兜底。Redis 故障时本地缓存仍能撑一段时间,但要注意本地缓存的一致性问题。

熔断与降级。 当数据库 QPS 超过阈值时,对非核心接口直接返回默认值或错误页,保住核心链路。Sentinel 或 Resilience4j 都可以配置。

高可用。 主从 + 哨兵或 Cluster,避免单点故障;同时给 Redis 客户端配置合理的超时与重试,超时时间不宜过长,否则故障时线程池会被打满。

spring:
  data:
    redis:
      timeout: 500ms
      lettuce:
        pool:
          max-active: 50
          max-wait: 200ms

缓存预热。 系统重启或 Redis 重启后,缓存是空的,第一批请求会全部回源。上线前用离线任务把热点数据提前写入,或者用「分批放量」的方式逐步切流,避免冷启动瞬间压垮数据库。

降级兜底值。 非核心字段可以返回静态默认值,比如商品列表的推荐位、评分、销量,缓存不可用时返回空列表而不是抛异常。这类降级要在网关或服务层实现,并且可配置开关,方便故障时快速生效。

五、组合使用

真实系统通常三者叠加:

  1. 入口做参数校验和限流,挡掉明显非法的请求;
  2. 用布隆过滤器拦不存在的 ID,空值缓存兜住漏网之鱼;
  3. 热点 key 用逻辑过期或互斥锁重建;
  4. 所有 TTL 加随机抖动,核心数据做多级缓存;
  5. 全链路配监控:缓存命中率、DB QPS、Redis 延迟,任何一项异常都能定位到具体环节。

常见坑 / 最佳实践

  1. 空值缓存的 TTL 不能太长。 如果数据随后被创建,过长的空值缓存会持续返回空,建议 30~60 秒,并在写入时主动删除对应 key。
  2. 互斥锁一定要设过期时间,并在 finally 中释放。 否则持锁线程崩溃会导致死锁,热点 key 永远无法重建。
  3. 未抢到锁的线程不要直接查库。 那样等于没加锁,应短暂等待后重读缓存,或直接返回兜底值。
  4. 逻辑过期要评估数据陈旧的影响。 异步重建失败时旧值会一直返回,需要加重建失败告警和最大陈旧时间限制。
  5. 布隆过滤器要与数据写入放在同一事务链路。 新增数据后忘了 BF.ADD,会出现"库里明明有、请求却被判不存在"的诡异问题。

小结

  • 穿透是查不存在的数据,靠空值缓存、布隆过滤器和入口校验解决。
  • 击穿是单个热点 key 过期,靠互斥锁重建或逻辑过期解决,前者一致性强、后者吞吐高。
  • 雪崩是大量 key 同时失效或 Redis 故障,靠 TTL 抖动、多级缓存、熔断降级和高可用架构解决。
  • 三种问题要组合治理,并且必须配合命中率、DB QPS、Redis 延迟的监控才能快速定位。
  • 所有方案都有代价,选型依据是业务对一致性、延迟和吞吐的实际要求。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。