欢迎来到窥视未来的博客

https://github.com/lwx57280 https://gitee.com/li_VillageHead

双11大促前夕,缓存全挂了,我从“被骂”到“救火”的全复盘

兄弟们,你们遇到过这种情况吗?凌晨2点,业务方突然在群里@你:“系统卡死了,页面全白了!”你爬起来一看,数据库连接池被打满,慢查询堆积如山,CPU飙升到95%——原因很简单:Redis缓存挂了

那天晚上我翻了整整4个小时的源码和监控,才发现我们所谓的“缓存架构”,在穿透、击穿、雪崩面前,简直就是纸糊的。今天我就把这次“翻车”经历完整复盘,从三个问题的本质区别,到布隆过滤器、互斥锁、永不过期、熔断降级的组合拳方案,一次性讲透。

一、事故还原:缓存是怎么一步步被打穿的?

那天晚上8点,业务方开始推一波大促预热活动。流量从平时的5000 QPS瞬间飙到4万 QPS。我们的架构是:Nginx → 应用服务 → Redis → MySQL正常情况下,Redis命中率在95%以上,MySQL稳稳当当。

但那天,Redis集群里一批热点key刚好在活动开始后陆续过期。更糟的是,有爬虫在疯狂扫描不存在的商品ID——这些ID在Redis和MySQL里都不存在,每次请求都直穿缓存打到数据库。

结果MySQL连接池在3分钟内被耗尽,大量请求超时,页面白屏,用户无法下单。

MySQL-Redis

 监控数据显示:活动开始前MySQL QPS约2000,活动开始后飙到2.8万,直接打满了数据库的最大连接数。

二、缓存穿透:最隐蔽的“杀手”

2.1 什么是缓存穿透?

缓存穿透是指查询的数据在Redis和数据库中都不存在。因为缓存中查不到,每次请求都会穿透缓存直达数据库

这类请求通常来自:

  • 恶意攻击(爬虫用不存在的ID扫库)

  • 业务bug(前端传入了非法参数)

  • 缓存未预热的新数据

危害如果攻击者每秒发起1万个不存在ID的请求,相当于数据库要承受1万QPS的无效查询。假设单次查询耗时10ms,1万QPS意味着数据库每秒要处理10000次查询——这还没算正常业务流量

 

2.2 解决方案一:布隆过滤器

布隆过滤器是一个概率型数据结构,用极小的内存空间快速判断一个元素“一定不存在”或“可能存在

原理

  • 底层是一个二进制位数组,初始全部为0

  • 添加元素时,用k个哈希函数计算k个位置,全部设为1

  • 查询时,同样计算k个位置,如果有一个为0则元素一定不存在;如果全部为1,则元素可能存在(有误判可能)

bloomFilter

 Guava实现示例:

// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charsets.UTF_8),
    1000000,    // 预期元素数量
    0.001       // 误判率 0.1%
);

// 缓存所有合法Key
for (String key : allValidKeys) {
    bloomFilter.put(key);
}

// 查询前置过滤
public Object getData(String key) {
    if (!bloomFilter.mightContain(key)) {
        return null;  // 一定不存在,直接拦截
    }
    // 继续查Redis和MySQL
}

布隆过滤器为什么不能删除?

布隆过滤器的每个bit位可能被多个元素共享。如果删除某个元素,将该元素对应的bit位重置为0,可能会误删其他元素的标记,导致后续查询时把本应存在的元素误判为不存在

那数据变更时怎么办?

  • 定期重建布隆过滤器(如每天凌晨从DB全量刷新)

  • 或使用支持删除的Counting Bloom Filter(用计数器代替bit位)

2.3 解决方案二:缓存空值

对于查询结果为空的key,也写入一个空值到缓存,并设置较短的过期时间

Object data = redis.get(key);
if (data == null) {
    data = db.query(key);
    if (data == null) {
        redis.setex(key, 300, "NULL");  // 缓存空值5分钟
        return null;
    }
    redis.set(key, data);
}

缺点大量不存在的key会占用内存(缓存污染);如果真实数据后来写入,空值过期前会返回错误结果

建议布隆过滤器 + 空值缓存组合使用——布隆过滤器拦截大部分非法请求,空值缓存兜底。

三、缓存击穿:热点key失效的“瞬间爆破”

3.1 什么是缓存击穿?

缓存击穿是指一个访问极其频繁的热点key在过期失效的瞬间,大量并发请求同时发现缓存失效,一起涌向数据库

典型场景:某明星微博突然爆了,缓存了该明星信息的key在高峰期刚好过期,成千上万的请求瞬间打到数据库。

3.2 解决方案一:互斥锁(分布式锁)

当缓存失效时,不立即查数据库,而是先获取分布式锁。只有拿到锁的线程去查数据库并重建缓存,其他线程等待

3.2 解决方案一:互斥锁(分布式锁)

当缓存失效时,不立即查数据库,而是先获取分布式锁。只有拿到锁的线程去查数据库并重建缓存,其他线程等待

public Object getWithLock(String key) {
    // 第一重检查:先查缓存
    Object data = redis.get(key);
    if (data != null) {
        return data;
    }
    
    // 尝试获取分布式锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 30, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 第二重检查:拿到锁后再查一次缓存(DCL)
            data = redis.get(key);
            if (data != null) {
                return data;
            }
            // 真正查数据库
            data = db.query(key);
            redis.setex(key, 3600, data);
            return data;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,等待重试
        Thread.sleep(50);
        return getWithLock(key);  // 递归重试
    }
}

关键点:

  • 双重检查(DCL)获取锁后再次检查缓存,避免重复查库

  • 锁超时防止持有锁的线程挂掉导致死锁

  • 重试机制未获取锁的线程等待后重试

3.3 解决方案二:热点数据永不过期

物理永不过期不设置Redis的expire过期时间,只在数据更新时主动刷新缓存

辑过期在缓存value中额外存储一个逻辑过期时间。业务线程发现逻辑过期后,不阻塞等待,而是异步重建缓存,自己先返回旧数据

public class CacheWithLogicExpire {
    public Object getData(String key) {
        String json = redis.get(key);
        CacheWrapper wrapper = JSON.parseObject(json, CacheWrapper.class);
        
        // 判断逻辑是否过期
        if (wrapper.getExpireTime() < System.currentTimeMillis()) {
            // 尝试获取锁,异步重建
            if (tryLock("rebuild:" + key)) {
                new Thread(() -> rebuildCache(key)).start();
            }
            // 不管是否拿到锁,都返回旧数据
        }
        return wrapper.getData();
    }
}

两种方案对比:

 
方案优点缺点
互斥锁 数据一致性好 有性能损耗,可能阻塞
永不过期(物理) 无阻塞,性能最佳 需要主动更新机制
永不过期(逻辑) 无阻塞,异步更新 实现复杂,可能返回旧数据

四、缓存雪崩:最致命的“多米诺骨牌”

4.1 什么是缓存雪崩?

缓存雪崩是指大量缓存key在同一时间过期,或者Redis服务整体不可用,导致所有请求同时穿透到数据库,引发数据库过载甚至宕机

两种触发场景:

  • 集中过期型大量key设置了相同的过期时间(如凌晨12点统一过期)

  • 服务崩溃型Redis集群主节点宕机,所有缓存瞬间失效

4.2 解决方案一:过期时间加随机偏移

在基础过期时间上增加随机值,避免集中失效

// 基础过期时间3600秒,随机偏移±300秒
int baseExpire = 3600;
int randomOffset = new Random().nextInt(600) - 300;  // -300 ~ +300
int actualExpire = baseExpire + randomOffset;
redis.setex(key, actualExpire, data);

4.3 解决方案二:多级缓存 + 熔断降级

多级缓存架构:

  • L1:本地缓存(Caffeine/Ehcache),毫秒级响应

  • L2:分布式缓存(Redis),集群部署

  • L3:数据库(MySQL),最终数据源

熔断降级当Redis响应异常或超时时,通过熔断机制快速返回默认值,避免请求持续等待

// Sentinel熔断配置示例
@SentinelResource(
    value = "getData",
    fallback = "fallbackGetData",  // 降级方法
    blockHandler = "blockHandler"  // 限流处理
)
public Object getData(String key) {
    // 正常业务逻辑
}

public Object fallbackGetData(String key, Throwable e) {
    // 返回默认值或兜底数据
    return getDefaultData(key);
}

 

降级策略:

  • 返回兜底数据(如“系统繁忙,请稍后重试”)

  • 返回本地缓存的过期数据

  • 返回静态默认值

4.4 解决方案三:缓存高可用部署

  • Redis Cluster数据分片存储,单个节点宕机只影响部分数据

  • 主从复制 + 哨兵自动故障转移

  • 读写分离减轻主库压力

综合方案对比:

 
雪崩原因解决方案优先级
大量key集中过期 过期时间+随机偏移 ⭐⭐⭐⭐⭐
Redis节点故障 集群部署 + 主从切换 ⭐⭐⭐⭐⭐
数据库压力过大 限流 + 熔断降级 ⭐⭐⭐⭐
缓存完全不可用 多级缓存(本地缓存兜底) ⭐⭐⭐

五、综合防御方案:从单点到立体防护

以下是我们在事故后上线的六层防御体系

mermaid-1782098898181

 

这套方案上线后,即使Redis集群短暂抖动,系统也能通过本地缓存和降级策略保住核心功能

 

六、三个问题的对比与总结

 
问题数据是否存在发生时机核心危害首选方案
缓存穿透 Redis无、DB无 任何时刻 无效查询打满DB 布隆过滤器
缓存击穿 Redis无、DB有 热点key过期瞬间 瞬间高并发冲击DB 互斥锁/永不过期
缓存雪崩 Redis大量无、DB有 大量key同时过期/Redis宕机 数据库过载宕机 随机过期+集群+降级

三个核心教训:

  1. 穿透靠“拦”布隆过滤器是性价比最高的防线,内存占用极小

  2. 击穿靠“锁”热点key必须用互斥锁或永不过期保护,不能裸奔

  3. 雪崩靠“散”过期时间加随机值,同时做好熔断降级的兜底预案

兄弟们,你们在生产环境中遇到过缓存三兄弟的坑吗?是布隆过滤器误判导致业务异常,还是互斥锁超时没处理好?评论区聊聊,我帮你们分析分析。

posted on 2026-06-22 11:27  k8s-Mango  阅读(0)  评论(0)    收藏  举报

导航