问题背景与适用场景

在分布式系统中,Redis作为高性能缓存被广泛应用。然而,在高并发场景下,缓存可能面临穿透、击穿和雪崩三大问题,这些问题会导致数据库压力剧增,甚至导致系统崩溃。

缓存穿透通常发生在查询不存在的数据时,请求直接穿透缓存到达数据库;缓存击穿则是当某个热点key失效时,大量并发请求直接打到数据库;缓存雪崩则是大量key同时失效或Redis宕机,导致所有请求涌向数据库。

这些问题常见于电商秒杀、热点新闻推送、微博热搜等高并发场景,一旦发生,轻则服务响应变慢,重则数据库宕机。

缓存原理

缓存穿透

缓存穿透是指查询一个根本不存在的数据,由于缓存中没有,请求会直接打到数据库。如果大量这类请求并发,数据库压力会骤增。

成因:

  1. 恶意攻击,大量请求不存在的数据
  2. 业务逻辑错误,大量查询不存在的数据
  3. 数据库中的数据被删除,但缓存未及时更新

缓存击穿

缓存击穿是指某个热点key在某一刻突然失效,此时大量并发请求同时发起,由于缓存未命中,这些请求会直接打到数据库。

成因:

  1. 热点key设置了较短的过期时间
  2. 热点key被大量并发请求访问
  3. 缓存更新策略不当

缓存雪崩

缓存雪崩是指大量key在同一时间失效或Redis服务宕机,导致所有请求直接访问数据库。

成因:

  1. 大量key设置了相同的过期时间
  2. Redis服务宕机
  3. 集群故障导致缓存不可用

操作步骤

缓存穿透解决方案

  1. 缓存空对象
  • 查询数据库后,即使数据不存在,也将结果缓存
  • 设置较短的过期时间,避免长期占用内存
  1. 布隆过滤器
  • 使用布隆过滤器预先过滤不存在的请求
  • 在请求查询缓存前,先通过布隆过滤器判断

缓存击穿解决方案

  1. 互斥锁(Mutex Lock)
  • 当缓存失效时,只允许一个线程查询数据库
  • 其他线程等待或返回默认值
  • 查询完成后更新缓存
  1. 永不过期(逻辑过期)
  • 热点key不设置物理过期时间
  • 通过逻辑标记判断是否需要更新
  • 后台异步更新缓存
  1. 随机过期时间
  • 为相同类型的key设置不同的过期时间
  • 避免大量key同时失效

缓存雪崩解决方案

  1. 缓存预热
  • 系统启动时主动加载热点数据到缓存
  • 定期刷新热点数据
  1. 错开过期时间
  • 为key设置基础过期时间加上随机值
  • 避免大量key同时失效
  1. 多级缓存
  • 使用本地缓存+分布式缓存
  • 即使Redis宕机,本地缓存仍可提供服务
  1. 降级与熔断
  • 当缓存不可用时,降级直接访问数据库
  • 设置熔断机制,防止数据库被压垮

示例代码

缓存空对象实现

public Object getWithCacheNull(String key) {
    // 1. 先从缓存获取
    Object value = redis.get(key);
    
    // 2. 缓存命中,直接返回
    if (value != null) {
        return value.equals("") ? null : value;
    }
    
    // 3. 缓存未命中,查询数据库
    value = db.query(key);
    
    // 4. 无论数据是否存在,都缓存结果
    if (value == null) {
        // 缓存空对象,设置较短的过期时间
        redis.setex(key, 60, "");
    } else {
        redis.setex(key, 3600, value.toString());
    }
    
    return value;
}

互斥锁解决缓存击穿

public Object getWithMutexLock(String key) {
    // 1. 先从缓存获取
    Object value = redis.get(key);
    if (value != null) {
        return value.equals("") ? null : value;
    }
    
    // 2. 尝试获取锁
    String lockKey = "lock:" + key;
    try {
        boolean locked = redis.setnx(lockKey, "1", 10); // 10秒过期
        if (locked) {
            // 3. 获取锁成功,查询数据库
            value = db.query(key);
            
            // 4. 更新缓存
            if (value == null) {
                redis.setex(key, 60, "");
            } else {
                redis.setex(key, 3600, value.toString());
            }
            
            return value;
        } else {
            // 5. 获取锁失败,休眠后重试
            Thread.sleep(50);
            return getWithMutexLock(key);
        }
    } catch (Exception e) {
        // 异常处理
        return null;
    } finally {
        // 6. 释放锁
        redis.del(lockKey);
    }
}

布隆过滤器实现

// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(), 1000000);

// 添加已存在的key到布隆过滤器
for (String key : db.getAllKeys()) {
    bloomFilter.put(key);
}

// 使用布隆过滤器过滤请求
public Object getWithBloomFilter(String key) {
    // 1. 先通过布隆过滤器判断
    if (!bloomFilter.mightContain(key)) {
        return null; // 一定不存在,直接返回
    }
    
    // 2. 从缓存获取
    Object value = redis.get(key);
    if (value != null) {
        return value.equals("") ? null : value;
    }
    
    // 3. 缓存未命中,查询数据库
    value = db.query(key);
    
    // 4. 更新缓存和布隆过滤器
    if (value == null) {
        redis.setex(key, 60, "");
    } else {
        redis.setex(key, 3600, value.toString());
        bloomFilter.put(key);
    }
    
    return value;
}

常见问题与注意事项

  1. 缓存空对象问题
  • 空对象会占用内存,需设置合理的过期时间
  • 可能导致缓存穿透问题依然存在,只是转移到数据库
  • 适用于数据相对稳定的场景
  1. 互斥锁问题
  • 可能导致性能下降,线程等待
  • 需要合理设置锁的超时时间,避免死锁
  • 高并发场景下可能成为瓶颈
  1. 布隆过滤器问题
  • 存在误判率,可能将存在的key误判为不存在
  • 不适合动态变化的数据集
  • 需要预估数据量,合理设置容量
  1. 缓存预热问题
  • 预热时机选择很重要,系统启动时可能影响性能
  • 需要识别真正的热点数据
  • 预热过程需要监控,避免影响正常服务
  1. 多级缓存问题
  • 本地缓存和分布式缓存的数据一致性问题
  • 内存占用问题
  • 缓存更新策略复杂

排错思路

  1. 监控缓存命中率
  • 设置缓存命中率监控,及时发现异常
  • 不同业务场景有不同的正常命中率范围
  1. 判断问题类型
  • 缓存穿透:特定key的请求大量增加,数据库压力大
  • 缓存击穿:单个key的请求量突增,数据库压力大
  • 缓存雪崩:整体请求量突增,数据库压力大
  1. 日志分析
  • 记录缓存命中情况,分析异常模式
  • 记录数据库查询日志,识别高频查询
  1. 性能监控
  • 监控Redis响应时间,发现性能下降
  • 监控数据库连接数,判断是否过载
  1. 报警机制
  • 设置缓存命中率过低报警
  • 设置数据库响应时间过长报警
  • 设置Redis内存使用率报警

总结

缓存穿透、击穿和雪崩是Redis缓存的常见问题,需要根据业务场景选择合适的解决方案。缓存穿透可通过缓存空对象和布隆过滤器解决;缓存击穿可通过互斥锁和永不过期策略解决;缓存雪崩可通过缓存预热、错开过期时间、多级缓存和降级熔断解决。

系统设计时需综合考虑性能、可用性和一致性,同时建立完善的监控和报警机制,以便及时发现和解决问题。在实际应用中,往往是多种策略的组合使用,以达到最佳效果。

主楼