Redis缓存穿透击穿雪崩防御指南:从布隆过滤器到多级缓存实战

举报
数据库小学妹 发表于 2026/08/12 09:39:28 2026/08/12
【摘要】 从618促销缓存雪崩事故切入,深度解析缓存穿透、击穿、雪崩的底层机制、生产级防御方案与监控告警策略,附布隆过滤器实现和分布式锁代码

大家好,我是数据库小学妹👋我踩过的坑,你别再踩。

618凌晨我值班,凌晨一点二十分,监控群同时弹出17条告警。Redis命中率从98%断崖式跌到23%。MySQL连接池直接打满。网关返回502的时间,每单支付平均慢了四秒。事后查了三个小时,根因是一批过期key的TTL设成了同一秒。同一时间全部过期,几千个请求同时打到数据库。这就是典型的缓存雪崩。

这次事故让我意识到,缓存穿透、击穿、雪崩这三个词很多教程混着讲,但底层机制完全不同,防御策略也完全不同。今天我把生产环境验证过的方案完整拆开讲,希望能帮你少走弯路少踩坑。

先说结论:三者不是一个东西

先看一张对比表,一眼分清。

维度 缓存穿透 缓存击穿 缓存雪崩
触发条件 查询不存在的key 单个热点key过期瞬间 大量key同时过期
命中情况 永远不会命中 命中瞬间归零后恢复 命中率断崖式下降
攻击面 恶意请求或脏数据 正常业务热点数据 TTL集中到期
DB压力 持续高,无效查询 瞬时打满 瞬间洪峰
核心防御 布隆过滤器+空值缓存 分布式锁+逻辑过期 TTL随机化+多级缓存
排查特征 请求分散无热点key 某个key请求量异常集中 大量key同时miss

搞混了防御手段,等于用创可贴止大动脉出血。

第一案:缓存穿透,查一个永远不存在的数据

商品表有100万条数据,有人恶意请求ID为999999999的商品。这个ID不存在,缓存里自然没有。每次请求都穿透到数据库,一万次就是万次无效查询,数据库CPU直接飙到90%。

穿透的本质是缓存层没有拦截无效请求的能力。每一次miss都变成一次数据库查询。请求量大的话数据库连接池很快被打满,正常的业务请求也被阻塞。

生产方案用布隆过滤器加空值缓存。布隆过滤器解决的是快速判断key是否存在。底层是一个二进制位数组加上多个哈希函数。元素经过多个哈希映射到位数组的多个位置。查询时只要有一个位置是0就说明不存在。误判率可调,但不会漏判。它说不存在的一定不存在。它说存在的小概率不存在。

// Guava BloomFilter 初始化
BloomFilter<String> bf = BloomFilter.create(
    Funnels.stringFunnel(Charset.forName("UTF-8")),
    1000000,  // 预期元素量
    0.01      // 误判率1%
);

// 预加载所有存在的商品ID
for (Long id : allProductIds) {
    bf.put(String.valueOf(id));
}

// 查询时先过布隆过滤器
public Product getProduct(Long id) {
    String key = "product:" + id;
    if (!bf.mightContain(String.valueOf(id))) {
        return null;  // 布隆说没有,直接返回
    }
    Product p = redis.get(key);
    if (p != null) return p;  // 缓存有直接返回
    p = db.getById(id);
    if (p != null) {
        redis.setex(key, 3600, p);
    } else {
        redis.setex(key, 60, "NULL");  // 数据库也没有,缓存空值
    }
    return p;
}

空值缓存的TTL设60秒到120秒。设太长的话万一新数据写入缓存还是旧的null。设太短防不住高频攻击。

我踩过的坑是布隆过滤器误判率一开始设了0.001。当时以为精度越高越好,结果内存占了好几百兆。后来改成0.01,误判率1%对缓存场景完全可接受,内存降了一个数量级。不过如果是安全敏感场景,0.001仍有必要,不能一概而论。业内推荐误判率在0.1%到5%之间就够。

还有个坑是布隆过滤器不支持删除。商品下架后位数组无法回退。我的做法是定期重建过滤器,凌晨两点全量刷一次。如果业务删除频繁,可以考虑布谷鸟过滤器,它支持删除操作,RedisBloom也原生支持。

对了,上面代码用的是Guava本地布隆过滤器。如果数据量大或者需要多实例共享,可以用RedisBloom模块。Redis 4.0之后就有这个模块,直接用BF.RESERVE、BF.ADD、BF.EXISTS命令操作。100万个ID大约只需1.2MB内存,过滤器和Redis同进程,没有额外网络开销。

第二案:缓存击穿,热点key过期的那一秒

首页推荐位有一个热点key,QPS三万,TTL设了300秒。第五分钟整key过期,同一瞬间200个线程同时发现key不在缓存。200个线程同时查数据库,连接数瞬间打满。

击穿和穿透的区别在于数据本身存在,只是缓存恰好在这个时间点过期。大量并发请求同时miss,瞬时压力比穿透更集中。

分布式锁的核心思路是key过期时只让一个线程去查数据库重建缓存,其他线程等待或返回旧值。

public Product getHotProduct(Long id) {
    String key = "product:" + id;
    String lockKey = "lock:product:" + id;
    Product p = redis.get(key);
    if (p != null) return p;

    boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (!locked) {
        Thread.sleep(100);  // 等100ms再查一次
        return redis.get(key);  // 大概率另一个线程已重建
    }
    try {
        p = db.getById(id);  // 拿到锁的线程查DB重建
        if (p != null) redis.setex(key, 300, p);
    } finally {
        redis.delete(lockKey);
    }
    return p;
}

但SETNX实现的分布式锁有个隐患。线程拿到锁后如果异常退出,锁可能不会释放。其他线程永远等不到。生产环境用Redisson。它有看门狗机制,默认30秒自动续期。线程异常退出后锁自动释放。

RLock lock = redisson.getLock(lockKey);
try {
    if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
        p = db.getById(id);
        redis.setex(key, 300, p);
    }
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

有些场景不允许线程等待,比如响应时间要求200ms以内。这时候用逻辑过期。缓存value里自带过期时间字段,实际Redis的TTL设成很长。查询时判断逻辑过期时间是否到了,到了就让后台线程异步重建,其他线程返回旧值。

@Data
class CacheWrapper<T> {
    private T data;
    private long expireTime; // 逻辑过期时间戳
}

public Product getProductWithLogicalExpire(Long id) {
    String key = "product:" + id;
    CacheWrapper<Product> wrapper = redis.get(key);
    if (wrapper == null) return loadAndCache(id);
    if (System.currentTimeMillis() > wrapper.getExpireTime()) {
        executor.submit(() -> loadAndCache(id));  // 逻辑过期,异步重建
    }
    return wrapper.getData();  // 当前返回旧值
}

这个方案零等待,缺点是可能返回过期数据,对一致性要求高的场景不适用。我当时的选择标准是用户中心用分布式锁方案要求强一致,商品详情页用逻辑过期允许秒级延迟,首页推荐用逻辑旧数据不影响体验。

第三案:缓存雪崩,TTL集中到期的连锁反应

这就是开头618事故的那一幕。促销活动期间上了5000个新商品,运营批量导入时缓存TTL统一设了3600秒。一小时整5000个key同时过期,每秒几千个miss涌向数据库。MySQL CPU从15%飙到98%,主从延迟超过30秒。

雪崩和击穿的区别在于规模。击穿是单个key,雪崩是大批key集中在同一时间点过期,压力不是一个量级。雪崩的另一个诱因是Redis节点宕机,整个缓存层不可用,所有请求直接打到数据库,这比TTL过期更致命。

TTL随机化是最基础的防御。给每个key的过期时间加上随机偏移量。

int baseTtl = 3600;
int randomTtl = baseTtl + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, randomTtl, value);

偏移量设基础TTL的5%到10%。三千六百秒加三百秒随机,key的过期时间就均匀分布在六十一分钟内,不会同时到期。

多级缓存是更稳的方案。本地缓存Caffeine做第一层,Redis做第二层,数据库是最后兜底。Redis挂的时候本地缓存还能扛一阵。

public Product getMultiLevel(Long id) {
    String key = "product:" + id;
    Product p = localCache.getIfPresent(key);
    if (p != null) return p;  // 第一层本地缓存
    p = redis.get(key);
    if (p != null) {
        localCache.put(key, p);
        return p;  // 第二层Redis
    }
    p = db.getById(id);
    if (p != null) {
        redis.setex(key, 3600, p);
        localCache.put(key, p);  // 第三层数据库
    }
    return p;
}

降级熔断是最后的防线。用Sentinel或Resilience4j,数据库响应时间超过阈值就触发降级,直接返回兜底数据。

SentinelConfig.loadRules(List.of(
    new DegradeRule("dbQuery")
        .setGrade(CircuitBreakingStrategy.SLOW_REQUEST_RATIO)
        .setSlowRatioThreshold(0.5)
        .setTimeWindow(30)
        .setMinRequestAmount(20)
));

监控和告警:别等炸了才知道

这三个问题的共同点是发现的时候已经炸了。事后我补了这套监控。关键指标有四个。

  • 缓存命中率按小时统计,miss率突增超过10%就要告警。
  • Redis慢查询日志设10毫秒阈值。
  • 每秒miss数的QPS曲线,陡增必有异常。
  • 数据库连接池使用率超过80%就预警。
    Grafana大盘上我把这四个指标放在一起,配合Redis自带的INFO命令。instantaneous_ops_per_sec看吞吐,keyspace_hits和keyspace_misses算命中率,connected_clients看连接数。

除了监控,还有两件事要提前做。一是Redis集群高可用,生产环境建议主从加哨兵或Redis Cluster,避免单点故障直接引发雪崩。二是新业务上线前做好缓存预热,别让第一批请求直接打到数据库。

避坑清单

布隆过滤器误判率在0.1%到5%之间就够,缓存场景1%完全可接受,不是越低越好。安全敏感场景可以设更低,但普通业务没必要追求千分之一。

缓存空值的TTL要单独管理,别和业务数据用同一套策略,太长会导致新数据写入后仍然返回null,太短起不到防护作用。

Redisson看门狗默认30秒续期,业务查询耗时超过30秒锁会被其他线程抢走,慢查询场景要把watchdogTimeout调大或改用tryLock带超时参数。

TTL随机化的偏移量别超基础值的百分之十,偏移量太大会拉长数据一致性窗口。雪崩场景下别用KEYS命令排查,生产环境执行KEYS会让Redis阻塞十几秒,用SCAN代替。商品删除频繁的场景可以考虑布谷鸟过滤器替代布隆过滤器,它支持删除操作。

你们生产环境遇到过哪种缓存事故?布隆过滤器和逻辑过期你们选哪种?评论区聊聊你的方案。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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