Redis缓存穿透,击穿,雪崩问题改如何解决?

发布时间 2023-09-19 11:33:01作者: 叫授_pront

无论在开发过程中还是面试过程中,这三个问题总是被遇到。下面是各个问题的原因和解决方案。

缓存穿透


原因

  1. 缓存穿透其实是缓存的单点问题,是指查询一个一定不存在的数据。如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。
  2. 同时也可能是由于恶意请求或非常罕见的请求导致的,原理同上。如果这种情况频繁发生,会导致大量的请求直接访问底层数据源,增加了系统的负载并降低了性能。

解决方案

缓存空值

public class Cache {
    private Map<String, String> cacheMap;  //这里也可以使用RedisTemplate

    public Cache() {
        cacheMap = new HashMap<>();
    }

    public String getFromCache(String key) {
        // 先从缓存中查找数据
        String value = cacheMap.get(key);

        // 如果缓存中不存在该数据
        if (value == null) {
            // 查询底层数据源
            value = queryDataFromDataSource(key);

            // 如果底层数据源中不存在该数据
            if (value == null) {
                // 将空值缓存起来,防止缓存穿透
                cacheMap.put(key, "");
            } else {
                // 将数据放入缓存
                cacheMap.put(key, value);
            }
        }

        return value;
    }

    private String queryDataFromDataSource(String key) {
        // 查询底层数据源的逻辑
        // ...
        return null; // 假设底层数据源中不存在该数据
    }
}

将空值给缓存起来,这样就减少了数据库的交互。

布隆过滤器

布隆过滤器是一种空间效率高、查询时间快的概率型数据结构,用于判断一个元素是否存在于一个集合中。它可以帮助我们快速判断一个请求是否是恶意请求,从而减轻底层数据源的负载。

以下是使用布隆过滤器来解决缓存穿透问题的具体步骤:

  1. 初始化布隆过滤器:根据预估的数据规模和期望的误判率,初始化一个布隆过滤器。布隆过滤器的大小和误判率是成正比的,需要根据实际情况来选择合适的参数。

  2. 添加元素到布隆过滤器:将所有可能存在于缓存中的关键字都添加到布隆过滤器中。每个关键字经过多次哈希函数计算后,会在布隆过滤器中的对应位上被标记为1。

  3. 查询请求是否存在于布隆过滤器中:当一个请求到来时,先用相同的哈希函数计算出请求的关键字。然后检查布隆过滤器中对应的位是否都为1。如果所有位都为1,说明请求可能存在于缓存中;如果有任何一位为0,说明请求一定不存在于缓存中。

  4. 进一步验证请求:如果布隆过滤器判断请求可能存在于缓存中,那么可以进一步查询缓存来验证请求的确切结果。如果布隆过滤器判断请求一定不存在于缓存中,可以直接返回缓存不存在的结果,避免查询底层数据源。

    import com.google.common.hash.BloomFilter;
    import com.google.common.hash.Funnels;
    
    public class BloomFilterExample {
        public static void main(String[] args) {
            // 创建一个布隆过滤器,预期插入的元素数量为10000,期望的误判率为0.1%
            BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.unencodedCharsFunnel(), 10000, 0.001);
            
            // 向布隆过滤器中添加元素
            bloomFilter.put("exampleKey1");
            bloomFilter.put("exampleKey2");
            
            // 检查元素是否存在于布隆过滤器中
            boolean exists1 = bloomFilter.mightContain("exampleKey1");
            boolean exists2 = bloomFilter.mightContain("exampleKey3");
            
            System.out.println("Exists 1: " + exists1); // 输出: Exists 1: true
            System.out.println("Exists 2: " + exists2); // 输出: Exists 2: false
        }
    }
    

    注意:??布隆过滤器是一个概率型数据结构,它可能会存在一定的误判率。因此,在使用布隆过滤器时,需要根据实际情况选择合适的预期误判率和容量来平衡误判率和存储空间的需求。??

    Snipaste_2023-09-19_10-57-15

缓存击穿


原因

  1. 热点数据失效:当一个热点数据在缓存中过期或被删除时,如果此时有大量的请求同时访问该数据,缓存无法命中,导致这些请求都直接访问后端存储系统。
  2. 并发访问压力:当一个热点数据失效时,大量的并发请求同时访问该数据,由于缓存无法命中,这些请求同时涌入后端存储系统,造成瞬时的高并发访问压力。
  3. 缓存过期时间设置不合理:如果缓存中的数据过期时间设置过短,那么热点数据很容易失效,导致缓存击穿的概率增加。
  4. 瞬时流量突增:当系统面临瞬时的大规模请求涌入时,可能会出现大量的请求同时访问一个热点数据,而缓存无法承受如此高的并发访问压力,导致缓存击穿。
  5. 缓存层故障:如果缓存层发生故障,导致无法正常提供缓存服务,所有的请求都会直接访问后端存储系统,引发缓存击穿。

解决方案

  1. 设置key的逻辑过期时间,避免热点数据频繁失效:在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间,当查询的时候,从redis取出数据后判断时间是否过期,如果过期则开通另外一个线程进行数据同步,当前线程正常返回数据,这个数据不是最新,但是不会造成线程阻塞

    mport redis.clients.jedis.Jedis;
    
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    
    public class CacheExample {
        private static final String CACHE_KEY = "exampleKey";
        private static final int EXPIRATION_SECONDS = 600; // 过期时间,单位为秒
    
        public static void main(String[] args) {
            // 创建Redis连接
            Jedis jedis = new Jedis("localhost", 6379);
    
            // 设置缓存键,并添加过期时间字段
            jedis.set(CACHE_KEY, "缓存字段");
            jedis.set(CACHE_KEY + ":expiration", String.valueOf(System.currentTimeMillis() + EXPIRATION_SECONDS * 1000));
    
            // 查询缓存数据
            String cachedData = jedis.get(CACHE_KEY);
    
            // 检查数据是否过期
            long expirationTime = Long.parseLong(jedis.get(CACHE_KEY + ":expiration"));
            if (System.currentTimeMillis() > expirationTime) {
                // 启动新线程进行数据同步
                ExecutorService executorService = Executors.newSingleThreadExecutor();
                executorService.execute(() -> {
                    // 进行数据同步的逻辑
                    // 更新缓存数据
                    jedis.set(CACHE_KEY, "更新的数据");
                    jedis.set(CACHE_KEY + ":expiration", String.valueOf(System.currentTimeMillis() + EXPIRATION_SECONDS * 1000));
                });
    
                // 返回旧数据
                System.out.println("Cached Data (not up-to-date): " + cachedData);
            } else {
                // 返回缓存数据
                System.out.println("Cached Data: " + cachedData);
            }
    
            // 关闭Redis连接
            jedis.close();
        }
    }
    
  2. 使用互斥锁分布式锁,保证只有一个请求可以去加载和更新缓存中的数据

    1. public class CacheExample {
          private static final String CACHE_KEY = "exampleKey";
          private static final int CACHE_EXPIRATION_SECONDS = 60; // 缓存过期时间,单位为秒
      
          public static void main(String[] args) {
              // 创建Redis连接
              Jedis jedis = new Jedis("localhost", 6379);
              // 创建Redisson配置
              Config config = new Config();
              config.useSingleServer().setAddress("redis://localhost:6379");
              // 创建Redisson客户端
              RedissonClient redisson = Redisson.create(config);
              // 获取分布式锁
              RLock lock = redisson.getLock(CACHE_KEY);
              try {
                  // 尝试获取锁,最多等待10秒
                  boolean isLocked = lock.tryLock(10, TimeUnit.SECONDS);
                  if (isLocked) {
                      try {
                          // 检查缓存中是否存在数据
                          String cachedData = jedis.get(CACHE_KEY);
                          if (cachedData != null) {
                              // 缓存命中,直接使用缓存数据
                              System.out.println("Data from cache: " + cachedData);
                          } else {
                              // 缓存未命中,从后端系统获取数据
                              String backendData = fetchDataFromBackend();
                              // 将数据存入缓存,并设置过期时间
                              jedis.setex(CACHE_KEY, CACHE_EXPIRATION_SECONDS, backendData);
                              System.out.println("Data from backend: " + backendData);
                          }
                      } finally {
                          // 释放锁
                          lock.unlock();
                      }
                  } else {
                      // 获取锁失败,可以进行相应的处理
                      System.out.println("Failed to acquire lock");
                  }
              } catch (InterruptedException e) {
                  // 处理锁等待过程中的中断异常
                  e.printStackTrace();
              }
              // 关闭Redis连接和Redisson客户端
              jedis.close();
              redisson.shutdown();
          }
      
          private static String fetchDataFromBackend() {
              // 模拟从后端系统获取数据的操作
              // 这里可以实现根据具体业务逻辑获取数据的代码
              return "Backend Data";
          }
      }
      

    当然两种方案各有利弊:

    如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题

    如果选择key的逻辑删除,则优先考虑的高可用性,性能比较高,但是数据同步这块做不到强一致。

缓存雪崩


原因

  1. 缓存数据的过期时间设置相近:如果缓存数据的过期时间设置得非常接近,那么这些数据很可能会在同一时间点失效,导致大量请求同时访问数据库。
  2. 批量缓存数据同时失效:当缓存中的大量数据同时失效,比如由于缓存服务器重启、网络故障等原因,都会导致大量请求直接访问数据库。
  3. 突发事件导致访问集中:当某个热点数据突发地被大量请求访问时,如果该热点数据的缓存过期,那么大量请求将会直接访问数据库。

解决方案

  1. 将缓存数据的过期时间分散开,避免大量数据在同一时间点失效。或者在将数据存入缓存时,可以给每个数据设置一个随机的过期时间,以减少缓存同时失效的概率。

    public class CacheExample {
        private static final String CACHE_KEY = "exampleKey";
        private static final int MIN_EXPIRATION_SECONDS = 60; // 最小过期时间,单位为秒
        private static final int MAX_EXPIRATION_SECONDS = 600; // 最大过期时间,单位为秒
    
        public static void main(String[] args) {
            // 创建Redis连接
            Jedis jedis = new Jedis("localhost", 6379);
    
            // 生成随机过期时间
            int expirationSeconds = generateRandomExpiration();
    
            // 缓存数据,并设置过期时间
            jedis.setex(CACHE_KEY, expirationSeconds, "Cached Data");
    
            // 关闭Redis连接
            jedis.close();
        }
    
        private static int generateRandomExpiration() {
            Random random = new Random();
            int randomSeconds = random.nextInt(MAX_EXPIRATION_SECONDS - MIN_EXPIRATION_SECONDS + 1) + MIN_EXPIRATION_SECONDS;
            return randomSeconds;
        }
    }
    
  2. 同时还可以采用多级缓存,将缓存分为多级,比如本地缓存和分布式缓存,提高缓存的稳定性和可靠性。

  3. 熔断降级,当数据库出现异常或者后台无法响应时,设置熔断机制,将请求直接返回默认值或者错误信息,避免请求堆积

总结


  • 缓存穿透是指查询一个一定不存在的数据,解决方案要不缓存空值,要不采用布隆过滤器。
  • 缓存击穿是指一个设置了过期时间的key,在它过期的时候,刚好有大量的请求过来,压力过大。解决方案要不采用锁(互斥锁),要不采用当前key逻辑过期。
  • 缓存雪崩就是一大批设置相同过期时间的key,某个时刻同时失效。压力过大,解决方案就是在原有的基础上设置随机的过期时间。防止同时失效
  • 所有的问题都可以采用熔断降级策略,需要找好方案与定制各种策略。