Redis 缓存问题

何为缓存?

缓存(Cache)就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地。

举个例子,在数据交换的过程中主要存在着三个角色,顾客、外卖柜、外卖员。顾客拿外卖的形式有两种:

  1. 外卖员直接把外卖交给顾客
  2. 外卖员把外卖放于外卖柜,顾客从外卖柜里拿

在这俩个场景中,外卖柜就想当于数据交换的缓冲区,我们把==外卖==放到外卖柜中就是我们讲地缓存操作。

知道了什么是缓存后,我们聊一聊为什么需要缓存?

想象一个场景:

“外卖员到了你家楼下,但你沉迷在’黑神话‘中打boss,没有理会外卖员。导致外卖员其他外卖面临超时风险,于是外卖员直接自爆,你的外卖也无了!”。

有了外卖柜后,外卖员可以把外卖放入其中,然后给用户发一个提取码即可。二者不再是点对点地交互,而是有了一个纽带,也就是我们所说地==缓存区==。

在这个场景中,服务端是数据库,它只需要把数据交给redis,而客户端从redis中获取数据即可。这样的好处在于:

  1. 对于某些经常性访问、修改频率低的数据,比如“个人信息、导航栏信息、物品的介绍信息”。
  2. 用户从Redis这个外卖柜里直接获取数据,减轻数据库的高频请求压力。
  3. 其次,Redis将数据直接存储在内存中,内存的读写速度远远快于磁盘,提高用户的访问速度。

需要注意的是,Redis将数据存于内存中,也意味着一旦Redis宕机,数据直接无了。不过不用担心,也有一些解决方案:主从复制哨兵(Sentinel)模式持久化机制Redis集群等。

看到这里,是不是认为Redis缓存模式没有什么太大问题,将数据放入Redis后等着用户来取似乎就完事大吉了。温馨提示我们在对redis使用的过程中还需要注意以下缓存相关的问题。

一、缓存穿透

缓存穿透指的是大量恶意或者非法请求避开了对缓存的调用,直接访问数据库或者后端服务。

首先给一张用户访问地流程图:

简单解释以下,当用户发起请求时,如果Redis有对应数据(也就是存在key),那么直接返回;如果没有对应的Key,那么访问数据库返回数据以及存入Redis中,以便下一次请求。

Redis是一种高性能的键值型(key-value)数据库。

而缓存穿透则是没有了对==Redis==这一步操作,通过访问一个不可能存在的Key,绕过了从==Redis==中获取数据,将请求全部打到MySQL。又因为MySQL也没有该Key对应的数据,导致之后的全部请求依然会打向MySQL

举个例子:

  • 通过id=-1查询商品信息(这里假设key也为商品id
String shopStr = stringRedisTemplate.opsForValue().get(key);
  • 因为key不存在,返回的shopStrnull,大量请求会访问数据库
Shop shopInfo = shopMapper.selectById(id);
  • 这里的shopInfo也一定为null;既不会返回数据,也不会缓存数据到==Redis==中,陷入恶性循环。

那有什么解决之道呢?

缓存NULL值

缓存穿透归根结底是因为MySQL中没有该id=-1对应地数据,简单的解决方案就是“哪怕这个key对应地数据在数据库中不存在,我们也将""存到==Redis==中”。下次用户再访问,在==Redis==中也能找到对应地Key而不会将请求打到MySQL

不需要担心数据冗杂,Redis支持设置键(key)的过期时间,并且具备数据清理机制。

布隆过滤器预检查

还有一种解决方案是使用布隆过滤器(BloomFilter), 一种概率型数据结构。

实现思路很简单,使用布隆过滤器对请求的参数或者key进行预检查,如果请求被布隆过滤器判断为不存在,那么后续操作都不需要,从而减少了缓存穿透的风险。

不过判断某key存在后,redis、mysql中可能依然没有其对应数据,这时候我们需要做后期处理,比如说设定一个较短的过期时间来缓存空值(其实也会用到第一个方案)。还有一个弊端是布隆过滤器删除数据非常困难。

基于布隆过滤器地流程如图所示:

因为布隆过滤器是基于哈希函数实现,所以还是存在误判。它可能会误判某些不存在的数据为存在(假阳性),但它永远不会误判不存在的数据为存在(假阴性)。 使用布隆过滤器地好处在于:1、判断过程非常快速,因为它只涉及哈希函数和位数组的操作。2、布隆过滤器使用固定的内存空间,无论存储多少数据,其内存占用都很小。

数据校验

对于id = -1的这种‘明显’错误,我们应该在业务之前就做一个参数校验。其次在字段设计上,修改id的自增策略,通过❄算法(SnowFlake)实现的分布式Id作为数据主键,防止用户猜测出id的规律。

SnowFlake算法是由Twitter开源的==分布式唯一ID==生成算法。 这里推荐一个优化雪花算法的雪花漂移算法

二、缓存击穿

缓存击穿问题也叫热点Key问题,指的是被高并发访问且缓存重建业务较复杂的key突然失效了,无数的请求会在==瞬间==给数据库带来巨大的冲击。

形象来说,就是有一个商店很受欢迎(==高并发访问==);但是有一天🔑坏了(key失效),并且修复钥匙花费的时间较长(==缓存重建==),于是客人被拒之门外(==无法获取缓存==)。他们只好一股脑地去找老板开门(==请求打到数据库==),但是由于人太多,老板的家也被挤爆了(==数据库无法承受这些压力==)。

这里先对缓存穿透做一个区分避免混淆:

  • 缓存穿透是访问一个不存在的key而绕过Redis,且数据库也无法通过该key获取数据,重构缓存。
  • 缓存击穿是Redis中的key失效了,但是数据库中有数据,只不过访问量过于庞大,数据库炸了。

我们可以看出解决缓存击穿的关键在于不能够快速、有效地重建缓存。

想想一个场景,钥匙坏了,但是商店旁边有一个专门修钥匙的人,5s不到你的钥匙就换了新,也就没有了后续问题。

不过5s对于现实很短,但对于网络服务来说很长。而且重建一个业务复杂的key消耗的时间无法避免。因此在时间上优化不太可能,所以我们解决出这个问题的人就好了(dog)。

我们在顾客与老板的家之间再加上一道“门”,每次只让一个顾客进来,然后带去修钥匙,又让其他顾客在店门口先休息,稍后老板再去开门。

这里的门,也就是我们常说的==加锁==。让线程一个一个来,其他线程则休眠、重试,等待缓存重建完成、再获取缓存。

本文主要讲的是Redis,所以解决方案基于Redis实现的分布式锁-互斥锁来实现。

互斥锁

何为互斥锁?互斥锁(Mutex)是一种常见的同步机制,用于确保在多线程环境中,对于共享资源的互斥访问。它的优点在于保证了一次只有一个线程可以访问特定的代码段,从而避免了数据竞争和一致性问题。

这里贴一张黑马讲解 Redis 的相关图解:

解释一下:当线程1访问,热点key失效,尝试获取锁成功,那么线程1就会执行缓存重建逻辑。当线程2访问,热点key也失效,由于线程1拿到了锁,所以线程2尝试获取锁失败,那么线程2开始休眠、重新查询缓存,直到线程1释放锁且缓存重建成功,此时线程2就能命中缓存,返回数据。

具体实现的话,基于redissetnx方法,即如果key不存在时,才添加该 key的值。Java代码如下:

   /**
    * 尝试获取锁
    */
   public boolean tryLock(String key,String value){
       //设置超时时间redisTTL防止死锁
       Boolean lock = stringRedisTemplate
               .opsForValue()
               .setIfAbsent(key,value,redisTTL,TimeUnit.SECONDS);
       return BooleanUtil.isTrue(lock);
   }
   /**
    * 释放锁
    */
   public void unlock(String key){
       stringRedisTemplate.delete(key);
   }

注意设置一个锁超时释放时间,防止其他情况导致死锁问题。 补充:Redis 是一个单线程的应用程序。

这样虽然能够减轻服务端压力,但在线程等待时间里用户什么都看不到(这让前端很难办)。我认为好的方案应该是返回允许的旧数据+缓存重建+给用户一个友好提示。

逻辑过期

逻辑过期是指的是不给热点KEY设置过期时间,而是手动给热点KEY设置逻辑过期值(时间戳),这样除了Redis集群宕机,否则的话都可以获取到热点数据。获取热点数据后,我们再根据逻辑过期值来判断数据是否失效。

如果失效了,就又需要缓存重建,同时也会伴随着缓存击穿问题。这时可以采用上述互斥锁的方法,获取锁失败的线程也不用等待(释放性能),而是直接返回==旧数据+提示==;获取锁成功后,开启一个==独立线程==去执行缓存重建任务,主线程返回==旧数据+提示==即可。

具体执行流程:

三、缓存雪崩

缓存雪崩是指在高并发的系统中,大量的缓存数据同时过期Redis宕机,导致大量的请求几乎同时直接打到后端数据库或服务上,从而对后端系统造成巨大压力,甚至导致服务崩溃的现象。

对于Redis宕机的解决方法在前文提“数据持久性”时有提过(主要是运维人员的工作)。

大致过程如图:

对于大量缓存数据==同时过期==,我们如何去解决什么呢?很简单,不要让这些key在同一时间段过期就好了;以及不要把鸡蛋放在同一个篮子里。

随机 TTL 值

我们在设计keyTTL时采用基本缓存时间+随机时间

long keyTTL = redisTTL + randomNumbers(0, 6);

这里假设单位为MinutesredisTTL = 10,这样我们每一次设置的缓存数据过期时间就可能为10,11,12,13,14,15,不会导致所有缓存数据同时失效。

多级缓存

多级缓存地实现比较复杂,这里直接贴图:

多级缓存可以避免数据同时失效而将请求全部打到数据库的问题,但是,不管是浏览器还是Nginx,它们都有其适合存储地数据对象。

浏览器本地缓存一般适用于静态资源如图片、CSS、JavaScript文件等;Nginx本地缓存适用于可以被快速访问的资源,如图片和静态HTML页面;Tomcat堆缓存适用于存储会话数据和热点数据;JVM进程缓存适用于快速访问和处理频繁使用的数据。Redis缓存适用于跨多个应用实例共享的数据。