「每日知识点」面试之缓存雪崩、穿透、预热、更新、降级等问题

点击上方"java全栈技术"关注,每天学习一个java知识点

「每日知识点」面试之缓存雪崩、穿透、预热、更新、降级等问题

前面文章讲过

这次给大家整理一篇Redis经常被问到的问题:缓存穿透、缓存雪崩、缓存预热、缓存更新、缓存降级等概念及简单解决方案。

一、缓存穿透

「每日知识点」面试之缓存雪崩、穿透、预热、更新、降级等问题

缓存穿透是指用户查询数据库没有的数据,缓存中自然也不会有。这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求绕过缓存直接查数据库,缓存形同虚设,这也是经常提的缓存命中率问题。

有很多种方法可以有效地解决缓存穿透问题,最长见的有空对象和布隆过滤器两种解决方案。

空对象是首选方案,简单直接,碰到查询结果为空的键,放一个空值在缓存中,下次再访问就立刻知道这个键无效,不用发出SQL了。

「每日知识点」面试之缓存雪崩、穿透、预热、更新、降级等问题

但存在如下问题:

  1. 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
  2. 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

对于第一点,我还建议空值放在另外的缓存空间中,不宜与正常值共用空间,否则当空间不足时,缓存系统的LRU算法可能会先剔除正常值,再剔除空值——这个漏洞可能会受到攻击。

对于第二点,如果是Redis缓存,更新数据后直接在Redis中清除即可;如果是本地缓存,就需要用消息来通知其他机器清除各自的本地缓存了。

布隆过滤器。将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率(错误率可调)和删除困难。

二、缓存雪崩

可以理解为:由于原有缓存失效(过期),新缓存未到期间(如:采用了相同的缓存过期时间策略,在同一时刻出现大面积的缓存过期),所有原本应该访问缓存的请求都去查询数据库,而对数据库CPU和内存造成巨大压力,甚至造成数据库宕机,从而引起一系列连锁反应,造成整个系统崩溃。

缓存正常从Redis中获取,示意图如下:

「每日知识点」面试之缓存雪崩、穿透、预热、更新、降级等问题

缓存失效瞬间示意图如下:

「每日知识点」面试之缓存雪崩、穿透、预热、更新、降级等问题

解决方案

1:在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。

2:缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件

3:做二级缓存,A1为原始缓存,A2为拷贝缓存,A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期

以下简单介绍两种实现方式的伪代码:

(1)碰到这种情况,一般并发量不是特别多的时候,使用最多的解决方案是加锁排队,伪代码如下:

「每日知识点」面试之缓存雪崩、穿透、预热、更新、降级等问题

加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法!

注意:加锁排队的解决方式分布式环境的并发问题,有可能还要解决分布式锁的问题;线程还会被阻塞,用户体验很差!因此,在真正的高并发场景下很少使用!

(2)还有一个解决方案是:给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存,实例伪代码如下:

「每日知识点」面试之缓存雪崩、穿透、预热、更新、降级等问题

解释说明:

1、缓存标记:记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际key的缓存;

2、缓存数据:它的过期时间比缓存标记的时间延长1倍,例:标记缓存时间30分钟,数据缓存设置为60分钟。 这样,当缓存标记key过期后,实际缓存还能把旧数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存。

关于缓存崩溃的解决方法,这里提出了三种方案:使用锁或队列、设置过期标志更新缓存、为key设置不同的缓存失效时间,还有一个被称为“二级缓存”的解决方法,有兴趣的读者可以自行研究。

三、缓存预热

缓存预热这个应该是一个比较常见的概念。新的缓存系统没有任何缓存数据,在缓存重建数据的过程中,系统性能和数据库负载都不太好,所以最好是在系统上线之前就把要缓存的热点数据加载到缓存中,这种缓存预加载手段就是缓存预热。

解决思路:

单机web系统情况下比较简单。

  1. 直接写个缓存刷新页面,上线时手工操作下。
  2. 数据量不大,可以在WEB系统启动的时候加载。
  3. 搞个定时器定时刷新缓存,或者由用户触发都行。

分布式缓存系统,如Memcached,Redis,比如缓存系统比较大,由十几台甚至几十台机器组成,这样预热会复杂一些。

  1. 写个程序去跑。
  2. 单个缓存预热框架。

缓存预热的目标就是在系统上线前,将数据加载到缓存中。

四、缓存更新

因为内存受限于空间缓存只能存储有限的数据,因此我们需要决定在我们的应用场景中,使用何种缓存更新策略,下面介绍几种常见的模式。

Cache-Aside模式

应用负责基于存储读写数据,缓存不直接和存储打交道,应用的行为如下:

  1. 检索缓存,缓存没有命中;
  2. 从数据库加载数据;
  3. 将数据更新至缓存;
  4. 返回结果;

Memcached通常被应用于这种方式,这种模式对于接下来的数据读取将非常快,Cache-Aside也叫做延迟加载,只有需要的数据被缓存,避免不需要的数据占用缓存空间。

这种模式的缺点如下:

  • 每次缓存没命中都增加系统之间的交互,这将会增加响应延迟;
  • 当对应数据库中的数据被更新之后将出现脏数据问题。这个问题可以通过设置过期时间(TTL)来缓解,当时间过期将发生强制更新缓存;
  • 当一个节点坏了之后,新的节点代替旧的节点,这个时候将出现大量的缓存穿透问题;

Write-Though模式

应用将缓存作为主要存储,读写都直接和缓存打交道,缓存负责基于存储进行读写:

  1. 应用基于缓存添加或删除记录;
  2. 缓存同步地将记录写入存储;
  3. 返回;

Write-Though对于所有的写操作都是比较慢的,但是对于读来说很快,用户通常需要容忍写延迟,但是不会出现脏数据。

这种模式的缺点如下:

  • 由于failure或者scaling带来的新增节点的时候,新增节点在下次更新数据之前将没有数据,这个问题可以结合Cache-Aside模式来缓解;
  • 对于很多写入的数据将永远不会读取到,这个问题可以通过设置过期时间解决;

Write-Behind模式

在这种模式下,应用的行为如:

  1. 直接读写缓存;
  2. 写操作通知任务来异步进行更新;

这种模式的缺点如下:

  • 如果在数据被更新到存储之前缓存挂了,则数据将会丢失;
  • 实现起来比Write-Though和Cache-Aside模式更为复杂;

Refresh-Ahead模式

我们可以配置缓存自动在最近访问的数据过期之前更新它们,如果可以准确预测将要访问的数据,Refresh-Ahead模式可以有效地减少读写的延迟。

这种模式的缺点如下:

  • 如果预测数据不准确,则比不做什么更有损性能;

五、缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。

降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。

在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:

  • 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
  • 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
  • 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
  • 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

缓存的目的是提升系统访问速度和增大系统能处理的容量,可谓是抗高并发流量的银弹,而降级是当服务出问题或者影响到核心流程的性能则需要暂时屏蔽掉,待高峰或者问题解决后再打开。

六、总结

这些都是实际项目中可能碰到的一些问题,也是面试的时候经常会被问到的知识点,实际上还有很多各种各样的问题,文中的解决方案,也不可能满足所有的场景。一般正式的业务场景往往要复杂的多,应用场景不同,方法和解决方案也不同,具体解决方案要根据实际情况来确定!


分享到:


相關文章: