Redis缓存三大问题:穿透击穿雪崩防护实战

Redis 缓存用得好,数据库压力能降一个数量级;用得不好,缓存本身就是故障放大器。生产上最常见的三类事故——缓存穿透、缓存击穿、缓存雪崩,表现都是”数据库突然被打满、接口大面积超时”,但成因和解法完全不同。本文把这三个问题的边界讲清楚,并给出可以直接落地的防护代码:空值缓存、布隆过滤器、分布式锁重建、逻辑过期与 TTL 打散。

一、先分清三个概念:不要混着治

很多团队排查时把三者混为一谈,结果药不对症。它们的区别只在于三个维度:请求的数据在数据库里存不存在失效的 key 是一个还是一批压力落到哪一层

问题触发条件数据是否存在影响范围核心解法
缓存穿透大量查询不存在的 keyDB 中也不存在每次都打到 DB空值缓存 + 布隆过滤器
缓存击穿单个热点 key 过期瞬间DB 中存在并发线程同时重建互斥锁 / 逻辑过期
缓存雪崩大批 key 同时过期或 Redis 宕机DB 中存在整层缓存失效TTL 打散 + 高可用 + 降级

记住一句判断口诀:查不到的是穿透,热点掉的是击穿,一起掉的是雪崩。如果你还不熟悉 Redis 的基础数据结构与持久化配置,建议先看这篇 Redis 入门与常用数据结构实战 打好底子。

二、缓存穿透:拦住”根本不存在”的查询

典型场景是被恶意刷接口:攻击者用自增或随机 ID 请求 /api/user/999999999,这些 ID 在缓存和数据库都不存在,于是每一个请求都完整走一遍”查缓存未命中 → 查数据库无结果 → 不写缓存”的流程。数据库连接池很快被打满。

方案一:空值缓存(最快见效)

数据库查不到时,也往 Redis 写一个短 TTL 的空标记。实现简单,5 分钟就能上线,代价是占用少量内存,且要处理”数据后来真的被创建了”的一致性问题——所以空值 TTL 必须短(30 秒到 2 分钟),并在写入业务数据时主动删除空标记。

private static final String NULL_HOLDER = "__NULL__";

public User getUser(Long id) {
    String key = "user:" + id;
    String cached = redis.opsForValue().get(key);

    // 1. 命中空标记,直接返回,不再打 DB
    if (NULL_HOLDER.equals(cached)) {
        return null;
    }
    if (cached != null) {
        return JSON.parseObject(cached, User.class);
    }

    // 2. 未命中,查库
    User user = userMapper.selectById(id);
    if (user == null) {
        // 关键:空结果也写缓存,TTL 要短
        redis.opsForValue().set(key, NULL_HOLDER, 60, TimeUnit.SECONDS);
        return null;
    }
    redis.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
    return user;
}

// 新增用户时必须清掉空标记,否则 60 秒内查不到新数据
public void createUser(User user) {
    userMapper.insert(user);
    redis.delete("user:" + user.getId());
}

方案二:布隆过滤器(应对大规模刷量)

当攻击 ID 空间极大(比如随机 64 位数字),空值缓存会把内存撑爆。此时用布隆过滤器在入口拦截:它以极小的内存判断”某个 key 一定不存在”或”可能存在”,存在假阳性但没有假阴性——这正好符合我们的需求,被误判为”可能存在”的少量请求放过去走一次 DB 是可接受的。

# 方式 A:Redis 官方 RedisBloom 模块(推荐,无需应用侧维护)
# 预期元素 100 万,误判率 0.1%,实际占用约 1.7MB
127.0.0.1:6379> BF.RESERVE user:bloom 0.001 1000000
OK
127.0.0.1:6379> BF.ADD user:bloom 10086
(integer) 1
127.0.0.1:6379> BF.EXISTS user:bloom 10086
(integer) 1
127.0.0.1:6379> BF.EXISTS user:bloom 999999999
(integer) 0      # 明确不存在,接口可直接返回 404
// 方式 B:Redisson 实现(不依赖 RedisBloom 模块)
@Bean
public RBloomFilter<Long> userBloomFilter(RedissonClient redisson) {
    RBloomFilter<Long> filter = redisson.getBloomFilter("user:bloom");
    // 预期插入量 100 万,误判率 0.1%
    filter.tryInit(1_000_000L, 0.001);
    return filter;
}

public User getUserSafely(Long id) {
    // 布隆过滤器说不存在,就是真不存在,直接短路
    if (!userBloomFilter.contains(id)) {
        return null;
    }
    return getUser(id);   // 再走缓存 + DB 流程
}

// 新增数据时同步写入过滤器,否则新用户会被误杀
public void createUser(User user) {
    userMapper.insert(user);
    userBloomFilter.add(user.getId());
    redis.delete("user:" + user.getId());
}

⚠️ 布隆过滤器最大的坑是不支持删除。用户被物理删除后,过滤器仍认为它”可能存在”,请求会继续落到 DB——好在此时空值缓存会兜住。生产上常见做法是逻辑删除 + 定期全量重建过滤器(凌晨低峰跑一次),或改用 Cuckoo Filter(CF.DEL 支持删除)。

三、缓存击穿:热点 key 过期瞬间的并发重建

数据是存在的,问题出在时间点上。假设一个秒杀商品详情页 QPS 5000,缓存 TTL 到期的那一毫秒,5000 个线程同时发现未命中,同时去查数据库、同时写缓存。数据库瞬间承受 5000 次相同查询,慢查询堆积、连接耗尽。这种”缓存重建风暴”是压测能过、线上却挂的典型原因。

方案一:分布式锁串行重建

只让一个线程去重建缓存,其余线程短暂等待后重试读缓存。强一致性好,代价是等待线程会阻塞,极端情况下可能拖长响应时间。

public Product getProduct(Long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }

    String lockKey = "lock:product:" + id;
    // SET key val NX EX 10:只有第一个线程能拿到锁
    Boolean locked = redis.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查:可能在等锁期间别人已经重建好了
            cached = redis.opsForValue().get(key);
            if (cached != null) {
                return JSON.parseObject(cached, Product.class);
            }
            Product p = productMapper.selectById(id);
            if (p != null) {
                redis.opsForValue().set(key, JSON.toJSONString(p),
                        randomTtl(30), TimeUnit.MINUTES);
            }
            return p;
        } finally {
            redis.delete(lockKey);   // 务必在 finally 释放
        }
    }

    // 没抢到锁:短暂退避后重读缓存,避免直接压 DB
    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    return getProduct(id);
}

注意锁的 TTL 必须大于重建耗时,否则锁提前释放会失去互斥意义;生产环境更推荐用 Redisson 的 RLock,它自带看门狗自动续期,能规避”业务没跑完锁先过期”的问题。

方案二:逻辑过期 + 异步重建(高可用优先)

核心思路是缓存永不物理过期,而在 value 里存一个逻辑过期时间。读到过期数据时,先返回旧值保证响应,同时开一个异步线程去重建。用户永远拿得到数据(可能短暂是旧的),彻底消除阻塞——牺牲一点一致性换可用性,适合商品详情、榜单这类容忍秒级延迟的场景。

// value 结构:{"data": {...}, "expireAt": 1735660800000}
public Product getProductLogical(Long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached == null) {
        return rebuildSync(id, key);        // 冷启动,同步建一次
    }

    CacheWrapper wrapper = JSON.parseObject(cached, CacheWrapper.class);
    Product data = wrapper.getData();

    // 未逻辑过期,直接返回
    if (wrapper.getExpireAt() > System.currentTimeMillis()) {
        return data;
    }

    // 已逻辑过期:抢锁的线程异步重建,所有线程立刻返回旧值
    String lockKey = "lock:rebuild:" + id;
    if (Boolean.TRUE.equals(redis.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS))) {
        REBUILD_POOL.submit(() -> {
            try {
                rebuildSync(id, key);
            } finally {
                redis.delete(lockKey);
            }
        });
    }
    return data;   // 返回旧数据,不阻塞
}

private Product rebuildSync(Long id, String key) {
    Product p = productMapper.selectById(id);
    if (p == null) return null;
    CacheWrapper w = new CacheWrapper(p,
            System.currentTimeMillis() + Duration.ofMinutes(30).toMillis());
    // 物理 TTL 远大于逻辑 TTL(或不设),避免真过期
    redis.opsForValue().set(key, JSON.toJSONString(w), 24, TimeUnit.HOURS);
    return p;
}

四、缓存雪崩:整层失效的连锁反应

雪崩有两种成因,必须分别处理:一是大批 key 同一时刻集中过期(比如凌晨定时任务一次性预热了 10 万条数据,TTL 都写死 1 小时);二是 Redis 实例整体宕机,所有流量瞬间转向数据库。

TTL 随机化:一行代码消除集中过期

/** 基础 TTL 上叠加 0~20% 随机抖动,把过期时间点摊平 */
private long randomTtl(long baseMinutes) {
    long jitter = (long) (baseMinutes * 0.2 * ThreadLocalRandom.current().nextDouble());
    return baseMinutes + jitter;
}

// 批量预热时使用,10 万个 key 的过期时间分散在 30~36 分钟区间
public void warmUp(List<Product> products) {
    products.forEach(p -> redis.opsForValue().set(
            "product:" + p.getId(),
            JSON.toJSONString(p),
            randomTtl(30), TimeUnit.MINUTES));
}

Redis 宕机时的三道防线

第一道是高可用架构:单机换成主从 + Sentinel 或 Cluster,避免单点。第二道是本地缓存兜底:用 Caffeine 在 JVM 内做一层 L1 缓存(容量小、TTL 短),Redis 挂掉时热点数据仍能命中本地。第三道是熔断降级:用 Sentinel/Resilience4j 对数据库访问设并发上限,超出直接返回兜底默认值或友好提示,保住数据库不被打死——宁可部分请求降级,也不能让整个库崩掉。

// L1 本地缓存 + L2 Redis 的两级读取
private final Cache<String, Product> local = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofSeconds(30))   // 短 TTL 控一致性
        .build();

public Product get(Long id) {
    String key = "product:" + id;
    Product p = local.getIfPresent(key);
    if (p != null) return p;

    try {
        p = getProductLogical(id);          // L2:Redis
    } catch (RedisConnectionFailureException e) {
        // Redis 不可用:降级到限流后的 DB 直查
        p = degradeQuery(id);
    }
    if (p != null) local.put(key, p);
    return p;
}

入口层同样值得加一道闸——用 Nginx 做限流能在流量到达应用前削峰,具体配置可参考 Nginx 限流防刷实战:漏桶算法与 WAF 基础配置

五、方案选型对照:按场景挑,不要全都上

方案解决问题实现成本一致性适用场景
空值缓存穿透极低弱(短 TTL 缓解)ID 空间有限、快速止血
布隆过滤器穿透中(需维护同步)有假阳性海量随机 ID 刷量
分布式锁重建击穿金额、库存等强一致数据
逻辑过期击穿较高弱(秒级延迟)详情页、榜单等高 QPS 读
TTL 随机化雪崩极低无影响所有批量预热场景,必做
多级缓存 + 熔断雪崩核心链路、要求不可用时仍能兜底

务实的落地顺序是:TTL 随机化和空值缓存先无条件做(成本几乎为零);有明确热点 key 的接口再上逻辑过期或分布式锁;确认存在恶意刷量后才引入布隆过滤器;多级缓存和熔断只在核心交易链路投入。

六、验证与监控:别等事故发生才知道

防护上线后必须能观测。最关键的三个指标是缓存命中率数据库 QPS 突刺慢查询数量。命中率可以从 Redis 的 INFO stats 里算:

# 查看命中/未命中累计值,命中率 = hits / (hits + misses)
redis-cli INFO stats | grep keyspace

# 实时观察是否有大批 key 同时过期(expired_keys 陡增即雪崩前兆)
redis-cli --stat

# 找出内存占用最大的 key,排查空值缓存是否失控
redis-cli --bigkeys

# 压测验证击穿防护:并发 500、持续 30 秒打同一个热点 key
wrk -t8 -c500 -d30s http://127.0.0.1:8080/api/product/1

压测时同步盯数据库侧:如果热点 key 过期瞬间 DB QPS 出现尖峰,说明击穿防护没生效。指标采集与告警面板的搭建可参考 Prometheus + Grafana 监控面板实战;数据库侧的慢查询定位与执行计划分析,见 MySQL 深度调优:核心参数与执行计划解读MySQL 索引底层原理与优化实践

七、落地检查清单

上线前逐条对一遍,能挡掉绝大多数缓存类事故:

  • 所有 set 缓存的地方,TTL 是否都带了随机抖动?有没有写死的固定值?
  • 查库返回 null 时,是否写入了短 TTL 空标记?新增数据时是否清理了该标记?
  • 热点 key(详情页、首页配置、榜单)是否做了击穿防护?锁的 TTL 是否大于重建耗时?
  • 锁释放是否放在 finally?异常路径会不会导致锁永久占用?
  • Redis 连接异常时代码走什么分支?是抛 500 还是有降级兜底?
  • 缓存命中率、DB QPS、慢查询是否已接入监控并配置告警阈值?

缓存防护的本质不是把所有方案都堆上去,而是想清楚每个 key 的数据特征和一致性要求,再选最小够用的方案。穿透治入口、击穿治并发、雪崩治时间分布——分清病因,对症下药,Redis 才是真正的性能杠杆而不是故障源。

上一篇 MyBatis与JPA性能优化:N+1、批量与缓存实战
下一篇 前端自动化测试:Vitest 与 Playwright 实战