面试高频详解: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. 培养系统性思维:缓存问题往往反映出整个系统架构的深层次问题

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

相关推荐
小雨下雨的雨1 小时前
第10篇:Redis监控、运维与故障排查
运维·redis·bootstrap
菜鸟小九1 小时前
redis基础(java客户端)
java·redis·bootstrap
不会写程序的未来程序员1 小时前
Redis 哨兵(Sentinel)原理
数据库·redis·sentinel
阿海5741 小时前
卸载redis7.2.4的shell脚本
linux·redis·shell
有什么东东1 小时前
redis实现店铺类型查看
java·开发语言·redis
一字白首1 小时前
Vue 项目实战,从组件缓存到 Vant UI 集成:项目初始化全流程
vue.js·ui·缓存
Lisonseekpan1 小时前
技术选型分析:MySQL、Redis、MongoDB、ElasticSearch与大数据怎么选?
大数据·redis·后端·mysql·mongodb·elasticsearch
Baihai_IDP1 小时前
压缩而不失智:LLM 量化技术深度解析
人工智能·面试·llm
小明的小名叫小明1 小时前
区块链核心知识点梳理(面试高频考点3)-共识机制详解(POW、POS、POH)
面试·区块链