Redis 缓存实战:雪崩、穿透、击穿一次讲透

缓存用好了是性能翻倍,用不好凌晨三点把你叫醒。

Redis 在大多数系统里扮演的角色是挡在数据库前面的一堵墙。正常情况下,请求打到 Redis,命中就走,数据库压力不大。但这堵墙有三个洞:它可能整堵墙同时塌,可能有人绕过墙直接锤数据库,也可能墙上最忙的那块砖碎了。

对应过来就是雪崩、穿透、击穿。一个一个说。

一、缓存雪崩:整堵墙同时塌了

什么是雪崩: 大量缓存在同一个时间点集中过期,所有请求瞬间穿透到数据库,数据库扛不住直接挂。

典型的触发场景:你批量导入了 10 万条商品数据,所有缓存都设了 24 小时过期。24 小时后,10 万个 key 在同一秒集体死亡。下一秒打进来的请求全部查库,数据库连接池瞬间打满。

怎么解决,三道防线:

复制代码
// 防线一:过期时间加随机值,错开集体死亡
// 不要这样
redisTemplate.opsForValue().set(productKey, value, 24, TimeUnit.HOURS);

// 改成这样,随机加 1-6 小时
int randomHours = ThreadLocalRandom.current().nextInt(1, 7);
redisTemplate.opsForValue().set(productKey, value, 24 + randomHours, TimeUnit.HOURS);
// 防线二:永不过期 + 异步更新
// 缓存本身不设过期,由定时任务定期刷新
@Scheduled(fixedDelay = 300000) // 每5分钟
public void refreshHotProducts() {
    List<Product> hotProducts = productService.getHotProducts();
    for (Product p : hotProducts) {
        String key = "product:" + p.getId();
        redisTemplate.opsForValue().set(key, p, Duration.ofDays(7));
    }
}
// 注意:这里设了7天兜底,防止定时任务挂了缓存永远不更新
// 防线三:多级缓存 + 限流兜底
// 如果 Redis 也挂了,本地缓存拦截一波
@Cacheable(value = "product", key = "#id", unless = "#result == null")
public Product getProduct(Long id) {
    return productMapper.selectById(id);
}
// 数据库层加限流,即使被打穿也不至于雪崩到宕机
// Sentinel / RateLimiter 随便选一个,核心是保住数据库不挂

雪崩的核心矛盾不是数据过期,是所有东西同时过期。错开时间就消解了 80% 的风险。

二、缓存穿透:有人绕过墙直接锤数据库

什么是穿透: 查询一个不存在的数据------缓存里没有,数据库里也没有。正常逻辑会直接查库,然后发现是空的,不写缓存。下次同样的非法请求再来,继续查库。恶意攻击者可以构造一大堆不存在的 ID,把你的数据库活活打死。

复制代码
// 漏洞代码
public Product getProduct(Long id) {
    String key = "product:" + id;
    Product p = (Product) redisTemplate.opsForValue().get(key);
    if (p != null) {
        return p;
    }
    p = productMapper.selectById(id);
    if (p != null) {
        redisTemplate.opsForValue().set(key, p, 24, TimeUnit.HOURS);
    }
    // BUG:p 是 null 的时候什么都不做,下次还查库
    return p;
}

怎么修:

复制代码
// 方案一:缓存空值,把「没有」也存起来
public Product getProduct(Long id) {
    String key = "product:" + id;
    Product p = (Product) redisTemplate.opsForValue().get(key);
    if (p != null) {
        // 判断是不是空值标记
        if (p.getId() == -1L) return null;
        return p;
    }
    p = productMapper.selectById(id);
    if (p == null) {
        // 缓存一个空对象,短过期,防止被恶意刷
        Product empty = new Product();
        empty.setId(-1L);
        redisTemplate.opsForValue().set(key, empty, 5, TimeUnit.MINUTES);
        return null;
    }
    redisTemplate.opsForValue().set(key, p, 24, TimeUnit.HOURS);
    return p;
}

空值缓存的问题是如果有人刷几百万个不同的非法 ID,Redis 内存会被空对象占满。所以:

复制代码
// 方案二:https://zhida.zhihu.com/search?content_id=278841174&content_type=Article&match_order=1&q=%E5%B8%83%E9%9A%86%E8%BF%87%E6%BB%A4%E5%99%A8&zhida_source=entity,事前拦截不存在的 key
// 用 Guava 或 Redisson 的布隆过滤器
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("product_filter");
bloomFilter.tryInit(1000000L, 0.01); // 100万容量,1%误判率

// 数据写入时同步加入过滤器
bloomFilter.add("product:" + id);

// 查询前先过过滤器
public Product getProduct(Long id) {
    String key = "product:" + id;
    if (!bloomFilter.contains(key)) {
        return null; // 一定不存在,直接返回,不查库
    }
    // 剩下走正常缓存逻辑
    // ...
}

布隆过滤器的代价:有 1% 误判率,说存在但实际不存在。多查一次数据库是能接受的。它的价值是拦住 99% 的恶意穿透请求。

推荐组合:布隆过滤器前置拦截 + 空值缓存兜底。布隆过滤器拦不住的那 1% 由空值缓存接住。

三、缓存击穿:墙上最忙的那块砖碎了

什么是击穿: 一个热点 key 过期了,这个 key 平时扛几万 QPS。过期瞬间,几万请求同时打向数据库。和雪崩的区别是:雪崩是一大片 key 同时过期,击穿是单个爆款 key 过期。

典型场景:秒杀商品的库存缓存、首页推荐位的配置缓存、大 V 的粉丝数缓存。

复制代码
// 漏洞代码
public Product getHotProduct(Long id) {
    String key = "product:" + id;
    Product p = (Product) redisTemplate.opsForValue().get(key);
    if (p == null) {
        // 缓存过期了,直接查库
        // 几千个线程同时走到这里,全部查库
        p = productMapper.selectById(id);
        redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES);
    }
    return p;
}

怎么修:

复制代码
// 方案一:https://zhida.zhihu.com/search?content_id=278841174&content_type=Article&match_order=1&q=%E4%BA%92%E6%96%A5%E9%94%81&zhida_source=entity,只让一个人去查库
public Product getHotProduct(Long id) {
    String key = "product:" + id;
    String lockKey = "lock:product:" + id;

    Product p = (Product) redisTemplate.opsForValue().get(key);
    if (p != null) return p;

    // SET NX EX,抢锁,超时 10 秒防止死锁
    Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 抢到锁,二次检查
            p = (Product) redisTemplate.opsForValue().get(key);
            if (p != null) return p;

            p = productMapper.selectById(id);
            redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES);
            return p;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // 没抢到锁,等 50ms 重试
        try { Thread.sleep(50); } catch (InterruptedException ignored) {}
        return getHotProduct(id); // 递归重试
    }
}
// 方案二:https://zhida.zhihu.com/search?content_id=278841174&content_type=Article&match_order=1&q=%E9%80%BB%E8%BE%91%E8%BF%87%E6%9C%9F&zhida_source=entity,不删 key,只更新 value
// 适合容忍短期旧数据的场景
public Product getHotProductV2(Long id) {
    String key = "product:" + id;
    Product p = (Product) redisTemplate.opsForValue().get(key);
    if (p == null) {
        // 首次加载
        p = productMapper.selectById(id);
        redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES);
        return p;
    }
    // 判断是否逻辑过期(在 value 里塞一个过期时间戳)
    if (p.getExpireTime() < System.currentTimeMillis()) {
        // 异步更新,当前请求直接返回旧数据
        threadPool.submit(() -> refreshCache(id));
    }
    return p; // 返回可能过期的旧数据,但不会击穿
}

逻辑过期方案的关键取舍:宁愿用户看到 30 秒前的旧数据,也别让他等 5 秒白屏。

区别速查表

类型 现象 本质 核心解法
雪崩 大量 key 同时过期 过期时间一致 随机 TTL + 永不过期异步刷新
穿透 查不存在的 key 无缓存屏障 布隆过滤器 + 缓存空值
击穿 单个热点 key 过期 高并发查同一个 互斥锁 + 逻辑过期

最后

不管你用不用 Redis,这三类问题本质上都是同一件事:当缓存和数据库之间的假设破裂时,你的系统有没有兜底。

一个缓存 key 过期了你没有互斥锁,说明你假设了缓存永远能命中。缓存不存在你没有布隆过滤器,说明你假设了所有请求都是合法的。大量 key 同时过期你没有随机 TTL,说明你假设了过期时间是均匀分布的。

假设被打破的那一刻,就是你的系统开始雪崩的那一刻。

相关推荐
Steadfast_GG2 小时前
Redis典型应用 - 缓存,缓存更新策略,内存淘汰策略,缓存预热,缓存穿透,缓存雪崩,缓存击穿
redis·缓存穿透·缓存击穿·缓存雪崩·缓存预热·缓存更新策略·内存淘汰策略
赵渝强老师2 小时前
【赵渝强老师】使用Oracle可传输的表空间迁移数据
数据库·oracle
IpdataCloud3 小时前
跨区服玩家延迟高怎么办?用IP离线库自动分配到最近服务器
数据库·tcp/ip·github·php·ip
wuqingshun3141594 小时前
MYSQL默认的事务隔离级别是什么?为什么选择这个级别?
数据库·mysql
空中湖5 小时前
电池简史(五):极限之梦与终章——锂硫、锂空气、量子电池,以及你该知道的电池投资与生活指南
数据库·程序人生
蓝胖子酱6 小时前
在 Electron 中用 electron-store 实现加密存储与自定义格式文件
前端·javascript·数据库
snow@li7 小时前
Redis:数据库语法速查表 / 全景梳理
数据库·redis·缓存
Devlive 开源社区7 小时前
Nebula 1.6.0 发布:跨区域桶自动路由、秒传、文本/PDF 预览一次到位
数据库·sql
这个DBA有点耶7 小时前
导个数据中文全变问号?字符集这事儿真没那么简单
数据库·mysql·代码规范