面试高频详解:Redis 缓存击穿、雪崩、穿透

Redis 缓存击穿、雪崩、穿透:面试高频详解

一、核心概念对比

现象 描述 原因 影响 解决方案
缓存穿透 查询不存在的数据 恶意攻击或误操作 直接打到数据库 布隆过滤器、空值缓存
缓存击穿 热点key过期瞬间 高并发访问同一个key 数据库瞬时压力 互斥锁、永不过期
缓存雪崩 大量key同时失效 缓存大面积失效 数据库压力过大 过期时间随机、集群高可用

二、缓存穿透(Cache Penetration)

1. ​问题定义

  • 查询一个数据库中根本不存在的数据
  • 缓存无法命中,每次请求都直接访问数据库
  • 恶意攻击者可能利用此漏洞攻击系统

2. ​场景示例

复制代码
// 恶意攻击:查询不存在的ID
public User getUserById(Long id) {
    // 1. 先查缓存
    String key = "user:" + id;
    String value = redis.get(key);
    if (value != null) {
        return JSON.parseObject(value, User.class);
    }
    
    // 2. 缓存没有,查数据库
    User user = userDao.findById(id);
    if (user != null) {
        // 3. 写入缓存
        redis.setex(key, 3600, JSON.toJSONString(user));
    }
    
    return user;  // 如果user为null,每次都会查数据库
}

3. ​解决方案

方案1:布隆过滤器(Bloom Filter)
复制代码
// 初始化布隆过滤器
public class BloomFilterService {
    private BloomFilter<String> bloomFilter;
    
    public BloomFilterService() {
        // 预计元素数量1000万,误判率0.01%
        bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(Charset.defaultCharset()),
            10_000_000,
            0.0001
        );
        
        // 预热:加载所有存在的key
        List<String> allKeys = userDao.findAllIds();
        for (String key : allKeys) {
            bloomFilter.put("user:" + key);
        }
    }
    
    public boolean mightContain(String key) {
        return bloomFilter.mightContain(key);
    }
}

// 使用
public User getUserById(Long id) {
    String key = "user:" + id;
    
    // 1. 布隆过滤器判断
    if (!bloomFilter.mightContain(key)) {
        return null;  // 肯定不存在
    }
    
    // 2. 正常流程...
}
方案2:空值缓存
复制代码
public User getUserById(Long id) {
    String key = "user:" + id;
    
    // 1. 查缓存
    String value = redis.get(key);
    if (value != null) {
        if ("NULL".equals(value)) {
            return null;  // 空值缓存
        }
        return JSON.parseObject(value, User.class);
    }
    
    // 2. 查数据库
    User user = userDao.findById(id);
    
    if (user != null) {
        // 3. 正常缓存
        redis.setex(key, 3600, JSON.toJSONString(user));
    } else {
        // 4. 缓存空值(短时间)
        redis.setex(key, 300, "NULL");  // 5分钟
    }
    
    return user;
}
方案3:接口层校验
复制代码
// 1. 参数校验
public User getUserById(@NotNull @Min(1) Long id) {
    // 2. 正则校验
    if (!Pattern.matches("^\\d+$", id.toString())) {
        return null;
    }
    
    // 3. 范围校验
    if (id > 100000000L) {
        return null;  // ID超出范围
    }
    
    // 正常流程...
}

三、缓存击穿(Cache Breakdown)

1. ​问题定义

  • 某个热点key突然失效
  • 大量并发请求同时访问数据库
  • 数据库瞬时压力剧增

2. ​场景示例

复制代码
// 商品秒杀场景
public Product getProductDetail(Long productId) {
    String key = "hot_product:" + productId;
    
    // 热点商品缓存失效
    String value = redis.get(key);
    if (value == null) {  // 缓存失效
        // 大量请求同时进入这里,查询数据库
        Product product = productDao.findById(productId);
        redis.setex(key, 3600, JSON.toJSONString(product));
        return product;
    }
    
    return JSON.parseObject(value, Product.class);
}

3. ​解决方案

方案1:互斥锁(Mutex Lock)
复制代码
public Product getProductDetailWithLock(Long productId) {
    String cacheKey = "product:" + productId;
    
    // 1. 尝试从缓存获取
    String data = redis.get(cacheKey);
    if (data != null) {
        return JSON.parseObject(data, Product.class);
    }
    
    // 2. 获取分布式锁
    String lockKey = "lock:product:" + productId;
    String requestId = UUID.randomUUID().toString();
    
    try {
        // 尝试获取锁
        boolean locked = redis.setnx(lockKey, requestId, 10);  // 10秒超时
        
        if (locked) {
            try {
                // 3. 再次检查缓存(双重检查)
                data = redis.get(cacheKey);
                if (data != null) {
                    return JSON.parseObject(data, Product.class);
                }
                
                // 4. 查询数据库
                Product product = productDao.findById(productId);
                if (product != null) {
                    // 5. 写入缓存
                    redis.setex(cacheKey, 3600, JSON.toJSONString(product));
                }
                
                return product;
            } finally {
                // 释放锁
                if (requestId.equals(redis.get(lockKey))) {
                    redis.del(lockKey);
                }
            }
        } else {
            // 6. 未获取到锁,等待后重试
            Thread.sleep(50);
            return getProductDetailWithLock(productId);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return productDao.findById(productId);
    }
}
方案2:逻辑过期
复制代码
// 缓存数据结构
class CacheData {
    private Object data;          // 真实数据
    private long expireTime;     // 逻辑过期时间
    
    // getters and setters
}

public Product getProductDetailWithLogicExpire(Long productId) {
    String key = "product:" + productId;
    
    // 1. 从缓存获取
    String json = redis.get(key);
    if (json == null) {
        // 缓存没有,直接查数据库
        Product product = productDao.findById(productId);
        if (product != null) {
            // 异步更新缓存
            redis.setex(key, 3600, JSON.toJSONString(product));
        }
        return product;
    }
    
    // 2. 反序列化
    CacheData cacheData = JSON.parseObject(json, CacheData.class);
    Product product = (Product) cacheData.getData();
    
    // 3. 判断是否逻辑过期
    if (cacheData.getExpireTime() <= System.currentTimeMillis()) {
        // 已过期,异步更新
        CompletableFuture.runAsync(() -> {
            updateProductCache(productId);
        });
    }
    
    return product;
}

// 异步更新缓存
private void updateProductCache(Long productId) {
    String lockKey = "refresh:product:" + productId;
    
    // 获取锁,防止重复更新
    if (redis.setnx(lockKey, "1", 30)) {  // 锁30秒
        try {
            Product product = productDao.findById(productId);
            if (product != null) {
                CacheData cacheData = new CacheData();
                cacheData.setData(product);
                cacheData.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
                
                redis.set("product:" + productId, JSON.toJSONString(cacheData));
            }
        } finally {
            redis.del(lockKey);
        }
    }
}
方案3:热点key永不过期
复制代码
public class HotKeyManager {
    private static final Set<String> HOT_KEYS = new ConcurrentHashSet<>();
    
    // 监控热点key
    public void monitorHotKey(String key) {
        // 1. 计数器
        Long count = redis.incr("counter:" + key, 1);
        redis.expire("counter:" + key, 60);  // 60秒窗口
        
        // 2. 超过阈值标记为热点
        if (count > 1000) {  // 阈值
            HOT_KEYS.add(key);
            
            // 3. 取消过期时间
            redis.persist(key);
            
            // 4. 启动后台线程定期更新
            startRefreshTask(key);
        }
    }
    
    private void startRefreshTask(String key) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
        scheduler.scheduleAtFixedRate(() -> {
            // 异步更新缓存
            refreshHotKey(key);
        }, 0, 30, TimeUnit.SECONDS);  // 每30秒更新一次
    }
}

四、缓存雪崩(Cache Avalanche)

1. ​问题定义

  • 大量缓存key在同一时间失效
  • 所有请求直接访问数据库
  • 数据库压力过大,可能导致宕机

2. ​场景示例

复制代码
// 错误的设置方式:所有key同时过期
public void initCache() {
    List<Product> products = productDao.findAll();
    
    for (Product product : products) {
        String key = "product:" + product.getId();
        // 所有key都设置相同过期时间
        redis.setex(key, 3600, JSON.toJSONString(product));
    }
    // 1小时后,所有缓存同时失效!
}

3. ​解决方案

方案1:过期时间随机化
复制代码
public void setCacheWithRandomExpire(String key, Object value) {
    // 基础过期时间 + 随机时间
    int baseExpire = 3600;  // 1小时
    int randomRange = 600;  // 10分钟
    
    // 生成随机过期时间
    int expireTime = baseExpire + 
        new Random().nextInt(randomRange * 2) - randomRange;
    
    redis.setex(key, expireTime, JSON.toJSONString(value));
}

// 批量设置缓存
public void initCacheWithRandomExpire() {
    List<Product> products = productDao.findAll();
    
    for (Product product : products) {
        String key = "product:" + product.getId();
        setCacheWithRandomExpire(key, product);
    }
}
方案2:缓存预热
复制代码
@Component
public class CacheWarmUp {
    
    @PostConstruct
    public void warmUp() {
        // 1. 服务启动时预热
        CompletableFuture.runAsync(this::loadHotData);
    }
    
    @Scheduled(cron = "0 0 3 * * ?")  // 每天凌晨3点执行
    public void scheduledWarmUp() {
        loadHotData();
    }
    
    private void loadHotData() {
        // 2. 加载热点数据
        List<Product> hotProducts = productDao.findHotProducts();
        
        for (Product product : hotProducts) {
            String key = "product:" + product.getId();
            // 设置不同的过期时间
            int expireTime = 3600 + new Random().nextInt(1800);
            redis.setex(key, expireTime, JSON.toJSONString(product));
        }
    }
}
方案3:多级缓存架构
复制代码
public class MultiLevelCache {
    // 一级缓存:本地缓存(Caffeine)
    private Cache<String, Object> localCache = Caffeine.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(10000)
        .build();
    
    // 二级缓存:Redis集群
    private RedisTemplate<String, Object> redisTemplate;
    
    public Object get(String key) {
        // 1. 查本地缓存
        Object value = localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
        
        // 2. 查Redis
        value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            // 3. 写入本地缓存
            localCache.put(key, value);
            return value;
        }
        
        // 3. 查数据库
        value = loadFromDB(key);
        if (value != null) {
            // 异步更新缓存
            CompletableFuture.runAsync(() -> {
                redisTemplate.opsForValue().set(key, value, 
                    getRandomExpire(), TimeUnit.SECONDS);
            });
            localCache.put(key, value);
        }
        
        return value;
    }
}
方案4:服务降级和熔断
复制代码
@Component
public class CacheServiceWithFallback {
    
    // 使用Hystrix或Sentinel实现熔断
    @HystrixCommand(fallbackMethod = "getFromDBWithLimit")
    public Product getProduct(Long productId) {
        String key = "product:" + productId;
        
        // 1. 查缓存
        Product product = redis.get(key);
        if (product == null) {
            // 2. 查数据库
            product = productDao.findById(productId);
            redis.setex(key, 3600, product);
        }
        
        return product;
    }
    
    // 降级方法:限制数据库查询
    public Product getFromDBWithLimit(Long productId) {
        // 使用信号量限制并发
        if (!semaphore.tryAcquire()) {
            throw new RuntimeException("系统繁忙,请稍后重试");
        }
        
        try {
            return productDao.findById(productId);
        } finally {
            semaphore.release();
        }
    }
}

五、高级解决方案

1. ​Redis集群高可用

复制代码
# Redis哨兵模式配置
spring:
  redis:
    sentinel:
      master: mymaster
      nodes: 
        - 192.168.1.1:26379
        - 192.168.1.2:26379
        - 192.168.1.3:26379

2. ​多级缓存架构

复制代码
客户端请求
    ↓
Nginx缓存(静态资源)
    ↓
应用层本地缓存(Caffeine/Guava)
    ↓
分布式缓存(Redis Cluster)
    ↓
数据库

3. ​监控和报警系统

复制代码
@Component
public class CacheMonitor {
    
    // 监控缓存命中率
    @Scheduled(fixedRate = 60000)  // 每分钟
    public void monitorHitRate() {
        long hitCount = getHitCount();
        long missCount = getMissCount();
        
        double hitRate = (double) hitCount / (hitCount + missCount);
        
        if (hitRate < 0.8) {  // 命中率低于80%
            // 发送报警
            sendAlert("缓存命中率过低:" + hitRate);
        }
    }
    
    // 监控缓存雪崩风险
    public void monitorExpireKeys() {
        // 扫描即将过期的key
        Set<String> keys = redis.keys("product:*");
        
        Map<Long, Integer> expireCountMap = new HashMap<>();
        
        for (String key : keys) {
            Long ttl = redis.ttl(key);
            if (ttl != null && ttl < 300) {  // 5分钟内过期
                long timeSlot = ttl / 60;  // 按分钟分组
                expireCountMap.merge(timeSlot, 1, Integer::sum);
            }
        }
        
        // 检查是否有大量key同时过期
        for (Map.Entry<Long, Integer> entry : expireCountMap.entrySet()) {
            if (entry.getValue() > 1000) {  // 同一分钟超过1000个key过期
                sendAlert("缓存雪崩风险:" + entry.getKey() + "分钟");
            }
        }
    }
}

六、面试高频问题

Q1:缓存穿透、击穿、雪崩的区别是什么?

参考答案​:

复制代码
1. 缓存穿透:查不存在的数据,缓存和数据库都没有
   - 原因:恶意攻击、误操作
   - 解决:布隆过滤器、空值缓存

2. 缓存击穿:热点key突然失效
   - 原因:单个key过期,高并发访问
   - 解决:互斥锁、永不过期、逻辑过期

3. 缓存雪崩:大量key同时失效
   - 原因:缓存服务宕机、相同过期时间
   - 解决:过期时间随机、多级缓存、集群高可用

Q2:布隆过滤器的原理和优缺点?

参考答案​:

复制代码
原理:
1. 使用多个哈希函数映射到位数组
2. 插入时,将多个位置设为1
3. 查询时,所有位置都为1才认为可能存在

优点:
1. 空间效率极高
2. 查询时间O(k),k为哈希函数数量

缺点:
1. 存在误判率(假阳性)
2. 不能删除元素
3. 需要预先知道数据规模

Q3:如何选择互斥锁和逻辑过期方案?

参考答案​:

复制代码
选择依据:

使用互斥锁的场景:
1. 数据一致性要求高
2. 并发量不是特别大
3. 对性能要求不是极致

使用逻辑过期的场景:
1. 热点数据,访问频繁
2. 允许短暂的数据不一致
3. 对性能要求高

实际项目中可以结合使用:
1. 对关键数据使用互斥锁
2. 对热点数据使用逻辑过期
3. 配合监控系统动态调整

Q4:如何监控和预防缓存雪崩?

参考答案​:

复制代码
监控指标:
1. 缓存命中率
2. Redis内存使用率
3. 数据库QPS
4. Key过期时间分布

预防措施:
1. 设计阶段:设置随机过期时间
2. 部署阶段:Redis集群+哨兵
3. 运行阶段:缓存预热+多级缓存
4. 应急方案:服务降级+熔断

报警机制:
1. 命中率低于阈值
2. 大量key同时过期
3. 数据库连接数突增
4. Redis集群节点故障

七、实战代码示例

完整的缓存工具类

复制代码
@Component
public class CacheUtil {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private RedissonClient redissonClient;
    
    // 解决缓存穿透
    public <T> T getWithPenetrationProtection(String key, 
            Class<T> clazz, 
            Supplier<T> dbLoader, 
            int expireSeconds) {
        
        // 1. 尝试从缓存获取
        Object value = redisTemplate.opsForValue().get(key);
        
        if (value != null) {
            // 空值处理
            if ("NULL".equals(value)) {
                return null;
            }
            return JSON.parseObject((String) value, clazz);
        }
        
        // 2. 获取分布式锁
        RLock lock = redissonClient.getLock("lock:" + key);
        
        try {
            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
                try {
                    // 3. 双重检查
                    value = redisTemplate.opsForValue().get(key);
                    if (value != null) {
                        if ("NULL".equals(value)) {
                            return null;
                        }
                        return JSON.parseObject((String) value, clazz);
                    }
                    
                    // 4. 查询数据库
                    T data = dbLoader.get();
                    
                    if (data != null) {
                        // 设置随机过期时间,防止雪崩
                        int randomExpire = expireSeconds + 
                            new Random().nextInt(600) - 300;
                        redisTemplate.opsForValue().set(
                            key, 
                            JSON.toJSONString(data), 
                            randomExpire, 
                            TimeUnit.SECONDS
                        );
                    } else {
                        // 空值缓存
                        redisTemplate.opsForValue().set(
                            key, 
                            "NULL", 
                            300,  // 5分钟
                            TimeUnit.SECONDS
                        );
                    }
                    
                    return data;
                } finally {
                    lock.unlock();
                }
            } else {
                // 等待后重试
                Thread.sleep(100);
                return getWithPenetrationProtection(key, clazz, dbLoader, expireSeconds);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取缓存失败", e);
        }
    }
    
    // 热点key保护
    public <T> T getHotKey(String key, Class<T> clazz, Supplier<T> dbLoader) {
        // 逻辑过期方案
        String value = (String) redisTemplate.opsForValue().get(key);
        
        if (value == null) {
            return dbLoader.get();
        }
        
        CacheWrapper<T> wrapper = JSON.parseObject(value, 
            new TypeReference<CacheWrapper<T>>() {});
        
        // 检查是否逻辑过期
        if (wrapper.getExpireTime() > System.currentTimeMillis()) {
            return wrapper.getData();
        }
        
        // 过期,异步刷新
        CompletableFuture.runAsync(() -> refreshHotKey(key, dbLoader));
        
        return wrapper.getData();
    }
    
    private <T> void refreshHotKey(String key, Supplier<T> dbLoader) {
        RLock lock = redissonClient.getLock("refresh:" + key);
        
        if (lock.tryLock()) {
            try {
                T data = dbLoader.get();
                if (data != null) {
                    CacheWrapper<T> wrapper = new CacheWrapper<>();
                    wrapper.setData(data);
                    wrapper.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
                    
                    redisTemplate.opsForValue().set(
                        key, 
                        JSON.toJSONString(wrapper)
                    );
                }
            } finally {
                lock.unlock();
            }
        }
    }
    
    // 缓存包装类
    @Data
    static class CacheWrapper<T> {
        private T data;
        private long expireTime;
    }
}

八、最佳实践总结

  1. 设计阶段

    • 合理设置过期时间(基础时间+随机偏移)
    • 热点数据永不过期+异步更新
    • 非热点数据设置较短过期时间
  2. 开发阶段

    • 所有缓存操作都要有降级方案
    • 使用连接池,避免频繁创建连接
    • 大key拆分,避免阻塞
  3. 测试阶段

    • 压力测试缓存系统
    • 模拟缓存失效场景
    • 验证降级熔断机制
  4. 运维阶段

    • 监控缓存命中率
    • 定期分析热点key
    • 建立报警机制
  5. 应急方案

    • 快速扩容Redis集群
    • 启用本地缓存
    • 数据库限流保护

随着技术发展,缓存防护也在不断演进:

  1. 智能化防护:AI预测热点数据,自动调整缓存策略
  2. 无服务化缓存:Serverless缓存服务,按需弹性伸缩
  3. 边缘缓存:CDN + 边缘计算,进一步降低延迟
  4. 统一缓存层:跨多种数据源和存储的统一缓存抽象

最后的思考

缓存不仅仅是性能优化的工具,更是系统稳定性的基石 。缓存穿透、击穿、雪崩这三个问题,表面上看起来是技术挑战,实际上考验的是我们对系统架构的深度理解工程实践能力

记住:​没有银弹,只有合适的方案。在实际工作中,我们需要:

  1. 深入理解业务特点:不同业务对一致性、可用性的要求不同
  2. 权衡各种方案的利弊:在性能、一致性、复杂度之间找到平衡点
  3. 建立持续优化的机制:缓存策略需要随着业务发展不断调整
  4. 培养系统性思维:缓存问题往往反映出整个系统架构的深层次问题

优秀的缓存设计,就像优秀的城市规划------不仅要解决当前的交通拥堵,更要预见未来的发展需求。希望通过本文的学习,你不仅掌握了解决缓存三大问题的具体技术方案,更重要的是建立了防御性设计的思维方式,能够在实际工作中构建出更加健壮、可靠的系统。

相关推荐
Lee川18 分钟前
优雅进化的JavaScript:从ES6+新特性看现代前端开发范式
javascript·面试
Lee川4 小时前
从异步迷雾到优雅流程:JavaScript异步编程与内存管理的现代化之旅
javascript·面试
晴殇i6 小时前
揭秘JavaScript中那些“不冒泡”的DOM事件
前端·javascript·面试
绝无仅有6 小时前
Redis过期删除与内存淘汰策略详解
后端·面试·架构
绝无仅有6 小时前
Redis大Key问题排查与解决方案全解析
后端·面试·架构
AAA梅狸猫7 小时前
Looper.loop() 循环机制
面试
AAA梅狸猫7 小时前
Handler基本概念
面试
Wect8 小时前
浏览器缓存机制
前端·面试·浏览器
掘金安东尼8 小时前
Fun with TypeScript Generics:玩转 TS 泛型
前端·javascript·面试
掘金安东尼8 小时前
Next.js 企业级落地
前端·javascript·面试