【Redis】Redis高级:缓存技术与缓存常见问题

Redis高级:缓存技术与缓存常见问题

1 缓存概述

什么是缓存

举个例子:越野车,山地自行车,都拥有"避震器",防止车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样;

同样,实际开发中,系统也需要"避震器",防止过高的数据访问量猛冲系统,导致其操作线程无法及时处理信息而瘫痪,这在实际开发中对企业讲对产品口碑,用户评价都是致命的,所以企业非常重视缓存技术;

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

为什么要使用缓存

因为缓存速度快,好用

缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,对于那些频繁查询而且对查询速度要求较高的数据,如果我们使用缓存,不仅可以大大降低用户访问并发量带来的服务器读写压力,而且可以给用户较好的体验

实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;

但是缓存也会增加代码复杂度和运维的成本,当我们使用缓存之后,就可能需要通过代码去维护缓存与数据库之间的数据一致性,增加了代码复杂度,而且缓存数据量大了之后,一台服务器往往是不够的,这时候就需要集群部署,增加了运维成本

【Redis】Redis高级:缓存技术与缓存常见问题_第1张图片

如何使用缓存

实际的web应用中,会构筑多级缓存来使系统运行速度进一步提升,例如本地缓存与redis中的缓存并发使用。web应用中主要涉及的缓存有以下几种:

浏览器缓存:主要是存在于浏览器端的缓存,缓存的数据包括页面、图片等静态资源。浏览器缓存未命中则会去应用层缓存中寻找

应用层缓存:主要是tomcat本地缓存,包括map集合中的数据,或者是Redis集合中的数据。应用层缓存未命中则会去数据库缓存中寻找

数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中,数据库缓存未命中则会去CPU缓存或磁盘缓存中去寻找,如果仍然未命中就只能去访问磁盘了。

CPU缓存:当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存

【Redis】Redis高级:缓存技术与缓存常见问题_第2张图片

作为开发人员,我们主要需要去维护的是应用层缓存,而实现应用层缓存中常用的技术就是redis。当客户端发来数据查询的请求时,会首先去redis中查看是否有数据,如果有则直接返回,如果没有再去查询数据库,并将查询后的结果保存在redis中,这样下一次查询相同数据时就可以直接去redis中获取而不用再访问数据库了。


2 缓存更新策略

缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新(这种情况下叫为淘汰更合适)。

除了上述情况之后,缓存还需要保持与数据库数据的一致性,因为一般情况下,只有查询数据的操作是走缓存的,如果是增删改操作还是会直接访问数据库,这样就出现了一个问题:当数据库的数据更新之后,缓存里的数据并没有得到更新,用户查询到的仍然是更新前的旧数据,这样就出现了缓存与数据库中数据不一致的现象,这种情况下我们也需要对缓存进行更新。

缓存更新有以下几种常见策略:

内存淘汰:这种方式由redis自动进行,当redis内存达到设定的max-memery时,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式),这种方法的优点是维护成本低,因为完全由redis自动进行,我们无需编写任何代码进行维护,缺点是很难保证数据的一致性,我们只能被动等待缓存被淘汰,至于缓存什么时候被淘汰,哪些缓存被淘汰都不在我们控制的范围内,甚至当内存一直很充足时,缓存会一直得不到更新,数据甚至连最终一致性都做不到

超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便腾出内存空间来继续使用缓存。这种方式的优点在于可以保证缓存删除的时间和范围是在我们可以控制的范围内的,缺点是在redis未过期的时间内还是无法保证数据的一致性。

主动更新:我们可以通过代码去主动修改或删除缓存中的数据。这种方式能保证数据库和缓存之间数据的一致性,但是加大了代码编写的复杂度和维护成本

【Redis】Redis高级:缓存技术与缓存常见问题_第3张图片

我们需要根据不同的业务场景去使用不同的缓存更新策略。

  • 对于比较稳定,对于数据一致性要求比较低的数据,我们可以使用内存淘汰的策略
  • 对于经常变动,对于数据一致性要求比较高的数据,我们可以使用主动更新的策略
  • 不论是比较稳定的数据还是经常变动的数据,我们最好都为其设计一个超时时间作为兜底策略

3 数据一致性问题

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,这个问题我们在上面已经聊过了,一般我们都会通过主动更新缓存的方式来维护缓存与数据库数据的一致性,而主动更新方式又分为以下三种实现手段:

  • Cache Aside Pattern :通过手动编码,在对数据库进行更新操作后对缓存也进行更新,也称之为双写方案
  • Read/Write Through Pattern : 将缓存和数据库整合为一个服务,由该服务去维护数据库与缓存的一致性,调用者只需要调用该服务,无需关心缓存的一致性问题,但是这种方式的开发成本比较高。
  • Write Behind Caching Pattern :调用者只操作缓存,无论是增删查改都只对缓存进行操作,然后额外开辟几条线程去去异步处理数据库,实现数据上的最终一致,但是异步处理的时效性无法得到保证,数据可能在短期内仍然出现不一致的情况,另外如果Redis宕机的情况,那么还未被处理的变更就会丢失,故此方案在一致性和可靠性上都会存在一定问题。

【Redis】Redis高级:缓存技术与缓存常见问题_第4张图片

企业中一般使用方案一来保证数据的一致性,虽然需要通过额外的代码量去维护,但是却能很好的保证数据的一致性

在使用方案一操作缓存和数据库时,还有三个问题需要考虑:

1 当数据库更新后,对缓存中的旧数据时直接删除还是进行更新?

  • 更新缓存:如果我们每次更新数据库之后都去更新缓存,可能会出现一个问题,就是我们进行了多次更新,但是这期间根本没有用户来访问,这种情况下实际上只有最后一次加载到redis中的缓存是有效的,也就产生了大量无用的写操作
  • 删除缓存:更新数据库之后将缓存失效,等到下一次查询时再将查询出来的数据保存在缓存之中,相当于延迟加载,这种方式相对于更新缓存来说更好一些

2 如何保证缓存与数据库的操作的同时成功或失败?

  • 单体系统,将缓存与数据库操作作为一个事务使用代码进行维护
  • 分布式系统,利用TCC等分布式事务方案

3 先操作缓存还是先操作数据库?

在多线程并发环境下,先操作数据库还是先操作缓存也是一个需要考虑的问题。举个例子,假设现在数据库中有一条记录为10,而redis中也有一条关于该记录的缓存,目前也是10,而现在线程一发起了一次针对该数据的修改请求,将10改为20,而此时线程而发起了对该数据的查询请求,针对这种情况下,我们来分析一下先删缓存和先操作数据库可能会出现的并发问题:

  • 先删除缓存,再操作数据库

    假设一种情况:线程1执行更新操作,先将缓存中的数据删除,然后执行更新数据库操作,希望将10修改为20,由于更新操作需要花费较长的时间,在线程1还未执行或者未执行完更新操作时,线程二执行了查询操作,由于缓存中的数据被线程1删除了,导致缓存并未命中,于是线程1会去查询数据库,假设此时线程1仍未执行完更新操作,那么线程2查询到的数据仍然是10,接着线程就会将10写入缓存,这样即使数据库中的数据已经被更新为20了,后续的查询操作查询到的数据仍然为10,就出现了数据不一致问题。

【Redis】Redis高级:缓存技术与缓存常见问题_第5张图片

分析完了可能出现的情况,我们再来思考一下这种情况出现的概率高不高,首先更新数据库是一个比较耗时间的操作,如果业务逻辑复杂,涉及到多表或多个服务之间的更新操作更加消耗时间,而查询缓存、查询数据库、写入缓存都是比较快的操作,线程2完全有可能在线程1并未执行完更新操作之前就跑完了业务逻辑,由此看来,先删除缓存再操作数据库比较容易出现数据不一致的现象。

  • 先操作数据库,再删除缓存

    再假设一种情况,假设此时redis中的缓存刚好失效(不用管缓存为啥失效了,反正它就是失效了,不失效故事没办法编下去),线程1查询缓存,未命中去查询数据库,查询到了10,正准备写入缓存时,线程2来了,它对数据库进行了更新操作,将10更新成了20,然后删除缓存(此时并没有缓存,因此删了等于没删,但是基本流程还是要走一遍的),线程2执行完毕。然后线程1继续执行,将10写入缓存。这样就出现了数据不一致的情况。

【Redis】Redis高级:缓存技术与缓存常见问题_第6张图片

但是上述情况发生的概率极小,首先上述情况发生的前提是线程一来访问前缓存就已经失效了,但是实际上缓存失效的情况还是比较少的,其次由于线程1执行的查询缓存、查询数据库、写入缓存都是比较快的操作,而线程2执行的更新数据库是比较慢的操作,很有可能线程2的更新操作还没执行完,线程1的整个流程就已经跑完了。当然这种情况虽然概率低,但是仍然是有可能发生的,如果担心因为运气不好碰上了,我们也可以通过给缓存设置一个超时时间来兜底


4 缓存穿透问题

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。这个问题乍一看好像没什么,但是实际上可能会有人在短时间内大量访问数据库大量访问这些根本不存在的数据,对数据库进行恶意攻击,造成数据库压力突增,增加数据库崩溃的风险。

针对缓存穿透问题,常见的解决方案有以下两种:

  • 缓存空对象

    当一条数据在缓存中或数据库中都不存在但是却被访问了时,我们可以在将这条数据以空值的形式保存在redis中,当这条数据下一次被访问时,就可以命中缓存并直接返回,继而避免了对数据库的再次访问。这种实现方式的优点在于实现简单且维护方便,但是存储这些空对象带来了额外的内存消耗,为了解决这个问题,我们可以为这个空对象设置一个较短的失效时间。

  • 布隆过滤

    布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组关联数据库中的数据,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis或者数据库,如果布隆过滤器判断这个数据不存在,则直接返回,不允许该请求访问redis或数据库

【Redis】Redis高级:缓存技术与缓存常见问题_第7张图片

这种方式的优点在于节约内存空间,缺点在于实现复杂,且哈希思想存在误判的可能,因为只要是哈希思想,就可能存在哈希冲突。布隆过滤认为存在的数据实际上不一定存在,但是它认为不存在的数据是一定不存在的

除了以上两种方式以外,解决缓存穿透还可以通过以下手段:

  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

5 缓存雪崩问题

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

【Redis】Redis高级:缓存技术与缓存常见问题_第8张图片

针对缓存雪崩主要有以下几种解决方案:

  • 给不同的Key的TTL添加随机值

    一般在做缓存预热时,可能会提前将数据库中的数据批量导入到缓存中,由于是批量导入的,所以这些 key 的 TTL 是一样的,这就很有可能导致这些 key 在未来的某一时刻一起过期,从而引发缓存雪崩问题。为了解决这个问题,我们可以在设置 TTL 时,在 TTL 后面追加一个随机数,比如 TTL 设置的 30 分钟,我们在30 的基础上加上一个 1~5之间的随机数,那么这些 key 的过期时间就会在 30 ~ 35 之间,这样就可以将 key 的过期时间分散开来,而不是一起失效。

  • 利用Redis集群提高服务的可用性

    利用 Redis 的哨兵机制,Redis 哨兵机制可以实现服务的监控,比如在一个主从模式下的 Redis 集群,当主机宕机时,哨兵就会从从机中选出一个来替代主机,这样就可以确保 Redis 一直对外提供服务。另外,主从模式还可以实现数据的同步,当主机宕机,从机上的数据也不会丢失。

  • 给缓存业务添加降级限流策略

    • 限流:当redis服务宕机时,对一定时间内允许的用户访问量进行限制
    • 降级:当redis服务宕机时,针对用户请求进行快速失败或拒绝服务,牺牲系统的部分可用性保证数据库的安全
  • 给业务添加多级缓存

    可以先在反向代理服务器 Nginx 中做缓存,在 Nginx 中未命中缓存时,再去 Redis 中查询。


6 缓存击穿问题

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击,这种问题一般会在秒杀或抢购等场景中出现。

打个比方,假设现在有多个线程同时访问一条数据,而这条数据现在在缓存中是不存在的,假设线程1在查询缓存发现并未命中之后,准备去查询数据库并将这个数据重新加载到缓存,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是在线程1还没有完成加载缓存的整个流程时,后续的线程2,线程3,线程4也都来访问这条数据, 由于这些线程都不能从缓存中查询到数据,因此它们会在同一时间去访问数据库,同时的去执行数据库代码,加大数据库压力,而且在重建业务比较复杂的情况下,例如数据涉及到多表或者多个服务间调用的情况下,一次业务重建需要花费很长时间,再加上巨大的并发量,数据库很有可能就会崩溃

【Redis】Redis高级:缓存技术与缓存常见问题_第9张图片

针对缓存击穿问题,常见的解决方案有两种:

1 互斥锁

假设线程 1 查询缓存未命中,那么线程1就需要进行缓存重建工作,为了避免其他线程重复线程1的工作,在线程1执行缓存重建工作之前必须要先获取一把互斥锁,只有获取锁成功的线程才能够重建缓存数据。重建完成后,线程1就会将数据写入到缓存中,并将锁释放。如果在线程 1 将数据写入缓存之前,其他线程涌入,这个时候,其他线程查询缓存依然是未命中的,那么这些线程为了重建缓存,也必须先获取到互斥锁,但是,由于此时线程1未释放锁,所以其他线程就会获取锁失败,一旦获取锁失败,一般程序处理是让线程休眠一会儿,然后再重试(包括查询缓存以及获取互斥锁),直到线程1或者其他线程完成缓存数据重建,就能命中缓存了,但是如果线程 1 执行缓存重建时间过长,就会导致其他线程一直处于阻塞等待重试的状态,效率过低。而且在业务比较复杂的情况下,多个业务同时重建多个缓存很有可能出现死锁问题

【Redis】Redis高级:缓存技术与缓存常见问题_第10张图片

2 设置逻辑过期

当我们在向 Redis 中存储数据时,不再为 key 设置过期时间(TTL),而是在 value 中额外添加一个逻辑上的过期时间(一般是在当前时间上加上存活时间),这个 key 一旦存入到 Redis 中,就不会因为超时问题被Redis剔除。此时这个数据是否过期就需要我们通过代码来维护,当当前时间已经超过了逻辑上设置的过期时间,那么我们就可以认为这条数据已经过期了,此时我们要做的就应该是通过Java代码查询数据库并更新缓存中的数据。

假设线程 1 在查询缓存时发现逻辑时间已经过期,为了避免出现多个线程重建缓存,线程1在执行业务重建时还是会去获取互斥锁,但是与第一种解决方案不同的是,一旦线程1获取互斥锁成功,它就会开启一个独立线程,由独立线程去查询数据库重建缓存数据,以及写入缓存重置逻辑过期时间等操作,一旦完成操作,独立线程就会将互斥锁释放掉,而线程 1 在开启独立线程后,会直接将缓存中已经存在的过期数据返回。而在独立线程释放锁之前,缓存中的数据都是过期数据。当其他线程在此之前涌入程序时,去查询缓存获取到的依旧是逻辑时间已经过期的数据,当其他线程发现获取到的是过期数据时,就会试图获取互斥锁,但是此时由于独立线程还未释放锁,所以获取锁的操作会失败,一旦失败,这些线程不会再继续重试,而是直接将查询到的旧数据返回。只有当独立线程执行结束,其他线程才会从缓存中获取到最新的数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

【Redis】Redis高级:缓存技术与缓存常见问题_第11张图片

接下来我们对以上两种方案进行对比:

互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于线程之间只能串行执行,性能肯定受到影响,而且可能会出现死锁问题。因此互斥锁方案侧重的是数据的一致性,牺牲了服务的部分可用性

逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,而且代码实现起来麻烦。因此逻辑过期方案侧重的是服务的可用性,牺牲了数据的一致性,只能保证数据的最终一致

这两种方案没有孰优孰劣之分,我们需要根据不同的场景采用不同的解决方案

你可能感兴趣的:(#,Redis,缓存,redis,数据库)