问题场景及解决方案(如有不足请指正)

1.冷热数据分离

场景:在实际生产环境中,有些数据访问频率非常高(热数据),但大部分数据访问频率并不高(冷数据),所以我们需要进行数据的冷热区分,热数据我们通常是要从缓存中进行查找以减少与数据库的交互从而大大提升性能,从缓存中查询不到才从数据库中查找,查找完又会往缓存中放一份,我们新增或者更新需要缓存的数据,我们通常是先操作数据库进行新增或更新,然后根据id将数据设置到缓存中,但仅仅如此是不够的,如果数据量很大,这些数据时时刻刻保存在缓存中会占用非常多宝贵的Redis内存。

解决:我们在新增数据、修改数据、从缓存中查询数据没有查询到进而查询数据库,在这三个过程之后的设置缓存步骤中,我们对缓存设置一个超时时间,比如24小时,如果超过24小时,这个缓存就失效了,此时大部分冷数据就不存在于缓存中了,此后缓存中几乎全是经常访问的热数据,大大节约了Redis内存。

2.缓存击穿

场景:在1.冷热数据分离解决方案中介绍到了给缓存设置失效时间24小时,但仅仅只是设置固定的缓存失效时间在某些特定高并发场景下会出现,举个例子,我在今天12点上架了10000个商品,这10000个商品在新增的时候放进了缓存,但在明天12点这些缓存将会陆续失效,12点正是很多人午休时间,这个时候如果大量的查询商品请求发送到服务器,而这些商品的缓存已经过期了,所以这些商品数据将会从数据库中进行查询,这种情况就会造成数据库压力剧增,这就是缓存击穿。

解决:最简单的方法就在新增数据、修改数据、从缓存中查询数据没有查询到进而查询数据库,在这三个过程之后的设置缓存步骤中,我们对每个商品缓存设置不同的失效时间,a是24小时,b是24.5小时,具体实现我们可以在设置失效时间的时候在24小时的基础上加上一个随机值,这样就不会有大批量的热点数据的缓存几乎同时过期,避免了缓存击穿给数据库造成巨大压力。

3.缓存穿透

场景:缓存穿透意思就是将缓存和数据库都被打穿,举例:假如有一个黑客伪造了一个商品id请求后端,一秒钟发个几十万次请求来攻击网站,此时缓存中肯定是没有这个数据的缓存,那么需要查询数据库,而数据库中也没有,大量无意义的请求查询数据库,无疑给数据库带来了巨大压力。

解决:当请求第一次穿透到数据库后查询到的数据为空时,对这个数据缓存设置一个空缓存比如一个空字符串,同时设置一个几分钟的超时时间,避免这个空缓存长时间占用内存空间,那么下一次请求过来就会直接查询这个空缓存,不会击穿到数据库,同时在查询到空缓存后也设置超时时间,避免缓存过期后请求又打到数据库,更大程度上的提升性能。如果伪造了大量且不同的ID来攻击呢?可以使用布隆过滤器,布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放 ,在这个大量不同且不存在的请求打进来基本就会被布隆过滤器过滤掉,存在才从缓存中查找,就避免了被大量且不同的ID请求攻击。

4.突发性热点缓存重建

场景:在某种特点情况下,举个例子,有一个冷门商品,这个商品因为是冷门商品,大概率此前有过的缓存已经失效了,假如此时突然有一个大V对这个冷门商品进行带货,主播喊出上链接的一瞬间,数万人同时点进这个商品页面,而此时缓存中是没有这个商品的缓存的,那么肯定是有很多的请求将会同时直接查询数据库,进而设置缓存,虽然是同一商品,但因为是并发请求所以数据库和缓存的设置几乎都是重复的,造成了缓存击穿给数据库带来了巨大压力,也消耗了Redis性能。

解决:我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。 将并行转为串行,在查询数据库并重建缓存的步骤上加一把锁如synchronized,那么请求将会逐个查询数据库并重建缓存,但仅仅如此肯定不够,还是有大量同一数据的请求重复查询数据库并重建缓存,我们采用双重检查锁定机制(DCL ,double check lock)进行优化,我们第一次进行缓存重建后,在第二次请求拿到锁进来的时候擦查询数据库之前,我们再判断一次缓存中是不是为空(在加锁过程之前已有一次缓存是否为空的判断,加这一重判断达成双重检查),不为空再去查询数据库,极大减轻了数据库压力,但还有一点问题,synchronized是JVM进程级别的锁,那么在集群环境下,synchronized就只能锁住一台机器,当大量突发性热点请求被分发到数台机器上,意味着每一台机器都要至少进行一次这个并发重建操作,但这个问题我认为无关紧要,紧要的是,假如这个大主播带货的这个商品(ID:100)在并发重建的过程中,另一个大V主播也在带另一个商品(ID:200)的货,那么id为200的这个商品也会等待id为100商品并发重建,显然这两个商品并不是相关的,并不需要互斥,那么其实我们需要对每一个商品进行加锁,这个可以通过分布式锁进行实现,具体可以借用Redisson#getLock方式来获取锁,使用完后对锁进行释放。大致逻辑如下:

      //查询缓存并判断
      data=getCache()
      if(data!=null){
         return data;
      }
      RLock lock = redissonClient.getLock("lock");
      lock.lock();
      try{
         //查询缓存并判断
        data=getCache()
        if(data!=null){
          return data;
       }
       //查询数据库
       data=dao.getData();
       //设置缓存
      }finally{
          lock.unlock();
      }

5.缓存数据库数据不一致

场景:在高并发场景下,同时操作数据库与缓存会存在数据不一致性问题 ,一般存在两种情况,如下 :
图一:双写不一致
在这里插入图片描述
如图一,可能会出现线程1在写数据库(id:1,name:yiyi)->更新缓存(id:1,name:“yiyi”)的期间,线程2已经完成了写数据库(id:1,name:erer)以及更新缓存(id:1,name:“erer”)的操作,接着线程1才更新缓存,那么此时缓存中数据的name为"yiyi"而数据库中为"erer",从而导致数据库与缓存不一致。
图二:读写并发不一致
在这里插入图片描述
如图二,在线程3查询数据库(id:1,stock:10)->更新缓存(id:1,stock:10)的期间,线程2已经完成了写数据库(id:1,stock:6)以及更新(缓存(id:1,stock:6)或者删除缓存的操作,接着线程2才更新缓存,那么此时查到的数据为stock=10,缓存中也是stock=10的数据,但数据库中的数据却是stock=6的数据,从而导致数据库与缓存不一致。
解决:我们可以写操作和读操作前分别加一把分布式锁,那么两个线程就会由并行改为串行,就不会出现不一致的情况,但此处更推荐读写锁的方式来解决,具体可以用Redisson#getReadWriteLock方式来获取锁,使用完后对锁进行释放。在写数据库步骤前加一把写锁,在查数据库步骤前加一把写锁,读写锁通过锁的模式(mode)来判断是否要等待,如果判断当前这把锁为写锁,那么读锁需要等待写锁释放才能获取到,否则就不需要等待。
读锁大致逻辑如下

      //查询缓存并判断
      data=getCache()
      if(data!=null){
         return data;
      }
      RLock lock = redissonClient.getLock("lock");
      lock.lock();
      try{
         //查询缓存并判断
        data=getCache()
        if(data!=null){
          return data;
       }
        RReadWriteLock readWriteLock =  redissonClient.getReadWriteLock("readWriteLock");
        //读之前加读锁,读锁的作用就是等待该lock释放写锁以后再读
        RLock readLock = readWriteLock.readLock();
        readLock.lock()
       try{
         //查询数据库
         data=dao.getData();
          //设置缓存
       }finally{
         readLock.unlock()
       }

      }finally{
          lock.unlock();
      }

写锁逻辑大致如下

        RReadWriteLock readWriteLock =  redissonClient.getReadWriteLock("readWriteLock");
        //读之前加读锁,读锁的作用就是等待该lock释放写锁以后再读
        RLock writeLock= readWriteLock.writeLock();
        writeLock.lock()
       try{
          //更新数据库
         data=dao.update();
          //设置缓存
       }finally{
         writeLock.unlock()
       }

6.缓存雪崩

场景:在某种高并发场景下可能会出现缓存雪崩问题,比如某大V发布了一条消息,瞬间来了几百万粉丝查看他的消息,下一时刻又会又大量粉丝查询,因为是同一条消息,基本会打在Redis某个Master节点上,这种请求量基本会把缓存打挂掉,而缓存挂掉之后,就只能数据库来承载查询压力,基本不久之后就会导致整个系统崩溃,这就是所谓的缓存雪崩

解决:是否可以通过多级缓存来解决这种情况,我们定义一个JVM进程级别的缓存,如定义一个concurrentHashMap的全局变量,在第一次设置缓存时或者更新缓存时同步更新一下map,在下一次查询缓存的时候先查询JVM进程级别的缓存,JVM进程级别缓存可以抗很高的并发且可以分摊到不同机器的web应用。思想如下图:
在这里插入图片描述

还可以依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。比如服务降级,我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是非核心数据(例如电商商品属性,用户信息等)时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐