【中间件:Redis】4、Redis缓存实战:穿透/击穿/雪崩的5种解决方案(附代码达成)

# Redis缓存实战:穿透/击穿/雪崩的5种解决方案(附代码实现)

在Redis的实战场景中,“缓存三大问题”——穿透、击穿、雪崩是中大厂面试的必问项。面试官不仅会问“什么是缓存穿透”,更会追问“怎么解决?、代码怎么实现?、生产环境选哪种方案?”

本文将从“问题本质→解决方案→代码实现→场景选择→踩坑点”五个维度,系统拆解这三大问题,每个方案都附Java实战代码(Spring Boot+Redis),帮你既懂原理又能落地。

一、缓存穿透:查不到的数据“穿透”到数据库

1. 问题定义与危害

定义:客户端频繁请求“不存在的数据”(如查询ID=-1的用户、不存在的商品ID),由于缓存中没有这些数据,请求会直接穿透到数据库,导致数据库压力骤增,甚至宕机。
本质:缓存只缓存“存在的key”,对“不存在的key”无防护,形成“缓存真空”。

2. 解决方案1:缓存空值(简单有效,推荐中小场景)

(1)原理

第一次查询不存在的数据时,不仅返回空结果,还会往缓存中存入一个“空值”(如""null),并设置短期过期时间(避免长期占用内存)。后续请求会直接命中缓存的空值,不再访问数据库。

(2)Java代码实现(Spring Boot)
@Service
public class UserService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
  @Autowired
  private UserMapper userMapper;
  // 空值过期时间:5分钟(根据业务调整,不宜过长)
  private static final long NULL_VALUE_EXPIRE = 5 * 60 * 1000L;
  public User getUserById(Long id) {
  String key = "user:id:" + id;
  // 1. 先查缓存
  User user = (User) redisTemplate.opsForValue().get(key);
  if (user != null) {
  // 2. 缓存命中:若为null(空值标记),返回null;否则返回用户
  return user instanceof NullValue ? null : user;
  }
  // 3. 缓存未命中:查数据库
  user = userMapper.selectById(id);
  if (user == null) {
  // 4. 数据库也不存在:缓存空值(用自定义NullValue标记,避免与真实null混淆)
  redisTemplate.opsForValue().set(key, new NullValue(), NULL_VALUE_EXPIRE, TimeUnit.MILLISECONDS);
  return null;
  }
  // 5. 数据库存在:缓存真实数据(设置合理过期时间,如1小时)
  redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
  return user;
  }
  // 自定义空值标记类(避免与真实null混淆,防止缓存穿透)
  static class NullValue implements Serializable {}
  }
(3)适用场景与踩坑点
  • 适用场景:数据量不大(如十万级)、不存在的key请求频率不高的场景。
  • 踩坑点
    • 空值需设置短期过期时间(如5-10分钟),避免“真实数据新增后,缓存的空值导致查询不到”;
    • 用自定义NullValue类标记空值,避免与真实null混淆(RedisTemplate默认不存储null)。

3. 解决方案2:布隆过滤器(大数据量场景,推荐)

(1)原理

布隆过滤器是一种“概率性数据结构”,能快速判断“一个元素是否存在于集合中”。提前将所有“存在的key”(如数据库中所有用户ID)存入布隆过滤器,请求先经过过滤器:

  • 若过滤器判断“不存在”,直接返回空,不访问缓存和数据库;
  • 若过滤器判断“可能存在”(允许一定误判率),再走“缓存→数据库”流程。
(2)Java代码实现(基于Guava布隆过滤器)
@Configuration
public class BloomFilterConfig {
// 预计数据量(如100万用户ID)
private static final long EXPECTED_INSERTIONS = 1000000;
// 误判率(推荐0.01-0.001,越小占用内存越大)
private static final double FPP = 0.01;
// 初始化用户ID布隆过滤器
@Bean
public BloomFilter<Long> userBloomFilter() {
  BloomFilter<Long> filter = BloomFilter.create(
    Funnels.longFunnel(),
    EXPECTED_INSERTIONS,
    FPP
    );
    // 从数据库加载所有存在的用户ID,放入过滤器(实际应异步加载)
    List<Long> allUserIds = userMapper.selectAllIds();
      allUserIds.forEach(filter::put);
      return filter;
      }
      }
      @Service
      public class UserService {
      @Autowired
      private BloomFilter<Long> userBloomFilter;
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
          @Autowired
          private UserMapper userMapper;
          public User getUserById(Long id) {
          // 1. 先过布隆过滤器:若不存在,直接返回null
          if (!userBloomFilter.mightContain(id)) {
          return null;
          }
          // 2. 过滤器判断可能存在,再查缓存和数据库(同缓存空值方案的后续流程)
          String key = "user:id:" + id;
          User user = (User) redisTemplate.opsForValue().get(key);
          if (user != null) {
          return user instanceof NullValue ? null : user;
          }
          user = userMapper.selectById(id);
          if (user == null) {
          redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
          return null;
          }
          redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
          return user;
          }
          }
(3)适用场景与踩坑点
  • 适用场景:数据量大(百万级以上)、不存在的key请求频率极高的场景(如爬虫恶意攻击)。
  • 踩坑点
    • 存在“误判率”(无法完全避免穿透):需配合“缓存空值”兜底;
    • 不支持删除数据:若数据库数据删除,布隆过滤器无法同步删除,需定期重建过滤器(如每天凌晨);
    • 内存占用:100万数据、0.01误判率约占1.5MB,可接受。

二、缓存击穿:热点key失效瞬间,请求全冲库

1. 问题定义与危害

定义:某个“热点key”(如秒杀商品ID、热门文章ID)缓存突然过期,瞬间大量并发请求未命中缓存,全部穿透到数据库,导致数据库过载。
本质:热点key的“缓存失效时间点”与“高并发请求”重叠,形成“流量尖峰”。

2. 解决方案1:互斥锁(控制并发,推荐通用场景)

(1)原理

缓存失效时,不是所有请求都去查数据库,而是让“第一个请求”获取锁(如Redis的SET NX),去数据库查询并更新缓存;其他请求获取锁失败后,等待一段时间再重试,直到缓存更新完成。

(2)Java代码实现(基于Redis分布式锁)
@Service
public class GoodsService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
  @Autowired
  private GoodsMapper goodsMapper;
  // 锁过期时间:3秒(需大于数据库查询+缓存更新的耗时)
  private static final long LOCK_EXPIRE = 3 * 1000L;
  // 重试间隔:100毫秒
  private static final long RETRY_INTERVAL = 100L;
  public Goods getGoodsById(Long id) {
  String key = "goods:id:" + id;
  // 1. 先查缓存
  Goods goods = (Goods) redisTemplate.opsForValue().get(key);
  if (goods != null) {
  return goods;
  }
  // 2. 缓存失效:尝试获取锁
  String lockKey = "lock:goods:" + id;
  boolean locked = false;
  try {
  // 2.1 用SET NX获取锁(仅当锁不存在时成功)
  locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_EXPIRE, TimeUnit.MILLISECONDS);
  if (locked) {
  // 2.2 获取锁成功:查数据库
  goods = goodsMapper.selectById(id);
  if (goods == null) {
  // 数据库不存在:缓存空值(短期)
  redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
  return null;
  }
  // 数据库存在:更新缓存(设置合理过期时间,如30分钟)
  redisTemplate.opsForValue().set(key, goods, 30, TimeUnit.MINUTES);
  return goods;
  } else {
  // 2.3 获取锁失败:等待后重试(最多重试5次)
  int retryCount = 0;
  while (retryCount < 5) {
  Thread.sleep(RETRY_INTERVAL);
  goods = (Goods) redisTemplate.opsForValue().get(key);
  if (goods != null) {
  return goods;
  }
  retryCount++;
  }
  // 重试多次仍未获取缓存:返回默认兜底数据(如“系统繁忙”)
  return new Goods().setName("系统繁忙,请稍后再试");
  }
  } catch (InterruptedException e) {
  Thread.currentThread().interrupt();
  return null;
  } finally {
  // 3. 释放锁(仅释放自己的锁,避免误删)
  if (locked) {
  redisTemplate.delete(lockKey);
  }
  }
  }
  }
(3)适用场景与踩坑点
  • 适用场景:热点key更新频率不高、数据库查询耗时较短的场景(如商品详情)。
  • 踩坑点
    • 锁过期时间需大于“数据库查询+缓存更新”的耗时,避免“锁提前释放,多个线程同时查库”;
    • 重试次数和间隔需合理(如5次、100ms),避免线程长时间阻塞;
    • 释放锁需判断“是否是自己的锁”(复杂场景可用Lua脚本,本例简化处理)。

3. 解决方案2:热点key永不过期(彻底避免失效,推荐超高并发场景)

(1)原理

两种实现方式:

  • 物理上不设置过期时间:缓存中的热点key永远不过期;
  • 逻辑上永不过期:设置过期时间,但用异步线程定期(如每隔29分钟)更新过期时间,保证缓存“逻辑上不过期”。

核心是“不让热点key在高并发时失效”。

(2)Java代码实现(异步线程续期)
@Service
public class SeckillService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
  @Autowired
  private SeckillMapper seckillMapper;
  // 初始化定时线程池(用于更新过期时间)
  private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);
  public Seckill getSeckillById(Long id) {
  String key = "seckill:id:" + id;
  // 1. 先查缓存
  Seckill seckill = (Seckill) redisTemplate.opsForValue().get(key);
  if (seckill != null) {
  return seckill;
  }
  // 2. 缓存未命中:查数据库并初始化缓存(设置30分钟过期)
  seckill = seckillMapper.selectById(id);
  if (seckill == null) {
  return null;
  }
  redisTemplate.opsForValue().set(key, seckill, 30, TimeUnit.MINUTES);
  // 3. 启动异步线程:每隔29分钟更新一次过期时间(逻辑上永不过期)
  scheduler.scheduleAtFixedRate(() -> {
  redisTemplate.expire(key, 30, TimeUnit.MINUTES);
  }, 29, 29, TimeUnit.MINUTES);
  return seckill;
  }
  // 服务关闭时关闭线程池
  @PreDestroy
  public void destroy() {
  scheduler.shutdown();
  }
  }
(3)适用场景与踩坑点
  • 适用场景:超高并发的热点key(如秒杀、热门活动),且数据更新频率低(避免缓存与数据库不一致)。
  • 踩坑点
    • 需保证“异步线程池”的稳定性(避免线程泄露);
    • 若数据更新,需主动更新缓存(如发布消息通知缓存更新),否则会出现“缓存脏数据”。

三、缓存雪崩:大量key同时过期,数据库被冲垮

1. 问题定义与危害

定义:缓存中大量key在同一时间过期,或缓存集群宕机,导致所有请求瞬间落到数据库,数据库直接被压垮。
本质:“缓存失效”或“缓存不可用”与“高并发请求”叠加,形成“流量洪峰”。

2. 解决方案1:过期时间随机化(避免同时过期,基础方案)

(1)原理

给每个key的过期时间加一个“随机值”(如30分钟 ± 5分钟),避免大量key在同一时间点过期,将过期时间分散到不同时间段。

(2)Java代码实现(封装Redis工具类)
@Component
public class RedisCacheUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
  // 基础过期时间(如30分钟)
  private static final long BASE_EXPIRE = 30 * 60 * 1000L;
  // 随机值范围(±5分钟)
  private static final long RANDOM_RANGE = 5 * 60 * 1000L;
  private static final Random random = new Random();
  // 存储缓存并添加随机过期时间
  public void setWithRandomExpire(String key, Object value) {
  // 计算随机过期时间:BASE_EXPIRE ± RANDOM_RANGE
  long expire = BASE_EXPIRE + (random.nextLong() % (2 * RANDOM_RANGE + 1) - RANDOM_RANGE);
  redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MILLISECONDS);
  }
  }
  // 使用示例
  @Service
  public class ProductService {
  @Autowired
  private RedisCacheUtil redisCacheUtil;
  public void saveProduct(Product product) {
  // 存储缓存时自动添加随机过期时间
  redisCacheUtil.setWithRandomExpire("product:id:" + product.getId(), product);
  }
  }

3. 解决方案2:缓存集群高可用(避免缓存不可用,核心方案)

(1)原理

通过“主从复制+哨兵”或“Redis Cluster”部署缓存集群,避免单节点宕机导致整个缓存不可用:

  • 主从复制:主节点处理写请求,从节点同步数据并处理读请求,主节点宕机后从节点可切换为主;
  • 哨兵:监控主从节点健康状态,自动完成故障转移(主节点宕机后选新主);
  • Redis Cluster:分片存储数据,支持多主多从,单个节点宕机不影响整体可用。
(2)核心配置(Redis Cluster示例)
# Spring Boot配置Redis Cluster
spring:
redis:
cluster:
nodes:
- 192.168.1.101:6379
- 192.168.1.102:6379
- 192.168.1.103:6379
- 192.168.1.104:6379
- 192.168.1.105:6379
- 192.168.1.106:6379
max-redirects: 3  # 最大重定向次数

4. 解决方案3:服务熔断降级(保护数据库,兜底方案)

(1)原理

当数据库压力过大(如QPS超过阈值),通过熔断工具(如Sentinel、Resilience4j)暂时“熔断”缓存到数据库的请求,返回降级数据(如“系统繁忙,请稍后再试”),避免数据库被压垮。

(2)Java代码实现(基于Sentinel)
@Service
public class OrderService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
  @Autowired
  private OrderMapper orderMapper;
  // 用Sentinel注解设置熔断规则:QPS>1000时降级
  @SentinelResource(
  value = "getOrderById",
  blockHandler = "getOrderByIdBlockHandler"  // 熔断时执行的方法
  )
  public Order getOrderById(Long id) {
  String key = "order:id:" + id;
  Order order = (Order) redisTemplate.opsForValue().get(key);
  if (order != null) {
  return order;
  }
  // 缓存未命中:查数据库(熔断时不会执行到这里)
  order = orderMapper.selectById(id);
  if (order != null) {
  redisTemplate.opsForValue().set(key, order, 1, TimeUnit.HOURS);
  }
  return order;
  }
  // 熔断降级处理方法(参数和返回值需与原方法一致)
  public Order getOrderByIdBlockHandler(Long id, BlockException e) {
  return new Order().setId(id).setMessage("系统繁忙,请稍后再试");
  }
  }
(3)Sentinel控制台配置

在Sentinel控制台为getOrderById资源设置规则:

  • 阈值类型:QPS;
  • 单机阈值:1000;
  • 流控模式:直接;
  • 流控效果:快速失败(直接返回降级数据)。

四、场景化选择:不同场景怎么选方案?

问题类型场景特点推荐方案
缓存穿透数据量小(<10万)、无效请求少缓存空值
缓存穿透数据量大(>100万)、无效请求多布隆过滤器+缓存空值(兜底)
缓存击穿热点key更新频率低、并发中等互斥锁
缓存击穿热点key超高并发(如秒杀)热点key永不过期+异步更新
缓存雪崩预防key同时过期过期时间随机化
缓存雪崩预防缓存集群不可用Redis Cluster(主从+哨兵)
缓存雪崩数据库压力过大时兜底服务熔断降级(Sentinel)

五、面试高频踩坑题&标准答案

  1. 问:缓存空值会导致什么问题?如何避免?
    答:问题:若真实数据新增,缓存的空值会导致“查不到新数据”。避免:设置短期过期时间(如5分钟),或新增数据时主动删除缓存的空值。
  2. 问:布隆过滤器为什么不能删除数据?
    答:布隆过滤器通过“多个哈希函数映射到位数组”实现,删除一个元素会影响其他元素的映射结果(可能导致误判“不存在”),因此不支持删除。解决方案:定期重建过滤器。
  3. 问:互斥锁的过期时间设置过短会怎样?
    答:若锁过期时间小于“数据库查询+缓存更新”的耗时,会导致“锁提前释放,多个线程同时查库”,重新引发缓存击穿。需根据实际耗时设置(如3-5秒),并预留冗余。
  4. 问:过期时间随机化的随机范围怎么定?
    答:随机范围=基础过期时间的10%-20%(如基础1小时,随机±6-12分钟),范围太小仍可能集中过期,太大可能导致缓存数据过期时间过长(脏数据)。

六、总结与下一篇预告

缓存三大问题的核心是“流量控制”和“风险隔离”:

  • 穿透:用“缓存空值”或“布隆过滤器”拦截无效请求;
  • 击穿:用“互斥锁”或“永不过期”控制热点key的并发流量;
  • 雪崩:用“随机过期”“集群高可用”“熔断降级”分散风险。

掌握这些方案的代码实现和场景选择,就能应对中大厂的实战面试题。

下一篇将聚焦“Redis分布式锁”——从基础实现到Redisson高级版的演进,包括锁续期、可重入性、集群场景优化等核心考点,帮你彻底搞懂分布式锁的实战落地,敬请关注。

如果觉得本文有用,欢迎收藏+转发,后续会持续更新Redis面试核心系列,帮你系统攻克Redis考点~

posted @ 2025-12-09 12:05  yangykaifa  阅读(0)  评论(0)    收藏  举报