Skip to content

设计 Redis 缓存穿透/击穿/雪崩解决方案

缓存三剑客——穿透、击穿、雪崩。

这是 Redis 使用中最常见的问题,也是面试中的高频考点。

今天,我们用一篇文章,把这三个问题彻底讲清楚。

一、三个问题的本质

┌─────────────────────────────────────────────────────────┐
│              缓存三剑客的本质区别                          │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  穿透:数据本身不存在                                    │
│  ┌─────────┐     ┌─────────┐     ┌─────────┐         │
│  │  请求   │────▶│  缓存   │────▶│  数据库  │         │
│  │         │     │ (miss)  │     │ (no data)│         │
│  └─────────┘     └─────────┘     └─────────┘         │
│     恶意攻击                                              │
│     业务漏洞                                              │
│                                                         │
│  击穿:热点 key 过期                                     │
│  ┌─────────┐     ┌─────────┐     ┌─────────┐         │
│  │  请求   │────▶│  缓存   │────▶│  数据库  │         │
│  │         │     │ (过期!) │     │ (大量)  │         │
│  └─────────┘     └─────────┘     └─────────┘         │
│     热点数据                                              │
│     过期瞬间                                              │
│                                                         │
│  雪崩:大量 key 同时过期                                  │
│  ┌─────────┐     ┌─────────┐     ┌─────────┐         │
│  │  请求   │────▶│  缓存   │────▶│  数据库  │         │
│  │         │     │(批量过期)│     │ (爆炸)  │         │
│  └─────────┘     └─────────┘     └─────────┘         │
│     集中过期                                              │
│     Redis 宕机                                           │
│                                                         │
└─────────────────────────────────────────────────────────┘

二、缓存穿透

2.1 什么是缓存穿透?

大量请求查询一个不存在的数据——数据既不在缓存,也不在数据库。

常见场景:

  • 恶意攻击:黑客用不存在的 ID 疯狂请求
  • 业务漏洞:查询已被删除的商品或用户

危害:

  • 每个请求都打到数据库
  • 数据库被打垮

2.2 解决方案

方案一:缓存空值

java
/**
 * 方案一:缓存空值
 * 
 * 思路:将「数据不存在」也缓存起来
 */
public class CacheNullValueSolution {
    
    private RedisTemplate<String, Object> redis;
    
    /**
     * 查询用户(带空值缓存)
     */
    public User getUser(String userId) {
        String cacheKey = "user:" + userId;
        
        // 1. 查缓存
        Object cached = redis.opsForValue().get(cacheKey);
        if (cached != null) {
            // 注意:空值也是有效缓存
            if (cached instanceof String && "NULL".equals(cached)) {
                return null;
            }
            return (User) cached;
        }
        
        // 2. 查数据库
        User user = database.findUserById(userId);
        
        // 3. 缓存结果(注意空值也要缓存)
        if (user != null) {
            redis.opsForValue().set(cacheKey, user, Duration.ofHours(1));
        } else {
            // 空值缓存时间短一些(5 分钟)
            // 避免数据真的存在时,长时间不一致
            redis.opsForValue().set(cacheKey, "NULL", Duration.ofMinutes(5));
        }
        
        return user;
    }
}

方案二:布隆过滤器

java
/**
 * 方案二:布隆过滤器
 * 
 * 思路:用布隆过滤器判断数据是否存在
 * - 不存在的数据:100% 返回不存在
 * - 存在的数据:可能误判(但可以接受)
 */
public class BloomFilterSolution {
    
    private RedisTemplate<String, String> redis;
    private BloomFilter<String> bloomFilter;
    
    /**
     * 初始化布隆过滤器
     * 
     * 从数据库加载所有存在的 key
     */
    public void initBloomFilter() {
        // 创建布隆过滤器
        // expectedInsertions: 预期插入数量
        // fpp: 可接受的误判率
        bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(StandardCharsets.UTF_8),
            100_000_000,  // 1 亿
            0.01          // 1% 误判率
        );
        
        // 从数据库加载所有用户 ID
        List<String> allUserIds = database.getAllUserIds();
        for (String userId : allUserIds) {
            bloomFilter.put(userId);
        }
    }
    
    /**
     * 查询用户(带布隆过滤器)
     */
    public User getUser(String userId) {
        // 1. 先判断布隆过滤器
        if (!bloomFilter.mightContain(userId)) {
            // 布隆过滤器说一定不存在,直接返回
            return null;
        }
        
        // 2. 过滤器说可能存在,查缓存
        String cacheKey = "user:" + userId;
        Object cached = redis.opsForValue().get(cacheKey);
        if (cached != null) {
            return (User) cached;
        }
        
        // 3. 查数据库
        User user = database.findUserById(userId);
        
        // 4. 缓存结果
        if (user != null) {
            redis.opsForValue().set(cacheKey, user, Duration.ofHours(1));
        }
        
        return user;
    }
}

方案对比

方案适用场景优点缺点
缓存空值数据量较小简单浪费内存存空值
布隆过滤器数据量巨大内存效率高有误判率

三、缓存击穿

3.1 什么是缓存击穿?

热点 key 过期瞬间,大量请求同时回源数据库

常见场景:

  • 热点商品详情页(618 大促期间缓存过期)
  • 明星粉丝系统(明星发微博时粉丝暴涨)

危害:

  • 大量请求同时打到数据库
  • 数据库被打垮

3.2 解决方案

方案一:互斥锁

java
/**
 * 方案一:互斥锁(分布式锁)
 * 
 * 思路:只有一个线程去查数据库,其他线程等待
 */
public class MutexLockSolution {
    
    private RedisTemplate<String, Object> redis;
    private RedissonClient redisson;
    
    /**
     * 获取用户(带互斥锁)
     */
    public User getUserWithLock(String userId) {
        String cacheKey = "user:" + userId;
        String lockKey = "lock:user:" + userId;
        
        // 1. 先查缓存
        User cached = (User) redis.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }
        
        // 2. 获取分布式锁
        RLock lock = redisson.getLock(lockKey);
        
        try {
            // 尝试获取锁,最多等待 3 秒,锁自动过期 10 秒
            boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
            
            if (acquired) {
                try {
                    // 双重检查缓存
                    cached = (User) redis.opsForValue().get(cacheKey);
                    if (cached != null) {
                        return cached;
                    }
                    
                    // 查数据库
                    User user = database.findUserById(userId);
                    
                    // 回填缓存
                    if (user != null) {
                        redis.opsForValue().set(cacheKey, user, Duration.ofHours(1));
                    }
                    
                    return user;
                    
                } finally {
                    // 释放锁
                    lock.unlock();
                }
            } else {
                // 获取锁失败,短暂等待后重试
                Thread.sleep(50);
                return getUserWithLock(userId); // 递归重试
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
    }
}

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

java
/**
 * 方案二:热点数据永不过期
 * 
 * 思路:不设置 TTL,用逻辑过期时间
 */
public class LogicalExpireSolution {
    
    private RedisTemplate<String, String> redis;
    
    /**
     * 缓存数据结构(包含逻辑过期时间)
     */
    public static class CacheData<T> {
        T data;
        long logicalExpireTime;  // 逻辑过期时间戳
        
        public boolean isExpired() {
            return System.currentTimeMillis() > logicalExpireTime;
        }
    }
    
    /**
     * 获取用户(带逻辑过期)
     */
    public User getUserWithLogicalExpire(String userId) {
        String cacheKey = "user:" + userId;
        
        // 1. 查缓存
        String cachedJson = redis.opsForValue().get(cacheKey);
        if (cachedJson == null) {
            return null;
        }
        
        // 2. 反序列化
        CacheData<User> cacheData = JSON.parseObject(cachedJson, 
            new TypeReference<CacheData<User>>() {});
        
        // 3. 检查是否逻辑过期
        if (cacheData.isExpired()) {
            // 异步更新缓存(不阻塞读取)
            refreshCacheAsync(userId, cacheKey);
            
            // 返回旧数据(虽然过期,但勉强能用)
            return cacheData.data;
        }
        
        // 4. 数据有效,直接返回
        return cacheData.data;
    }
    
    /**
     * 异步刷新缓存
     */
    @Async
    public void refreshCacheAsync(String userId, String cacheKey) {
        // 1. 获取分布式锁
        RLock lock = redisson.getLock("lock:" + cacheKey);
        
        try {
            lock.lock();
            
            // 2. 重新查询数据库
            User user = database.findUserById(userId);
            
            // 3. 更新缓存
            CacheData<User> newCacheData = new CacheData<>();
            newCacheData.data = user;
            newCacheData.logicalExpireTime = System.currentTimeMillis() + 30 * 60 * 1000; // 30 分钟后过期
            
            redis.opsForValue().set(cacheKey, 
                JSON.toJSONString(newCacheData),
                Duration.ofHours(2)); // 物理过期时间 2 小时
            
        } finally {
            lock.unlock();
        }
    }
}

方案三:提前重建

java
/**
 * 方案三:提前重建缓存
 * 
 * 思路:在 key 过期前,主动重建
 */
public class AdvanceRebuildSolution {
    
    /**
     * 获取用户(提前重建)
     */
    public User getUserWithAdvanceRebuild(String userId) {
        String cacheKey = "user:" + userId;
        
        // 1. 查缓存
        User cached = (User) redis.opsForValue().get(cacheKey);
        if (cached != null) {
            // 2. 检查是否快过期
            Long ttl = redis.getExpire(cacheKey);
            if (ttl != null && ttl < 60) { // TTL 小于 60 秒
                // 异步重建
                rebuildCacheAsync(userId);
            }
            
            return cached;
        }
        
        // 3. 缓存不存在,查数据库
        User user = database.findUserById(userId);
        
        if (user != null) {
            redis.opsForValue().set(cacheKey, user, Duration.ofHours(1));
        }
        
        return user;
    }
    
    /**
     * 异步重建缓存
     */
    @Async
    public void rebuildCacheAsync(String userId) {
        // 获取锁后重建...
    }
}

四、缓存雪崩

4.1 什么是缓存雪崩?

大量 key 同时过期,或者 Redis 宕机,导致大量请求直接打到数据库

常见场景:

  • 大量 key 使用相同的过期时间
  • 凌晨 12 点大量 key 同时过期
  • Redis 宕机

危害:

  • 数据库瞬间承受巨大压力
  • 系统崩溃

4.2 解决方案

方案一:过期时间随机化

java
/**
 * 方案一:过期时间随机化
 * 
 * 思路:给过期时间加随机偏移量
 */
public class ExpireTimeJitterSolution {
    
    /**
     * 设置缓存(带随机过期时间)
     */
    public void setWithJitter(String key, Object value, long baseExpireSeconds) {
        // 基础过期时间 + 随机偏移量(0 ~ 30 分钟)
        long jitter = (long) (Math.random() * 1800);
        long actualExpire = baseExpireSeconds + jitter;
        
        redis.opsForValue().set(key, value, Duration.ofSeconds(actualExpire));
    }
    
    /**
     * 批量设置缓存(带随机过期时间)
     */
    public void batchSetWithJitter(Map<String, Object> data, long baseExpireSeconds) {
        Random random = new Random();
        
        for (Map.Entry<String, Object> entry : data.entrySet()) {
            long jitter = (long) (random.nextDouble() * 1800);
            long actualExpire = baseExpireSeconds + jitter;
            
            redis.opsForValue().set(entry.getKey(), entry.getValue(), 
                Duration.ofSeconds(actualExpire));
        }
    }
}

方案二:多级缓存

java
/**
 * 方案二:多级缓存(L1 + L2)
 * 
 * 思路:本地缓存 + Redis + 数据库
 * 本地缓存作为最后的防线
 */
public class MultiLevelCacheSolution {
    
    private Cache<String, Object> localCache;  // Caffeine / Guava Cache
    private RedisTemplate<String, Object> redis;
    
    /**
     * L1 本地缓存配置
     * 
     * 使用 Caffeine:
     * - 最大容量:10000
     * - 过期:访问后 1 分钟过期
     * - 线程安全
     */
    @Bean
    public Cache<String, Object> localCache() {
        return Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterAccess(1, TimeUnit.MINUTES)
            .build();
    }
    
    /**
     * 三级查询
     */
    public User getUser(String userId) {
        String cacheKey = "user:" + userId;
        
        // 1. L1 本地缓存(无网络开销,μs 级)
        User cached = localCache.getIfPresent(cacheKey);
        if (cached != null) {
            return cached;
        }
        
        // 2. L2 Redis 缓存
        cached = (User) redis.opsForValue().get(cacheKey);
        if (cached != null) {
            // 回填 L1 缓存
            localCache.put(cacheKey, cached);
            return cached;
        }
        
        // 3. 数据库
        User user = database.findUserById(userId);
        
        if (user != null) {
            // 回填 L2 缓存
            redis.opsForValue().set(cacheKey, user, Duration.ofHours(1));
            // 回填 L1 缓存
            localCache.put(cacheKey, user);
        }
        
        return user;
    }
    
    /**
     * 更新时清理所有缓存
     */
    public void updateUser(User user) {
        String cacheKey = "user:" + user.getId();
        
        // 1. 更新数据库
        database.updateUser(user);
        
        // 2. 删除 L2 缓存
        redis.delete(cacheKey);
        
        // 3. 删除 L1 缓存
        localCache.invalidate(cacheKey);
    }
}

方案三:Redis 高可用 + 熔断降级

java
/**
 * 方案三:Redis 高可用 + 应用层熔断
 */
public class RedisHighAvailabilitySolution {
    
    private RedisTemplate<String, Object> redis;
    private CircuitBreaker circuitBreaker;
    
    /**
     * 查询(带熔断保护)
     */
    public User getUserWithCircuitBreaker(String userId) {
        String cacheKey = "user:" + userId;
        
        try {
            // 尝试从 Redis 获取
            User cached = (User) redis.opsForValue().get(cacheKey);
            if (cached != null) {
                return cached;
            }
            
            // 查数据库
            User user = database.findUserById(userId);
            
            // 回填缓存
            if (user != null) {
                redis.opsForValue().set(cacheKey, user, Duration.ofHours(1));
            }
            
            // 成功后重置熔断器
            circuitBreaker.recordSuccess();
            
            return user;
            
        } catch (Exception e) {
            // Redis 出错,降级到数据库
            circuitBreaker.recordFailure();
            
            // 熔断器打开时,直接查数据库
            if (circuitBreaker.isOpen()) {
                return database.findUserById(userId);
            }
            
            throw e;
        }
    }
    
    /**
     * 熔断器实现
     */
    public static class CircuitBreaker {
        
        private AtomicInteger failureCount = new AtomicInteger(0);
        private volatile long lastFailureTime = 0;
        private static final int THRESHOLD = 5;
        private static final long RECOVERY_TIMEOUT = 30_000; // 30 秒
        
        public void recordSuccess() {
            failureCount.set(0);
        }
        
        public void recordFailure() {
            failureCount.incrementAndGet();
            lastFailureTime = System.currentTimeMillis();
        }
        
        public boolean isOpen() {
            if (failureCount.get() >= THRESHOLD) {
                // 检查是否超过恢复时间
                if (System.currentTimeMillis() - lastFailureTime > RECOVERY_TIMEOUT) {
                    // 进入半开状态,允许一个请求试试
                    return false;
                }
                return true;
            }
            return false;
        }
    }
}

方案四:接口限流

java
/**
 * 方案四:接口限流
 */
public class RateLimitSolution {
    
    private RedisTemplate<String, String> redis;
    
    /**
     * 查询(带限流保护)
     */
    public User getUserWithRateLimit(String userId) {
        String cacheKey = "user:" + userId;
        String rateLimitKey = "ratelimit:user:" + userId;
        
        // 1. 限流检查
        if (!tryAcquire(rateLimitKey)) {
            // 限流触发,返回默认值或友好提示
            return getDefaultOrCached(userId);
        }
        
        // 2. 正常查询
        User cached = (User) redis.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }
        
        User user = database.findUserById(userId);
        
        if (user != null) {
            redis.opsForValue().set(cacheKey, user, Duration.ofHours(1));
        }
        
        return user;
    }
    
    /**
     * 滑动窗口限流
     */
    private boolean tryAcquire(String key) {
        long now = System.currentTimeMillis();
        long windowStart = now - 60_000; // 1 分钟窗口
        
        // 使用 Lua 脚本保证原子性
        String luaScript = """
            redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
            redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
            redis.call('EXPIRE', KEYS[1], 120)
            return redis.call('ZCARD', KEYS[1])
            """;
        
        Long count = redis.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(key),
            String.valueOf(windowStart),
            String.valueOf(now),
            UUID.randomUUID().toString()
        );
        
        return count != null && count <= 100; // 每分钟最多 100 次
    }
}

五、综合解决方案

5.1 完整的缓存防护体系

java
/**
 * 完整的缓存防护方案
 */
public class CacheProtectionSystem {
    
    private RedisTemplate<String, Object> redis;
    private BloomFilter<String> bloomFilter;
    private Cache<String, Object> localCache;
    
    /**
     * 获取数据(综合防护)
     */
    public <T> T get(String key, Class<T> clazz, DataLoader<T> loader) {
        // 1. L1 本地缓存(防雪崩第一道防线)
        T localCached = localCache.getIfPresent(key);
        if (localCached != null) {
            return localCached;
        }
        
        // 2. L2 Redis 缓存
        try {
            T cached = (T) redis.opsForValue().get(key);
            if (cached != null) {
                // 回填 L1
                localCache.put(key, cached);
                return cached;
            }
        } catch (Exception e) {
            // Redis 异常,降级
            return loadAndCache(key, loader);
        }
        
        // 3. 布隆过滤器检查(防穿透)
        if (bloomFilter != null && !bloomFilter.mightContain(key)) {
            // 一定不存在,直接返回空
            return null;
        }
        
        // 4. 数据库加载(带互斥锁,防击穿)
        return loadWithLock(key, loader);
    }
    
    /**
     * 带锁的数据加载
     */
    private <T> T loadWithLock(String key, DataLoader<T> loader) {
        String lockKey = "lock:" + key;
        RLock lock = redisson.getLock(lockKey);
        
        try {
            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
                try {
                    // 双重检查
                    T cached = (T) redis.opsForValue().get(key);
                    if (cached != null) {
                        return cached;
                    }
                    
                    // 加载数据
                    T data = loader.load();
                    
                    // 回填缓存
                    if (data != null) {
                        redis.opsForValue().set(key, data, 
                            Duration.ofSeconds(randomExpire(3600)));
                        localCache.put(key, data);
                    }
                    
                    return data;
                } finally {
                    lock.unlock();
                }
            } else {
                // 等待后重试
                Thread.sleep(50);
                return get(key, null, loader);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return loader.load();
        }
    }
    
    /**
     * 生成随机过期时间
     */
    private long randomExpire(long baseSeconds) {
        return baseSeconds + (long) (Math.random() * 1800);
    }
    
    /**
     * 数据加载器接口
     */
    @FunctionalInterface
    public interface DataLoader<T> {
        T load();
    }
}

六、面试总结

问题原因解决方案
穿透数据不存在缓存空值 / 布隆过滤器
击穿热点 key 过期互斥锁 / 逻辑过期 / 提前重建
雪崩大量 key 同时过期过期随机化 / 多级缓存 / 高可用 / 限流
┌─────────────────────────────────────────────────────────┐
│                  缓存防护最佳实践                            │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  预防措施                                              │
│  ├── 过期时间随机化                                    │
│  ├── 热点数据永不过期                                  │
│  └── Redis 高可用部署                                  │
│                                                         │
│  兜底措施                                              │
│  ├── 多级缓存(L1 本地 + L2 Redis)                   │
│  ├── 熔断降级                                         │
│  └── 接口限流                                         │
│                                                         │
│  根治措施                                              │
│  ├── 布隆过滤器防穿透                                 │
│  ├── 互斥锁防击穿                                     │
│  └── 监控告警                                         │
│                                                         │
└─────────────────────────────────────────────────────────┘

"缓存三剑客不是孤立的三个问题,而是一个系统的防护体系。最好的方案是预防 + 兜底的组合拳。"

基于 VitePress 构建