Redis 缓存策略实战:彻底解决穿透、击穿与雪崩问题

By | 2026年10月20日

引言

在高并发系统中,Redis 作为缓存层已经不可或缺。但你是否遇到过:某个不存在的 ID 疯狂请求导致数据库压力飙升?热点 Key 过期瞬间大量请求直接打到数据库?大量 Key 同时失效引发连锁反应?这些正是缓存穿透、击穿和雪崩的典型症状。

本文将从实际场景出发,先讲清三者的本质区别,再给出可落地的代码方案,最后分享一些容易忽略的细节与最佳实践。

1. 概念辨析:穿透 vs 击穿 vs 雪崩

| 问题 | 触发条件 | 现象 | 核心解决思路 | |——|———-|——|————–| | 缓存穿透 | 查询不存在的数据 | 缓存和数据库都无数据,每次请求都穿透到 DB | 缓存空值、布隆过滤器 | | 缓存击穿 | 热点 Key 过期 | 大量请求同时访问已过期的热点 Key | 互斥锁、逻辑过期 | | 缓存雪崩 | 大量 Key 同时过期或 Redis 宕机 | 数据库瞬间承受全量请求 | 随机过期时间、高可用集群、熔断降级 |

下面我们逐一拆解,并给出 Spring Boot + Redis 的实战代码。

2. 缓存穿透:布隆过滤器 + 空值缓存

场景复现

假设有一个用户查询接口 GET /user/{id},攻击者用不存在的 ID(如负数和超大数)频繁请求。由于缓存不命中,每次都会查数据库,导致 DB 压力过大。

解决方案一:缓存空值

对于查询结果为空的数据,仍然写入缓存,但设置较短的过期时间(如 60 秒)。


public User getUserById(Long id) {
    String cacheKey = "user:" + id;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        // 如果缓存的是空值标记,直接返回 null
        if ("NULL".equals(cached)) {
            return null;
        }
        return JSON.parseObject(cached, User.class);
    }
    User user = userMapper.selectById(id);
    if (user == null) {
        // 缓存空值,过期时间短一些
        redisTemplate.opsForValue().set(cacheKey, "NULL", 60, TimeUnit.SECONDS);
        return null;
    }
    redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
    return user;
}

> 注意:空值缓存只能应对随机的不存在 ID,如果攻击者每次使用不同的随机 ID,依然会产生大量空值缓存,占用内存。此时需要布隆过滤器。

解决方案二:布隆过滤器

布隆过滤器可以快速判断一个元素是否可能存在。我们将所有合法 ID 预先加载到布隆过滤器中,查询前先过滤。

使用 Redisson 的布隆过滤器实现:


@Configuration
public class BloomFilterConfig {
    @Bean
    public RBloomFilter<Long> userBloomFilter(RedissonClient redissonClient) {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("user:bloom");
        // 初始化:预计元素数量 100 万,误判率 1%
        bloomFilter.tryInit(1_000_000L, 0.01);
        return bloomFilter;
    }
}

@Service
public class UserService {
    @Autowired
    private RBloomFilter<Long> userBloomFilter;

    @PostConstruct
    public void initBloomFilter() {
        // 启动时加载所有用户 ID(实际项目可分批加载)
        List<Long> allUserIds = userMapper.selectAllIds();
        allUserIds.forEach(userBloomFilter::add);
    }

    public User getUserById(Long id) {
        // 1. 布隆过滤器拦截
        if (!userBloomFilter.contains(id)) {
            return null;
        }
        // 2. 查询缓存(同前)
        // ...
    }
}

> 💡 最佳实践:布隆过滤器存在误判率(可能把不存在的误判为存在),但不会漏判。对于新增数据,需要同步更新布隆过滤器(如用户注册后立即 add)。

3. 缓存击穿:互斥锁与逻辑过期

场景复现

某个秒杀商品详情页,缓存 30 分钟过期。当缓存过期瞬间,大量并发请求同时到达,全部去查数据库,导致 DB 压力骤增。

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

只允许一个线程去重建缓存,其他线程等待并重试。


public Product getProductById(Long id) {
    String cacheKey = "product:" + id;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }
    // 获取分布式锁
    String lockKey = "lock:product:" + id;
    String lockValue = UUID.randomUUID().toString();
    try {
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(locked)) {
            // 双重检查:可能其他线程已经重建了缓存
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return JSON.parseObject(cached, Product.class);
            }
            Product product = productMapper.selectById(id);
            if (product != null) {
                redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
            }
            return product;
        } else {
            // 未获取到锁,短暂等待后重试
            Thread.sleep(50);
            return getProductById(id); // 递归重试,注意控制重试次数
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("获取商品信息失败", e);
    } finally {
        // 释放锁(使用 Lua 脚本保证原子性)
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), lockValue);
    }
}

> 注意:递归重试可能导致栈溢出,建议使用循环 + 最大重试次数。另外锁的超时时间要合理,避免业务未完成锁就释放。

解决方案二:逻辑过期(永不过期)

缓存不设置物理过期时间,而是将过期时间存储在 value 中。获取时判断是否逻辑过期,若过期则异步重建,同时返回旧数据。


@Data
public class RedisData {
    private Object data;
    private LocalDateTime expireTime;
}

public Product getProductWithLogicalExpire(Long id) {
    String cacheKey = "product:" + id;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached == null) {
        // 缓存不存在,直接查库并写入(可加锁)
        return loadFromDbAndCache(id);
    }
    RedisData redisData = JSON.parseObject(cached, RedisData.class);
    Product product = JSON.parseObject(JSON.toJSONString(redisData.getData()), Product.class);
    if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
        return product; // 未过期,直接返回
    }
    // 已过期,尝试获取锁异步重建
    String lockKey = "lock:product:" + id;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (Boolean.TRUE.equals(locked)) {
        // 开启新线程重建缓存
        executorService.submit(() -> {
            try {
                Product fresh = productMapper.selectById(id);
                RedisData newData = new RedisData();
                newData.setData(fresh);
                newData.setExpireTime(LocalDateTime.now().plusMinutes(30));
                redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(newData));
            } finally {
                redisTemplate.delete(lockKey);
            }
        });
    }
    // 无论是否获取锁,都返回旧数据
    return product;
}

> 💡 适用场景:逻辑过期适合对数据一致性要求不高的热点数据(如商品详情),能保证高可用,但可能返回旧数据。

4. 缓存雪崩:随机过期 + 高可用 + 熔断

场景复现

凌晨批量导入数据时,给大量 Key 设置了相同的过期时间(如 1 小时),导致一小时后所有 Key 同时失效,请求全部涌向数据库。或者 Redis 集群宕机,缓存层完全失效。

解决方案一:随机过期时间

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


public void setWithRandomExpire(String key, String value, long baseSeconds) {
    // 随机增加 0~300 秒
    long expire = baseSeconds + ThreadLocalRandom.current().nextLong(300);
    redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
}

解决方案二:Redis 高可用

使用哨兵模式或集群模式,避免单点故障。同时可配置本地缓存(如 Caffeine)作为二级缓存,在 Redis 不可用时降级。


@Configuration
public class CacheConfig {
    @Bean
    public Cache<String, Object> localCache() {
        return Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .maximumSize(10_000)
            .build();
    }
}

public User getUserWithLocalCache(Long id) {
    String cacheKey = "user:" + id;
    // 1. 查本地缓存
    Object local = localCache.getIfPresent(cacheKey);
    if (local != null) {
        return (User) local;
    }
    // 2. 查 Redis
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        User user = JSON.parseObject(cached, User.class);
        localCache.put(cacheKey, user);
        return user;
    }
    // 3. 查 DB
    User user = userMapper.selectById(id);
    if (user != null) {
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
        localCache.put(cacheKey, user);
    }
    return user;
}

解决方案三:熔断降级

当数据库压力过大时,通过熔断器(如 Sentinel)快速失败,返回兜底数据。


@SentinelResource(value = "getUser", blockHandler = "handleBlock", fallback = "fallback")
public User getUser(Long id) {
    // 正常业务逻辑
}

public User handleBlock(Long id, BlockException ex) {
    // 限流降级处理
    return new User(id, "默认用户", "降级返回");
}

public User fallback(Long id, Throwable t) {
    // 异常兜底
    return new User(id, "默认用户", "异常兜底");
}

5. 综合实战:缓存工具类封装

将上述策略整合成一个通用的缓存工具类,支持穿透、击穿防护。


@Component
public class CacheHelper {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private RedissonClient redissonClient;

    private static final String NULL_VALUE = "NULL";

    public <T> T getWithCache(String key, Class<T> clazz, Supplier<T> dbLoader, long expireSeconds) {
        // 1. 查缓存
        String cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            if (NULL_VALUE.equals(cached)) {
                return null;
            }
            return JSON.parseObject(cached, clazz);
        }
        // 2. 分布式锁防击穿
        RLock lock = redissonClient.getLock("lock:" + key);
        try {
            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
                // 双重检查
                cached = redisTemplate.opsForValue().get(key);
                if (cached != null) {
                    return NULL_VALUE.equals(cached) ? null : JSON.parseObject(cached, clazz);
                }
                // 3. 查数据库
                T data = dbLoader.get();
                if (data == null) {
                    // 缓存空值防穿透
                    redisTemplate.opsForValue().set(key, NULL_VALUE, 60, TimeUnit.SECONDS);
                    return null;
                }
                // 4. 随机过期时间防雪崩
                long expire = expireSeconds + ThreadLocalRandom.current().nextLong(300);
                redisTemplate.opsForValue().set(key, JSON.toJSONString(data), expire, TimeUnit.SECONDS);
                return data;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
        // 获取锁失败,短暂等待后重试(或直接查库,根据业务容忍度)
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getWithCache(key, clazz, dbLoader, expireSeconds);
    }
}

6. 常见坑与最佳实践

  • 锁的粒度:锁的 key 要精确到具体数据 ID,避免大范围锁影响性能。
  • 锁的超时时间:一定要设置,且大于业务最大执行时间,防止死锁。
  • 空值缓存时间:不宜过长(建议 30~60 秒),避免数据变更后长时间不一致。
  • 布隆过滤器更新:新增数据后要同步添加,删除数据无法从布隆过滤器移除(可定期重建)。
  • 逻辑过期实现:异步重建时注意线程池隔离,避免影响主业务。
  • 监控与告警:对缓存命中率、数据库 QPS 进行监控,及时发现异常。

总结

缓存穿透、击穿、雪崩是 Redis 使用中的高频问题,理解它们的本质并掌握对应的解决方案,是构建高可用系统的必备技能。本文从原理到代码,给出了完整的实战指南:

  • 穿透:布隆过滤器 + 空值缓存
  • 击穿:互斥锁 + 逻辑过期
  • 雪崩:随机过期 + 高可用 + 熔断降级

建议根据业务场景组合使用这些策略,并在实际项目中通过压测验证效果。下一步可以研究多级缓存架构(本地 + Redis + DB)以及缓存一致性方案(如 Canal 监听 binlog)。

延伸阅读:

  • Redisson 官方文档(分布式锁与布隆过滤器)
  • Spring Cache 抽象与 Redis 集成
  • 阿里巴巴 Sentinel 熔断降级指南