1. 首页
  2. >
  3. 数据库技术
  4. >
  5. Redis

如何破解缓存穿透和并发?

缓存是我们现在经常使用的技术之一,对于缓存的使用,看似非常简单,其实却蕴含着很多技巧在里面,这些技巧可以帮助我们最大化地发挥缓存的功效,减少因为缓存的使用导致的线上生产事故,那么这些技巧都有哪些,如何使用呢?下面试着从缓存穿透,缓存并发来开展破解之道。

缓存穿透

流程和危害

  在通常情况下,我们在前台是如何使用缓存的呢?我们来看下面这个流程:


如何破解缓存穿透和并发?

  当系统接收到一个请求时,请求先从缓存中查找数据,查找数据一般得分两种情况:

  • 如果缓存中有数据的话,就直接从缓存中读取数据,然后返回给请求方;
  • 如果缓存中没有,那就从数据库中读取数据,然后再更新到缓存中;

  以上这种方法可以在一定程度上减少数据库的压力,在并发量不高的时候,使用起来是没有问题的。但是当并发较高的时候,其实我是不建议使用缓存过期这个策略的,我更希望缓存一直存在,通过后台系统来更新缓存系统中的数据,达到数据的一致性的目的。

  因为这种方案在并发很高的场景下会存在漏洞,这个漏洞就是缓存穿透。什么是缓存穿透呢(如下图所示),就是查询一个一定不存在的数据,由于需要从数据库中查询,而数据库也没有数据,这时候就无法更新缓存了。这将会导致这个不存在的数据每次请求时都要到数据库去查询,如果有人利用这个漏洞进行恶意攻击的话,将会对数据库产生非常大的压力,严重的话会导致数据库宕机。


如何破解缓存穿透和并发?

解决方案

  那么针对这种缓存穿透,我们有没有什么好的解决方案呢?

  • 方法1:预先设定一个值

  其实有一个巧妙的做法,我们可以在这个不存在KEY预先设定一个值,比如KEY为test,VALUE设定为"null",在返回这个空字符串"null"时候,我们应用就可以认为这是一个不存在的KEY,就可以决定是否需要继续等待还是继续访问,或者干脆放弃这次操作。如果继续等待访问,过一个时间轮询点后,再次请求这个题,如果取到的值不值不为null,就可以认为KEY是有值的,这是避免了穿透到数据库的情况出现,从而可以把大量的类似请求挡在了缓存之中。这种解法就是始终把请求和数据库保持隔离状态。

  • 方法2:使用布隆过滤器

  用布隆过滤器,实际上它是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中,优点是空间效率和查询时间都远远超过一般的算法,缺点就是有一定的误识别率,并且删除很困难。

  布隆过滤器的原理是比较复杂的,代码层面不容易实现,谷歌的Guava工具为我们提供了现成的类库供我们使用,这样不但可以解决我们写布隆过滤器工具的成本,而且使用起来也非常的简单,具体这里不做代码展开了。

缓存并发

流程和危害

  除了缓存穿透的场景还有一个场景,那就是缓存并发,假设这是有多个获取同一条数据的请求到来,从缓存中没有查询数据,这时所有请求都会到数据库中去查询,然后所有请求在反复更新缓存数据,注意更新的是同一条数据。如下图所示:


如何破解缓存穿透和并发?

  这样不仅会增加数据库的压力,还会因为反复更新问题占用Redis资源,这个问题就叫缓存并发问题。

解决方案:

  那我们该如何解决呢?这里我们给出一个解决方案,注意这个方案不包括缓存穿透的场景,我们只是假定能从数据库中查询到数据。如下图所示:


如何破解缓存穿透和并发?

  • 一个查询数据请求从客户端发起,请求会先从缓存中读取数据,然后判断是否能从缓存中读取到,如果读取到数据,就直接返回给客户端,流程结束;
  • 如果没有读取到,那么就在Redis中使用setNX方法设置一个状态位,表示这是一种锁定状态。要设置成功了,表示已经锁定成功后,请求从数据库中读取数据,然后更新缓存,最后再将数据返回给客户端,要是设置没成功的话,表示这个状态已经被其他请求锁定,这个请求等待一段时间会重新发起数据查询,这样就能保证在同一时间只能有一个请求来查询数据库更新缓存,其他请求只能等待重新发起,再次查询后发现缓存中已经有数据了,那么直接返回数据就可以了。

缓存过期

场景和危害

  第三种场景就是缓存过期,如果使用不当不仅会造成缓存穿透,而且还会造成缓存雪崩的效应出现。我们在请求的过程中会不断地往缓存中写数据,这样环境中的数据就会越来越多,但真正被经常访问的数据可能只是其中一小部分,所以在某些需求下,我们没有必要将全部数据都放到缓存中,于是就会设置缓存过期时间。这样一来,不经常访问的数据就会自动过期,而不会占用缓存空间。

  那么我们该如何设置缓存过期时间呢?一般情况下,可能会将缓存的过期时间设置为固定的时间,比如1分钟,5分钟。

  并发很高的时候,可能会出现某一个时间点,用户同时设置了很多的缓存,并且过期时间都一样,当缓存到期时,缓存同时失效,缓存请求全部转发到数据库,这时数据库压力就会瞬间增大,就会造成缓存雪崩现象。

解决方案

  对于缓存雪崩的现象,可以有两种解决方案:

  • 一个是将时间分散开。比如我们可以在原有失效时间的基础上去增加一个随机值,只有1~5分钟的随机,这样每一个缓存的过期时间的重复率就会降低,也就很难引起集体失效的事件了;
  • 第二解决方案就是缓存不过期。我们可以通过后台来更新缓存数据,来避免因为缓存失效造成缓存雪崩,也可以在一定程度上避免并发问题。

  缓存不过期的缺点就是资源浪费,接下来我们来聊聊缓存热点。

缓存热点

应用场景

  可能你会问,我的项目并不是所有数据都需要存放到缓存中,而只是其中一小部分数据会被用户频繁访问,如果我把所有数据都缓存起来,过于浪费资源,而且缓存容量本身也会受限,那么针对这个问题我该怎么处理和解决呢?

  接下来,我们再来聊聊缓存热点问题,我们以一个用户中心的案例入手,每个用户都会首先获取自己的用户信息,然后再进行其他相关的操作。


如何破解缓存穿透和并发?

  有可能会有这样的场景,大量相同用户重复访问同一项目或者同一个用户频繁访问同一模块,也就是说数据库存在的用户数据,可能某一些用户的请求量非常大,有些用户的请求量很小,因为用户的数据量非常大,且受限于缓存空间的大小,然而又不能把全部用户数据都存放在缓存中,只能存放热点用户数据,那我们该如何来判断哪些用户数据是热点数据呢?

  我们以一个访问用户中心数据的案例作为切入点,来说明热点缓存用法。

解决方案

  总体思路就是通过判断数据最新访问时间来做排名,过滤掉不常访问的数据,留下经常访问的数据。


如何破解缓存穿透和并发?

  可以先通过缓存系统做一个排序队列,比如存放1000个用户,系统会根据用户的访问时间更新用户信息的时间,越是最近访问的用户排名越靠前,同时系统会定期过滤掉排名最后的200个用户,然后再从数据库中随机抽出200名用户加入到队列,这样请求每次到达的时候会先从队列中获取用户信息,如果命中就根据userId,再从另一个缓存数据结构中读取用户信息。在Redis中可以zadd方法和zrang方法,来完成序列队列和去200个用户的操作。

缓存解耦

  前面的内容中,我们都是将缓存操作与业务代码耦合在一起。这样做虽然容易实现,但是未来操作的复用性就变得很差。基本上每做一个新业务,都要重新写一次相应的操作,而且维护起来也不容易,我们想修改项目中某一块业务的完成操作,就需要先通读一遍这块业务的代码,了解了业务逻辑后才能进行修改,那如何来解决这个问题呢?

  有一种思路是将缓存操作与业务代码进行解耦,使用这个方案的前提是我们使用MySQL数据库来存取数据,因为需要使用binlog,同时也需要借助阿里的Canal数据同步组件。

  • 用户在应用后台添加配置数据,配置数据存储到数据库中;
  • 同时数据库更新了binlog日志数据;
  • 接着再通过Canal组件来获取最新的binlog日志数据;
  • 然后解析这些数据,并通过事先定义好的新的数据格式,重新生成新的数据,并发送到MQ中。
  • 对于Canal组件我们又增加了限流功能,当日志数据量非常大的时候,我们会根据一定的频率来做读取限制,以此来防止给MQ造成较大的请求和数据库压力的情况出现。
  • 在应用方就需要监听MQ了,接收到数据后,判断这个数据操作是添加修改还是删除,然后根据不同的操作来插入修改和删除Redis中相应的数据。


如何破解缓存穿透和并发?

总结

  现在让我们来回顾一下:

  • 首先是缓存穿透的两种解决方案,我推荐的是采用设值方案,因为布隆过滤器本身存在误判的情况,实现起来也较复杂;
  • 接着讲到了缓存并发问题,我们可以利用Radis的setNX方法来配合解决;
  • 之后讲到了遇到缓存过期的情况,我们该如何避免集体失效问题,并分享了两种解决方案,一种是热点缓存方案,另一种是利用binlog和canal来实现缓存操作和业务分类。

  缓存的使用给我们带来非常多的好处的同时,要充分考虑缓存使用上面潜在的坑。一定要多考虑缓存和数据库数据一致性问题,还要考虑缓存容量限制,以及每次存放到缓存的数据大小等,但实际工作中,我们经常会因为这些问题导致生产事故,造成比较严重的后果。