Redis缓存三大问题实战:穿透、击穿、雪崩,我用一个电商秒杀场景讲清楚

上个月公司秒杀活动,Redis 被打挂了两次。第一次是缓存穿透,恶意请求直接打到 MySQL,数据库连接池满了;第二次是缓存雪崩,一批 key 同时过期,流量瞬间压垮后端。事后复盘,把这三个经典问题从头到尾捋了一遍,配上可运行的代码和压测数据,记录一下。

一、先看场景:一个秒杀接口

简化后的业务逻辑:

java 复制代码
public Product getProduct(Long id) {
    String key = "product:" + id;
    // 1. 查缓存
    String json = redis.get(key);
    if (json != null) {
        return JSON.parseObject(json, Product.class);
    }
    // 2. 查数据库
    Product product = productMapper.selectById(id);
    // 3. 回写缓存
    if (product != null) {
        redis.set(key, JSON.toJSONString(product), 3600);
    }
    return product;
}

看着没问题,但线上跑起来,三种翻车方式轮番上演。

二、缓存穿透:查一个不存在的数据

现象

活动开始后,监控发现 MySQL QPS 飙到 8000,Redis 命中率只有 40%。查日志发现大量请求的 id 是负数或者不存在的 ID,比如 -1、999999999。

原因

请求查询一个数据库里根本不存在的数据。缓存永远不命中,每次都打到 DB。如果是恶意攻击,可以轻松打穿数据库。

解决方案一:缓存空值

java 复制代码
public Product getProduct(Long id) {
    String key = "product:" + id;
    String json = redis.get(key);
    if (json != null) {
        // 空值标记,直接返回 null
        if ("NULL".equals(json)) {
            return null;
        }
        return JSON.parseObject(json, Product.class);
    }
    Product product = productMapper.selectById(id);
    if (product != null) {
        redis.set(key, JSON.toJSONString(product), 3600);
    } else {
        // 缓存空值,过期时间短一点,避免脏数据长期存在
        redis.set(key, "NULL", 60);
    }
    return product;
}

优点 :实现简单,立竿见影。

缺点:如果攻击者每次用不同的随机 ID,会缓存大量空值,浪费内存。需要配合限流或参数校验。

解决方案二:布隆过滤器

在缓存之前加一层布隆过滤器,把所有存在的 ID 提前放进去。请求来了先过布隆过滤器,不存在直接返回。

java 复制代码
@Component
public class BloomFilterConfig {

    private BloomFilter<Long> bloomFilter;

    @PostConstruct
    public void init() {
        // 预估 100 万数据,误判率 1%
        bloomFilter = BloomFilter.create(
            Funnels.longFunnel(), 1_000_000, 0.01);
        // 启动时加载所有商品 ID(实际项目可分页加载)
        List<Long> ids = productMapper.selectAllIds();
        ids.forEach(bloomFilter::put);
    }

    public boolean mightExist(Long id) {
        return bloomFilter.mightContain(id);
    }
}
java 复制代码
public Product getProduct(Long id) {
    // 布隆过滤器拦截
    if (!bloomFilterConfig.mightExist(id)) {
        return null;
    }
    // ... 原有缓存逻辑
}

注意:布隆过滤器有误判率(说存在但实际不存在),所以不能完全替代空值缓存,两者配合使用效果最好。另外新增商品时要同步写入布隆过滤器。

三、缓存击穿:热点 key 过期瞬间

现象

某个爆款商品 ID 的缓存刚好在活动高峰期过期,一瞬间几千个请求同时打到 DB,数据库 CPU 直接 100%。

原因

单个热点 key 过期 ,大量并发请求同时穿透到数据库。注意和穿透的区别:击穿是数据存在 但缓存失效了,穿透是数据不存在。

解决方案一:互斥锁

只让一个线程去查 DB 重建缓存,其他线程等待。

java 复制代码
public Product getProductWithLock(Long id) {
    String key = "product:" + id;
    String json = redis.get(key);
    if (json != null) {
        return JSON.parseObject(json, Product.class);
    }

    String lockKey = "lock:product:" + id;
    // SETNX 加锁,过期时间 10 秒防止死锁
    Boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查,防止重复查库
            json = redis.get(key);
            if (json != null) {
                return JSON.parseObject(json, Product.class);
            }
            Product product = productMapper.selectById(id);
            if (product != null) {
                redis.set(key, JSON.toJSONString(product), 3600);
            }
            return product;
        } finally {
            redis.delete(lockKey);
        }
    } else {
        // 没抢到锁,短暂休眠后重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductWithLock(id);
    }
}

优点 :保证一致性,DB 压力可控。

缺点:吞吐量下降,等待的线程有阻塞风险。适合强一致性场景。

解决方案二:逻辑过期

缓存永不过期,但在 value 里存一个逻辑过期时间。发现逻辑过期后,异步重建缓存,当前请求先返回旧数据。

java 复制代码
public Product getProductWithLogicalExpire(Long id) {
    String key = "product:" + id;
    String json = redis.get(key);
    if (json == null) {
        return null;
    }
    CacheData cacheData = JSON.parseObject(json, CacheData.class);
    if (cacheData.getExpireAt() > System.currentTimeMillis()) {
        return cacheData.getData();
    }
    // 逻辑过期,异步重建
    String lockKey = "lock:product:" + id;
    if (Boolean.TRUE.equals(redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS))) {
        threadPool.execute(() -> {
            try {
                Product product = productMapper.selectById(id);
                CacheData newData = new CacheData(
                    product, System.currentTimeMillis() + 3600_000);
                redis.set(key, JSON.toJSONString(newData));
            } finally {
                redis.delete(lockKey);
            }
        });
    }
    // 先返回旧数据
    return cacheData.getData();
}

优点 :高可用,不阻塞请求。

缺点:短时间数据不一致。适合秒杀商品详情这类能容忍短暂脏数据的场景。

四、缓存雪崩:大批 key 同时过期

现象

凌晨批量导入商品时,给所有 key 设置了统一的 3600 秒过期时间。一小时后,几千个 key 同时失效,DB QPS 从 200 瞬间冲到 6000,连接池被打满。

原因

大量 key 在同一时间过期,或者 Redis 实例宕机,所有流量直接打到 DB。

解决方案

1. 过期时间打散

java 复制代码
// 基础过期时间 + 随机偏移,避免同时失效
int expire = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.set(key, json, expire);

2. 多级缓存

本地缓存(Caffeine)+ Redis,Redis 挂了还有本地缓存兜底。

java 复制代码
LoadingCache<Long, Product> localCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .build(id -> getProductFromRedisOrDb(id));

3. 熔断降级

用 Sentinel 或 Hystrix 给 DB 调用加熔断,超过阈值直接返回兜底数据,保护数据库。

java 复制代码
@SentinelResource(value = "queryProduct",
    blockHandler = "handleBlock",
    fallback = "handleFallback")
public Product queryProduct(Long id) {
    return getProduct(id);
}

public Product handleBlock(Long id, BlockException e) {
    // 限流后的兜底:返回默认商品或缓存中的旧数据
    return new Product(id, "商品暂时火爆,请稍后重试");
}

4. Redis 高可用

主从 + 哨兵或 Cluster 集群,避免单点故障导致的全量雪崩。

五、三种问题对比总结

问题 触发条件 数据是否存在 核心危害 首选方案
穿透 查询不存在的数据 否 恶意流量打穿 DB 布隆过滤器 + 空值缓存
击穿 单个热点 key 过期 是 瞬时并发压垮 DB 互斥锁 / 逻辑过期
雪崩 大量 key 同时过期或 Redis 宕机 是 整体流量打垮 DB 过期打散 + 多级缓存 + 熔断

六、压测数据对比

用 JMeter 模拟 5000 并发查询同一个热点 key,缓存过期后的表现:

方案 平均 RT DB QPS 峰值 错误率
无保护 1200ms 4800 12%
互斥锁 85ms 180 0%
逻辑过期 12ms 15(异步) 0%

逻辑过期在可用性上明显更优,互斥锁在一致性上更稳。实际项目里,商品详情用逻辑过期,库存扣减用互斥锁,按场景选。

七、最后的建议

  1. 空值缓存的过期时间别设太长,60 秒左右够了,否则新增数据后会有延迟。
  2. 布隆过滤器要留扩容方案,数据量增长后误判率会上升,可以考虑 RedisBloom 模块。
  3. 互斥锁的过期时间要大于业务执行时间,否则锁提前释放会出问题,可以用 Redisson 的看门狗机制。
  4. 逻辑过期的异步重建要用独立线程池,别跟业务线程池混用。
  5. 监控一定要加:缓存命中率、DB QPS、Redis 连接数、慢查询,这三个问题都能从监控里提前发现苗头。

缓存三大问题说起来是老生常谈,但真到了高并发场景,细节决定成败。希望这篇踩坑记录能帮你少挂一次线上。


参考:

  • 《Redis 设计与实现》黄健宏
  • Redisson 官方文档:https://redisson.org
  • 布隆过滤器原理:Bloom Filter 论文(Burton H. Bloom, 1970)
相关推荐
这个DBA有点耶1 小时前
Merge还是Materialize?一文讲透MySQL 8.0 CTE底层的执行计划博弈与性能陷阱
数据库·mysql·架构
鶴哥只手遮天2 小时前
《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路
redis·后端·开源
70后写java的老码农2 小时前
docker-compose 部署 Redis Stack Server 6.2.6-v19
redis·docker
Thanks_ks2 小时前
MySQL 的 REPEATABLE READ 到底能不能防幻读——从快照读、当前读到 Next-Key Lock
数据库·mysql·innodb·mvcc·锁机制·事务隔离级别·间隙锁
千桐科技3 小时前
qKnow智能体构建平台专业版 v3.1.5 发布:扩容工作流编排调用能力,上线Text2SQL插件,拓宽数据类智能应用构建边界
服务器·数据库·oracle·qknow
宇灬宇3 小时前
oracle直方图测试
数据库·oracle
Henry-SAP3 小时前
SAP PP中的核心业务流程——离散制造、重复制造、流程制造三大模式解析
前端·数据库·人工智能·制造
Pniubi3 小时前
mysql 一张表可以有多少列
数据库·mysql