跳转到内容
Go back

Redis缓存三大经典问题:穿透、击穿、雪崩的深度解析与解决方案

Redis缓存三大经典问题:穿透、击穿、雪崩的深度解析与解决方案

三大缓存问题概述

在使用Redis作为缓存时,会遇到三个经典问题。很多人容易混淆这三个概念,让我们先明确它们的本质区别:

缓存穿透:缓存没有,数据库也没有 缓存击穿:缓存没有,数据库有
缓存雪崩:大量缓存同时失效

缓存穿透(Cache Penetration)

问题本质

缓存穿透是指查询一个根本不存在的数据,缓存层和持久层都没有命中,但出于容错考虑,如果从持久层查不到数据则不写入缓存层。

用户请求 → 缓存查询(miss) → 数据库查询(miss) → 返回空

典型场景

恶意攻击场景

// 攻击者故意查询不存在的数据
for (int i = 0; i < 10000; i++) {
    getUserById(-i); // 负数ID不存在,每次都查数据库
}

业务场景

// 用户输入错误的商品ID
getProductById(999999999L); // 不存在的商品ID

问题危害

  1. 数据库压力:每次查询都会打到数据库
  2. 响应延迟:无效查询增加系统延迟
  3. 资源浪费:CPU、内存、网络资源被无效请求占用
  4. 系统风险:可能被恶意利用进行DDoS攻击

解决方案

方案一:缓存空值

@Service
public class UserService {
    
    private static final String NULL_VALUE = "NULL";
    
    public User getUserById(Long userId) {
        String cacheKey = "user:" + userId;
        String userJson = redisTemplate.opsForValue().get(cacheKey);
        
        // 检查是否是缓存的空值
        if (NULL_VALUE.equals(userJson)) {
            return null;
        }
        
        if (userJson != null) {
            return JSON.parseObject(userJson, User.class);
        }
        
        // 查询数据库
        User user = userRepository.findById(userId);
        if (user != null) {
            // 缓存正常数据,长时间过期
            redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 
                Duration.ofMinutes(30));
        } else {
            // 缓存空值,短时间过期
            redisTemplate.opsForValue().set(cacheKey, NULL_VALUE, 
                Duration.ofMinutes(2));
        }
        
        return user;
    }
}

优点:实现简单,能有效防止穿透 缺点:会占用缓存空间,可能缓存大量无用数据

方案二:布隆过滤器

@Component
public class BloomFilterService {
    
    private final BloomFilter<Long> userBloomFilter;
    
    public BloomFilterService() {
        // 创建布隆过滤器:预期100万元素,1%误判率
        this.userBloomFilter = BloomFilter.create(
            Funnels.longFunnel(), 1000000, 0.01);
        
        // 初始化:将所有存在的用户ID加入过滤器
        initUserBloomFilter();
    }
    
    public User getUserById(Long userId) {
        // 先检查布隆过滤器
        if (!userBloomFilter.mightContain(userId)) {
            // 肯定不存在,直接返回null
            log.info("布隆过滤器拦截不存在的用户ID: {}", userId);
            return null;
        }
        
        // 可能存在,继续正常查询流程
        return queryUserWithCache(userId);
    }
    
    private void initUserBloomFilter() {
        // 从数据库加载所有用户ID
        List<Long> allUserIds = userRepository.findAllUserIds();
        allUserIds.forEach(userBloomFilter::put);
        log.info("布隆过滤器初始化完成,加载用户数量: {}", allUserIds.size());
    }
    
    // 新增用户时,同步更新布隆过滤器
    public void addUser(User user) {
        userRepository.save(user);
        userBloomFilter.put(user.getId());
    }
}

优点:内存占用小,查询效率高 缺点:有一定误判率,无法删除元素

方案三:参数校验

@RestController
public class UserController {
    
    @GetMapping("/user/{userId}")
    public ResponseEntity<User> getUser(@PathVariable Long userId) {
        // 参数校验
        if (userId == null || userId <= 0) {
            return ResponseEntity.badRequest().build();
        }
        
        // 业务范围校验
        if (userId > 1000000000L) {
            return ResponseEntity.badRequest().build();
        }
        
        User user = userService.getUserById(userId);
        return ResponseEntity.ok(user);
    }
}

缓存击穿(Cache Breakdown)

问题本质

缓存击穿是指某个热点数据的缓存过期,在缓存重建期间,大量请求同时访问这个数据,都会打到数据库。

大量并发请求 → 缓存查询(miss) → 同时查询数据库 → 数据库压力激增

典型场景

热点商品场景

// 爆款商品缓存过期
// 瞬间1000个用户同时访问商品详情
// 所有请求都打到数据库查询同一个商品
getProductById(12345L); // 热门商品ID

明星用户场景

// 网红用户资料缓存过期
// 大量粉丝同时查看用户信息
getUserProfile(888888L); // 明星用户ID

问题危害

  1. 数据库瞬时压力:短时间内大量相同查询
  2. 响应时间增加:数据库查询比缓存慢很多
  3. 系统不稳定:可能导致数据库连接池耗尽
  4. 雪花效应:一个热点数据影响整个系统

解决方案

方案一:分布式锁

@Service
public class ProductService {
    
    public Product getProductById(Long productId) {
        String cacheKey = "product:" + productId;
        String lockKey = "lock:product:" + productId;
        
        // 1. 查询缓存
        String productJson = redisTemplate.opsForValue().get(cacheKey);
        if (productJson != null) {
            return JSON.parseObject(productJson, Product.class);
        }
        
        // 2. 缓存未命中,尝试获取分布式锁
        String lockValue = UUID.randomUUID().toString();
        Boolean lockAcquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10));
        
        if (lockAcquired) {
            try {
                // 3. 获得锁,双重检查缓存
                productJson = redisTemplate.opsForValue().get(cacheKey);
                if (productJson != null) {
                    return JSON.parseObject(productJson, Product.class);
                }
                
                // 4. 查询数据库并缓存
                Product product = productRepository.findById(productId);
                if (product != null) {
                    redisTemplate.opsForValue().set(cacheKey, 
                        JSON.toJSONString(product), Duration.ofMinutes(30));
                }
                
                return product;
            } finally {
                // 5. 释放锁
                releaseLock(lockKey, lockValue);
            }
        } else {
            // 6. 未获得锁,等待后重试
            try {
                Thread.sleep(50);
                return getProductById(productId);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return null;
            }
        }
    }
    
    private void releaseLock(String lockKey, String lockValue) {
        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);
    }
}

方案二:永不过期

@Service
public class HotDataService {
    
    // 热点数据设置永不过期,但记录过期时间
    public Product getHotProduct(Long productId) {
        String cacheKey = "hot:product:" + productId;
        
        String productData = redisTemplate.opsForValue().get(cacheKey);
        if (productData != null) {
            ProductCache cache = JSON.parseObject(productData, ProductCache.class);
            
            // 检查逻辑过期时间
            if (cache.getExpireTime() < System.currentTimeMillis()) {
                // 异步更新缓存
                CompletableFuture.runAsync(() -> refreshCache(cacheKey, productId));
            }
            
            return cache.getProduct();
        }
        
        // 缓存不存在,同步加载
        return loadAndCacheProduct(productId);
    }
    
    private void refreshCache(String cacheKey, Long productId) {
        Product product = productRepository.findById(productId);
        if (product != null) {
            ProductCache cache = new ProductCache();
            cache.setProduct(product);
            cache.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000); // 30分钟后逻辑过期
            
            redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));
        }
    }
    
    // 缓存包装类
    @Data
    static class ProductCache {
        private Product product;
        private Long expireTime;
    }
}

方案三:预热机制

@Component
public class CacheWarmupService {
    
    // 定时预热热点数据
    @Scheduled(fixedRate = 600000) // 每10分钟执行一次
    public void warmupHotData() {
        List<Long> hotProductIds = getHotProductIds();
        
        hotProductIds.parallelStream().forEach(productId -> {
            try {
                String cacheKey = "product:" + productId;
                Long ttl = redisTemplate.getExpire(cacheKey);
                
                // 如果缓存即将过期(剩余时间小于5分钟),提前刷新
                if (ttl != null && ttl < 300) {
                    Product product = productRepository.findById(productId);
                    if (product != null) {
                        redisTemplate.opsForValue().set(cacheKey, 
                            JSON.toJSONString(product), Duration.ofMinutes(30));
                        log.debug("预热缓存: {}", cacheKey);
                    }
                }
            } catch (Exception e) {
                log.error("预热缓存失败: {}", productId, e);
            }
        });
    }
    
    private List<Long> getHotProductIds() {
        // 从统计数据获取热点商品ID
        return analyticsService.getTopViewedProducts(100);
    }
}

缓存雪崩(Cache Avalanche)

问题本质

缓存雪崩是指大量缓存同一时间失效,导致大量请求直接打到数据库。

大量缓存同时失效 → 大量请求打到数据库 → 数据库压力激增 → 系统崩溃

典型场景

集中过期场景

// 系统重启后,批量设置缓存,都是30分钟过期
for (Product product : products) {
    redisTemplate.opsForValue().set("product:" + product.getId(), 
        JSON.toJSONString(product), Duration.ofMinutes(30));
}
// 30分钟后,所有缓存同时过期

缓存服务宕机场景

// Redis服务器宕机
// 所有缓存请求都失效
// 全部请求打到数据库

问题危害

  1. 系统崩溃风险:数据库可能被压垮
  2. 服务不可用:整个系统可能瘫痪
  3. 恢复困难:系统压力大,难以快速恢复
  4. 连锁反应:可能引发其他服务的连锁故障

解决方案

方案一:随机过期时间

@Service
public class RandomExpireService {
    
    private final Random random = new Random();
    
    public void cacheProductWithRandomExpire(Product product) {
        String cacheKey = "product:" + product.getId();
        
        // 基础过期时间30分钟,随机增加0-10分钟
        int baseExpireMinutes = 30;
        int randomMinutes = random.nextInt(10);
        int totalExpireMinutes = baseExpireMinutes + randomMinutes;
        
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 
            Duration.ofMinutes(totalExpireMinutes));
        
        log.debug("缓存商品,过期时间: {}分钟", totalExpireMinutes);
    }
    
    // 批量缓存时,每个都有不同的过期时间
    public void batchCacheProducts(List<Product> products) {
        products.forEach(this::cacheProductWithRandomExpire);
    }
}

方案二:多级缓存

@Component
public class MultiLevelCacheService {
    
    // L1缓存:本地缓存(Caffeine)
    private final LoadingCache<String, String> localCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .build(key -> null);
    
    // L2缓存:Redis
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    public Product getProduct(Long productId) {
        String cacheKey = "product:" + productId;
        
        // 1. 查询本地缓存
        String productJson = localCache.getIfPresent(cacheKey);
        if (productJson != null) {
            return JSON.parseObject(productJson, Product.class);
        }
        
        // 2. 查询Redis缓存
        productJson = redisTemplate.opsForValue().get(cacheKey);
        if (productJson != null) {
            // 写入本地缓存
            localCache.put(cacheKey, productJson);
            return JSON.parseObject(productJson, Product.class);
        }
        
        // 3. 查询数据库
        Product product = productRepository.findById(productId);
        if (product != null) {
            productJson = JSON.toJSONString(product);
            
            // 写入两级缓存
            redisTemplate.opsForValue().set(cacheKey, productJson, 
                Duration.ofMinutes(30));
            localCache.put(cacheKey, productJson);
        }
        
        return product;
    }
}

方案三:缓存预热 + 降级策略

@Component
public class CacheAvalancheProtection {
    
    // 系统启动时预热缓存
    @EventListener(ContextRefreshedEvent.class)
    public void warmupCache() {
        log.info("开始缓存预热...");
        
        try {
            // 预热核心数据
            warmupCoreProducts();
            warmupHotUsers();
            warmupSystemConfig();
            
            log.info("缓存预热完成");
        } catch (Exception e) {
            log.error("缓存预热失败", e);
        }
    }
    
    private void warmupCoreProducts() {
        List<Product> coreProducts = productService.getCoreProducts();
        coreProducts.parallelStream().forEach(product -> {
            String cacheKey = "product:" + product.getId();
            redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 
                Duration.ofMinutes(30 + new Random().nextInt(10)));
        });
    }
    
    // 降级策略:当缓存不可用时的处理
    @Service
    public static class FallbackService {
        
        // 使用断路器模式
        @HystrixCommand(fallbackMethod = "getProductFallback")
        public Product getProduct(Long productId) {
            return productCacheService.getProduct(productId);
        }
        
        // 降级方法:返回基础数据或默认数据
        public Product getProductFallback(Long productId) {
            log.warn("缓存服务降级,商品ID: {}", productId);
            
            // 返回基础商品信息(从本地缓存或数据库)
            return productRepository.findBasicInfoById(productId);
        }
    }
}

方案四:集群和哨兵

# Redis集群配置
spring:
  redis:
    cluster:
      nodes:
        - 192.168.1.10:7000
        - 192.168.1.10:7001
        - 192.168.1.11:7000
        - 192.168.1.11:7001
        - 192.168.1.12:7000
        - 192.168.1.12:7001
      max-redirects: 3
    timeout: 3000
    lettuce:
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 8
        min-idle: 0

三大问题对比总结

问题类型缓存状态数据库状态主要原因核心解决方案
缓存穿透无数据无数据查询不存在的数据布隆过滤器、缓存空值
缓存击穿无数据有数据热点数据过期分布式锁、永不过期
缓存雪崩大量无数据有数据大量缓存同时失效随机过期、多级缓存

综合防护策略

在实际项目中,通常需要综合运用多种策略:

@Service
public class ComprehensiveCacheService {
    
    // 综合防护的缓存查询
    public <T> T getCacheData(String key, Class<T> clazz, Supplier<T> dataLoader) {
        // 1. 布隆过滤器检查(防穿透)
        if (!bloomFilterService.mightContain(key)) {
            return null;
        }
        
        // 2. 多级缓存查询(防雪崩)
        T data = multiLevelCache.get(key, clazz);
        if (data != null) {
            return data;
        }
        
        // 3. 分布式锁防击穿
        String lockKey = "lock:" + key;
        return distributedLock.executeWithLock(lockKey, () -> {
            // 双重检查
            T cachedData = multiLevelCache.get(key, clazz);
            if (cachedData != null) {
                return cachedData;
            }
            
            // 加载数据
            T loadedData = dataLoader.get();
            if (loadedData != null) {
                // 随机过期时间(防雪崩)
                int expireMinutes = 30 + new Random().nextInt(10);
                multiLevelCache.put(key, loadedData, Duration.ofMinutes(expireMinutes));
            } else {
                // 缓存空值(防穿透)
                multiLevelCache.put(key, createNullObject(clazz), Duration.ofMinutes(2));
            }
            
            return loadedData;
        });
    }
}

总结

理解和解决Redis缓存的三大经典问题是构建高可用系统的关键:

核心要点

最佳实践

通过深入理解这三个问题的本质和解决方案,可以构建出更加稳定和高效的缓存系统。


Share this post on:

Previous Post
Redis缓存应用全面指南:从基础应用到问题解决的完整方案
Next Post
Redis持久化深度解析:RDB与AOF的原理、对比和最佳实践