【从零开始——Redis 进化日志|Day6】缓存的三剑客:穿透、击穿、雪崩,到底怎么防?(附生产级代码实战)

兄弟们,欢迎来到 Redis 进化日志的第六天。在 Day 5 里,我们用分布式锁解决了"超卖"问题。现在系统看起来固若金汤了对吧?

错!

在真实的互联网生产环境中,Redis 并不总是我们的守护神。如果使用姿势不对,它反而会成为击垮数据库的"帮凶"。

当海量请求绕过 Redis 直接轰炸脆弱的 MySQL 时,这种现象统称为缓存失效问题。

最经典的三个场景就是:穿透、击穿、雪崩。


一、 缓存穿透

1. 深度解析:什么是穿透?

核心定义 :请求的数据在 Redis 中不存在 ,且在 数据库中也不存在

场景模拟

  • 恶意攻击 :黑客写了个脚本,疯狂请求 id = -1id = uuid 这种数据库里绝对没有的数据。

  • 业务误操作:前端代码 Bug,传了一个不存在的商品 ID。

后果:

这就好比你去饭店点菜,非要点"女神的眼泪"。服务员(Redis)说没有,你就冲进厨房(DB)去找厨师。你每一次问,厨师都要停下来找一圈。如果有 10 万个人同时点"女神的眼泪",厨师(DB)直接累死,导致正常想吃"西红柿炒蛋"的客人也无法服务。

2. 解决方案 & 实战代码

方案 A:缓存空对象 (Cache Null)

即便 DB 查不到,我也存一个 null 或特定标识符到 Redis,并设置较短的过期时间(TTL)。

方案 B:布隆过滤器 (Bloom Filter) ------ 推荐

原理:在请求到达 Redis 之前,先加一道"安检门"。布隆过滤器利用位数组和哈希函数,能快速判断"这个 Key 一定不存在"或"可能存在"。如果它说不存在,直接驳回请求。

生产级代码(基于 Redisson):

java 复制代码
@Service
public class ProductService {

    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ProductMapper productMapper;

    private RBloomFilter<String> bloomFilter;

    // 1. 初始化布隆过滤器(通常在系统启动时预热)
    @PostConstruct
    public void initBloomFilter() {
        bloomFilter = redissonClient.getBloomFilter("product-id-filter");
        // 初始化参数:预计元素 100万,误差率 3%
        // 注意:误差率越低,占用的位数组越大,性能略微下降
        bloomFilter.tryInit(1000000L, 0.03);
        
        // 模拟预热:将所有存在的商品 ID 加载进去
        List<String> allIds = productMapper.selectAllIds();
        for (String id : allIds) {
            bloomFilter.add(id);
        }
    }

    public Product getProduct(String id) {
        String cacheKey = "product:" + id;

        // 【第一道防线】:布隆过滤器拦截
        // 如果布隆过滤器说不存在,那就一定不存在,直接返回,保护 Redis 和 DB
        if (!bloomFilter.contains(id)) {
            log.warn("非法请求,布隆过滤器拦截: {}", id);
            return null; 
        }

        // 【第二道防线】:查 Redis
        String json = redisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.isNotBlank(json)) {
            // 如果存的是空值标识(防止缓存穿透的兜底方案),直接返回
            if ("EMPTY_OBJECT".equals(json)) {
                return null;
            }
            return JSON.parseObject(json, Product.class);
        }

        // 【第三道防线】:查 DB(代码见下文击穿部分)
        // ...
        return null;
    }
}

二、 缓存击穿

1. 深度解析:什么是击穿?

核心定义 :Redis 中某个 热点 Key (比如"当季爆款")在某一瞬间 刚好过期 ,而此时恰好有 海量并发请求 同时访问这个 Key。

区别

  • 穿透:查"根本不存在"的数据。

  • 击穿:查"存在但过期"的数据。

后果:

Redis 这个盾牌瞬间破了一个洞,所有流量像子弹一样穿过这个洞打在 DB 上。DB 的 CPU 和内存瞬间飙升,连接池被占满。

2. 解决方案 & 实战代码

方案:互斥锁 (Mutex Lock)

当发现缓存失效时,不是所有人都去查库。而是只有拿到锁的那一个线程去查库、写缓存,其他线程等待(自旋)或稍后重试。

生产级代码(双重检查锁模式):

java 复制代码
public Product queryWithMutex(String id) {
    String cacheKey = "product:" + id;
    
    // 1. 先查缓存
    String json = redisTemplate.opsForValue().get(cacheKey);
    if (StringUtils.isNotBlank(json)) {
        return JSON.parseObject(json, Product.class);
    }

    // 2. 缓存未命中,准备重建缓存。定义分布式锁 Key
    String lockKey = "lock:product:" + id;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        // 3. 尝试加锁(阻塞等待 5秒,或者只试一次)
        boolean isLocked = lock.tryLock(5, TimeUnit.SECONDS);
        if (isLocked) {
            try {
                // 【关键点】:Double Check (双重检查)
                // 为什么?因为在你排队等锁的时候,可能前一个人已经把数据查出来塞进 Redis 了
                // 如果不查,你又会去查一遍 DB,这就浪费了
                json = redisTemplate.opsForValue().get(cacheKey);
                if (StringUtils.isNotBlank(json)) {
                    return JSON.parseObject(json, Product.class);
                }

                // 4. 真的没人查过,我去查 DB
                Product product = productMapper.selectById(id);
                
                // 5. 写入 Redis(记得处理空值,防止穿透)
                if (product == null) {
                    redisTemplate.opsForValue().set(cacheKey, "EMPTY_OBJECT", 5, TimeUnit.MINUTES);
                } else {
                    redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
                }
                return product;
            } finally {
                // 6. 释放锁
                lock.unlock();
            }
        } else {
            // 7. 没抢到锁(理论上 tryLock 会阻塞,走到这也是异常情况),降级或重试
            Thread.sleep(50);
            return queryWithMutex(id); // 递归重试
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("系统繁忙");
    }
}

三、 缓存雪崩

1. 深度解析:什么是雪崩?

核心定义

  1. 场景 A(绝望):Redis 节点宕机,所有缓存不可用。

  2. 场景 B(常见)大量 的 Key 在 同一时间 集中过期。

后果:

想象一下雪崩的场景,漫天的雪(请求)直接压塌了房子(数据库)。DB 瞬间承受平时的几十倍压力,直接报警宕机。

2. 解决方案 & 实战代码

方案 A:打散过期时间 (Random TTL)

在存入 Redis 时,不要让所有 Key 都活 1 小时。给它们加一个 随机值。

代码演示:

java 复制代码
// 设置缓存时,给 TTL 加一个随机抖动
// 基础过期时间:60分钟
long baseTtl = 60 * 60; 
// 随机抖动:0-300秒 (5分钟)
long randomTtl = new Random().nextInt(300); 

long finalTtl = baseTtl + randomTtl;

// 这样,10万个 Key 会在 5 分钟内陆续过期,而不是在第 60 分钟那一秒同时过期
redisTemplate.opsForValue().set(key, value, finalTtl, TimeUnit.SECONDS);

方案 B:高可用架构

  • 使用 Redis ClusterSentinel,防止单点故障。

  • 配置 Hystrix/Sentinel 限流降级。如果 Redis 真的崩了,直接返回"系统繁忙,请稍后再试",哪怕得罪用户,也要保住数据库。


面试官对线环节 (Interview Combat)

面试官问这块内容,通常有固定的套路。这里给你准备好了满分回答逻辑

Q1: 简单说一下穿透、击穿、雪崩的区别?

思路:用一句话概括核心差异,不要背长篇大论。

回答:

  • 穿透是查"完全不存在"的数据,流量直接打穿到 DB。

  • 击穿是查"热点且刚好过期"的数据,单点并发把 DB 打崩。

  • 雪崩是"大面积"Key 同时过期或 Redis 宕机,导致 DB 全面崩溃。

Q2: 你们项目中怎么解决击穿的?为什么不用 synchronized?

思路:体现分布式思维。

回答:

因为我们的服务是集群部署的(多台服务器),synchronized 只能锁住当前 JVM 进程,防不住其他服务器的流量。

所以我们用了 Redis 分布式锁(Redisson)。在查 DB 前先抢锁,并且在拿到锁之后做了 Double Check(双重检查),确保不会重复查询数据库。

Q3: 布隆过滤器有误判怎么处理?

思路:承认缺点,说明权衡(Trade-off)。

回答:

是的,布隆过滤器存在误判率(它说存在,可能实际不存在;但它说不存在,就一定不存在)。

在我们的业务中,我们容忍了极低概率的"误判穿透"(比如 1%),因为这已经过滤掉了 99% 的恶意流量,数据库完全扛得住。如果业务要求绝对精确,我们也可以考虑配合 Redis 的 BitMap 白名单机制。


总结:一张表看懂

问题 关键特征 形象比喻 核心解法
穿透 不存在的数据 去饭店点菜单上没有的菜,厨师白忙活 布隆过滤器、缓存空对象
击穿 过期热点数据 爆款菜刚好卖完,所有人围着厨师要 互斥锁 (分布式锁)、逻辑过期
雪崩 海量Key同时过期 饭店大厨突然请假,所有菜都做不出来 随机 TTL、Redis 集群、降级

下期预告

防住了外部攻击,咱们内部的数据一致性怎么保证?

面试官最爱问的"死锁题"来了:先删缓存还是先改数据库?什么是延时双删?

【Day 7】双写一致性难题:数据库与缓存如何不再"打架"?

关注专栏,带你接着卷!

相关推荐
地球没有花2 小时前
tw引发的对redis的深入了解
数据库·redis·缓存·go
可爱又迷人的反派角色“yang”2 小时前
k8s(七)
java·linux·运维·docker·云原生·容器·kubernetes
侧耳4292 小时前
android9_box hdmi铺不满的问题
android·java
测试工程师成长之路2 小时前
用 MySQL 玩转数据可视化:从底层驱动到商业智能
数据库·mysql·信息可视化
风象南2 小时前
像 ChatGPT 一样丝滑:Spring Boot 如何实现大模型流式(Streaming)响应?
java·spring boot·后端
jiaguangqingpanda2 小时前
Day23-20260119
java·开发语言
践行见远2 小时前
django之序列化
android·数据库·django
尘觉2 小时前
创作 1024 天|把热爱写成长期主义
数据库·1024程序员节
Java程序员威哥2 小时前
Spring Boot 3.x 云原生终极适配:GraalVM 原生镜像构建 + Serverless 生产级部署(完整实战+最优模板)
java·开发语言·spring boot·后端·云原生·serverless·maven