缓存中的问题

CAP

CAP原则又称CAP定理,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。CAP原则是NOSQL数据库的基石。

分布式系统的CAP理论

  • 一致性(C):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
  • 可用性(A):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
  • 分区容忍性(P):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。

取舍策略

CAP三个特性只能满足其中两个,那么取舍的策略就共有三种:
在这里插入图片描述

  • CA without P:如果不要求P(不允许分区),则C(强一致性)和A(可用性)是可以保证的。但放弃P的同时也就意味着放弃了系统的扩展性,也就是分布式节点受限,没办法部署子节点,这是违背分布式系统设计的初衷的。传统的关系型数据库RDBMS:Oracle、MySQL就是CA。

  • CP without A:如果不要求A(可用),相当于每个请求都需要在服务器之间保持强一致,而P(分区)会导致同步时间无限延长(也就是等待数据同步完才能正常访问服务),一旦发生网络故障或者消息丢失等情况,就要牺牲用户的体验,等待所有数据全部一致了之后再让用户访问系统。设计成CP的系统其实不少,最典型的就是分布式数据库,如Redis、HBase等。对于这些分布式数据库来说,数据的一致性是最基本的要求,因为如果连这个标准都达不到,那么直接采用关系型数据库就好,没必要再浪费资源来部署分布式数据库。

  • AP wihtout C:要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。典型的应用就如某米的抢购手机场景,可能前几秒你浏览商品的时候页面提示是有库存的,当你选择完商品准备下单的时候,系统提示你下单失败,商品已售完。这其实就是先在 A(可用性)方面保证系统可以正常的服务,然后在数据的一致性方面做了些牺牲,虽然多少会影响一些用户体验,但也不至于造成用户购物流程的严重阻塞。

一致性问题

  缓存和数据库一致性问题,存储的数据随着时间可能会发生变化,而缓存中的数据就会不一致。具体能容忍的不一致时间,需要具体业务具体分析,但是通常的业务,都需要做到最终一致性。

一致性分类

强一致性

强一致性可以理解为在任意时刻,所有节点中的数据是一样的。同一时间点,你在节点A中获取到key1的值与在节点B中获取到key1的值应该都是一样的。

弱一致性

弱一致性包含很多种不同的实现,分布式系统中广泛实现的是最终一致性。

最终一致性

所谓最终一致性,是弱一致性的一种特例,保证用户最终能够读取到某操作对系统特定数据的更新。但是随着时间的迁移,不同节点上的同一份数据总是在向趋同的方向变化。也可以简单的理解为在一段时间后,节点间的数据会最终达到一致状态。

一致性问题的两个前提

缓存必须要有过期时间

  为什么必须要有过期时间?首先对于缓存来说,当它的命中率越高的时候,我们的系统性能也就越好。如果某个缓存项没有过期时间,而它命中的概率又很低,这就是在浪费缓存的空间。而如果有了过期时间,且在某个缓存项经常被命中的情况下,我们可以在每次命中的时候都刷新一下它的过期时间,这样也就保证了热点数据会一直在缓存中存在,从而保证了缓存的命中率,提高了系统的性能。
设置过期时间还有一个好处,就是当数据库跟缓存出现数据不一致的情况时,这个可以作为一个最后的兜底手段。也就是说,当数据确实出现不一致的情况时,过期时间可以保证只有在出现不一致的时间点到缓存过期这段时间之内,数据库跟缓存的数据是不一致的,因此也保证了数据的最终一致性。

保证数据库跟缓存的最终一致性即可,不必追求强一致性

  那么为什么不应该追求数据强一致性呢?这个主要是个权衡的问题。数据库跟缓存,以Mysql跟Redis举例,毕竟是两套系统,如果要保证强一致性,势必要引入2PC或Paxos等分布式一致性协议,或者是分布式锁等等,这个在实现上是有难度的,而且一定会对性能有影响。而且如果真的对数据的一致性要求这么高,那引入缓存是否真的有必要呢?直接读写数据库不是更简单吗?那究竟如何做到数据库跟缓存的数据强一致性呢?这是个比较复杂的问题,本文会在最后稍作展开。
  本文主要在保证最终一致性的前提下进行方案讨论。

真实使用的缓存更新策略

Cache Aside Pattern

  这是最常用最常用的pattern了。其具体逻辑如下:

在这里插入图片描述
在这里插入图片描述
  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效(删缓存)。
  注意,我们的更新是先更新数据库,成功后,让缓存失效。那么,这种方式是否可以没有文章前面提到过的那个问题呢?我们可以脑补一下。
  一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而不会像文章开头的那个逻辑产生的问题,后续的查询操作一直都在取老的数据。
  这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。
那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
  但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。
  所以,这也就是Quora上的那个答案里说的,要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,而Paxos太复杂。当然,最好还是为缓存设置上过期时间。

Read/Write Through Pattern

  我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

Read Through

  Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write Through

  Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)
  下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。
在这里插入图片描述

Write Behind Caching Pattern

  Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。所以,基础很重要,我已经不是一次说过基础很重要这事了。
  Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
  但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。
  另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。
  在wikipedia上有一张write back的流程图,基本逻辑如下:
在这里插入图片描述

CAP原则及CA更新策略讨论

  说到数据库和缓存的读写顺序,最经典的方案就是这个所谓的Cache Aside Pattern了。下面简单介绍一下这个方案的思路:
  a. 失效:程序先从缓存中读取数据,如果没有命中,则从数据库中读取,成功之后将数据放到缓存中
  b. 命中:程序先从缓存中读取数据,如果命中,则直接返回
  c. 更新:程序先更新数据库,在删除缓存
  前两步跟数据读取顺序有关,我觉得大家对这样的设计应该都没有异议。读数据的时候当然要优先从缓存中读取,读不到当然要从数据库中读取,然后还要放到缓存中,否则下次请求过来还得从数据库中读取。关键问题在于第三点,也就是数据更新流程,为什么要先更新数据库?为什么之后要删除缓存而不是更新?总共大概有四种可能的选项:

先更新缓存,再更新数据库

  我们都知道不管是操作数据库还是操作缓存,都有失败的可能。如果我们先更新缓存,再更新数据库,假设更新数据库失败了,那数据库中就存的是老数据。当然你可以选择重试更新数据库,那么再极端点,负责更新数据库的机器也宕机了,那么数据库中的数据将一直得不到更新,并且当缓存失效之后,其他机器再从数据库中读到的数据是老数据,然后再放到缓存中,这就导致先前的更新操作被丢失了,因此这么做的隐患是很大的。
  从数据持久化的角度来说,数据库当然要比缓存做的好,我们也应当以数据库中的数据为主,所以需要更新数据的时候我们应当首先更新数据库,而不是缓存。

先更新数据库,再更新缓存

  这里主要有两个问题,首先是并发的问题:假设线程A(或者机器A,道理是一样的)和线程B需要更新同一个数据,A先于B但时间间隔很短,那么就有可能会出现:

  • 线程A更新了数据库
  • 线程B更新了数据库
  • 线程B更新了缓存
  • 线程A更新了缓存
      按理说线程B应该最后更新缓存,但是可能因为网络等原因,导致线程B先于线程A对缓存进行了更新,这就导致缓存中的数据不是最新的。(读到了脏数据)
      第二个问题是,我们不确定要更新的这个缓存项是否会被经常读取,假设每次更新数据库都会导致缓存的更新,有可能数据还没有被读取过就已经再次更新了,这就造成了缓存空间的浪费。

先删除缓存,再更新数据库

  这个方案的问题也是很明显的,假设现在有两个请求,一个是写请求A,一个是读请求B,那么可能出现如下的执行序列:

  • 请求A删除缓存
  • 请求B读取缓存,发现不存在,从数据库中读取到旧值
  • 请求A将新值写入数据库
  • 请求B将旧值写入缓存
      这样就会导致缓存中存的还是旧值,在缓存过期之前都无法读到新值。这个问题在数据库读写分离的情况下会更明显,因为主从同步需要时间,请求B获取到的数据很可能还是旧值,那么写入缓存中的也会是旧值。

先更新数据库,再删除缓存

  终于来到我们最常用的方案了,但是最常用并不是说就一定不会有任何问题,我们依然假设有两个请求,请求A是查询请求,请求B是更新请求,那么可能会出现下述情形:

  • 先前缓存刚好失效
  • 请求A查数据库,得到旧值
  • 请求B更新数据库
  • 请求B删除缓存
  • 请求A将旧值写入缓存
      上述情况确实有可能出现,但是出现的概率可能不高,因为上述情形成立的条件是在读取数据时,缓存刚好失效,并且此时正好又有一个并发的写请求。考虑到数据库上的写操作一般都会比读操作要慢,(这里指的是在写数据库时,数据库一般都会上锁,而普通的查询语句是不会上锁的。当然,复杂的查询语句除外,但是这种语句的占比不会太高)并且联系常见的数据库读写分离的架构,可以合理认为在现实生活中,读请求的比例要远高于写请求,因此我们可以得出结论。这种情况下缓存中存在脏数据的可能性是不高的。
      那如果是读写分离的场景下呢?如果按照如下所述的执行序列,一样会出问题:
  • 请求A更新主库
  • 请求A删除缓存
  • 请求B查询缓存,没有命中,查询从库得到旧值
  • 从库同步完毕
  • 请求B将旧值写入缓存
      如果数据库主从同步比较慢的话,同样会出现数据不一致的问题。事实上就是如此,毕竟我们操作的是两个系统,在高并发的场景下,我们很难去保证多个请求之间的执行顺序,或者就算做到了,也可能会在性能上付出极大的代价。那为什么我们还是应当采用先更新数据库,再删除缓存这个策略呢?首先,为什么要删除而不是更新缓存,这个在前面有分析,这里不再赘述。那为什么我们应当先更新数据库呢?因为缓存在数据持久化这方面往往没有数据库做得好,而且数据库中的数据是不存在过期这个概念的,我们应当以数据库中的数据为主,缓存因为有着过期时间这一概念,最终一定会跟数据库保持一致。
      那如果我就是想解决上述说的这两个问题,在不要求强一致性的情况下可以怎么做呢?

有没有更好的思路?

  其实在讨论最后一个方案时,我们没有考虑操作数据库或者操作缓存可能失败的情况,而这种情况也是客观存在的。那么在这里我们简单讨论下,首先是如果更新数据库失败了,其实没有太大关系,因为此时数据库和缓存中都还是老数据,不存在不一致的问题。假设删除缓存失败了呢?此时确实会存在数据不一致的情况。除了设置缓存过期时间这种兜底方案之外,如果我们希望尽可能保证缓存可以被及时删除,那么我们必须要考虑对删除操作进行重试。
  你当然可以直接在代码中对删除操作进行重试,但是要知道如果是网络原因导致的失败,立刻进行重试操作很可能也是失败的,因此在每次重试之间你可能需要等待一段时间,比如几百毫秒甚至是秒级等待。为了不影响主流程的正常运行,你可能会将这个事情交给一个异步线程或者线程池来执行,但是如果机器此时也宕机了,这个删除操作也就丢失了。
  那要怎么解决这个问题呢?首先可以考虑引入消息队列,OK我知道写入消息队列一样可能会失败,但是这是建立在缓存跟消息队列都不可用的情况下,应该说这样的概率是不高的。引入消息队列之后,就由消费端负责删除缓存以及重试,可能会慢一些但是可以保证操作不会丢失。
  回到上述的两个问题中去,上述的两个问题的核心其实都在于将旧值写入了缓存,那么解决这个问题的办法其实就是要将缓存删除,考虑到网络问题导致的执行失败或执行顺序的问题,这里要进行的删除操作应当是异步延时操作。具体来说应该怎么做呢?就是参考前面说的,引入消息队列,在删除缓存失败的情况下,将删除缓存作为一条消息写入消息队列,然后由消费端进行慢慢的消费和重试。
  那如果是读写分离场景呢?我们知道数据库(以Mysql为例)主从之间的数据同步是通过binlog同步来实现的,因此这里可以考虑订阅binlog(可以使用canal之类的中间件实现),提取出要删除的缓存项,然后作为消息写入消息队列,然后再由消费端进行慢慢的消费和重试。在这种情况下,程序可以不去主动删除缓存,但如果你希望缓存中尽快读取到最新的值,也可以考虑将缓存删除,那么就有可能出现又将旧值写入缓存,且缓存被重复删除的情况。但是一般来说这不会是个问题,首先旧值重新写入缓存,情况无非就是又退化到了程序没有主动删除缓存的这一情况,另外,重复删除缓存保证了数据库和缓存之间不会存在长时间的数据不一致。(为什么删除了缓存之后,还是有可能将旧值写入缓存?参见上面先更新数据库,再删除缓存的方案下,读写分离场景下的执行序列)当然我个人的建议是,如果你可以忍受一段时间之内的数据不一致,那就没必要自己再主动去删除缓存了。
  要解决上述问题的核心就在于要实现异步延时删除这一策略,因此在这里我们需要引入消息队列。如果数据库采用读写分离架构,则需要考虑订阅binlog,否则一样可能会出现先删除,后同步完毕的情况。

缓存淘汰策略

• FIFO(first in first out)
前进先出策略,最先进入缓存的数据在缓存容量空间不够的情况下会被优先清理,以释放空间加载新的数据。该策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用
• LFU(less frequently used)
最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素以释放空间。策略算法主要比较元素的 命中次数。 在 保证高频数据有效性 场景下,可选择这类策略
• LRU(least recently used)
最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素以释放空间。该策略算法主要 比较元素最近一次被get使用时间。在热点数据场景下较适用,优先保证热点数据的有效性

缓存穿透

描述:缓存穿透,是指查询一个数据库一定不存在的数据。正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存
问题:每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库
解决方案:如果从数据库查询的对象为空,也放入缓存,只是设定的缓存过期时间较短

缓存雪崩

描述:是指在某一个时间段,多个缓存key集中过期失效
问题:缓存集中过期,请求落到数据库,产生周期性压力波峰
解决方案:缓存失效时间添加个随机值(随机5分钟内失效等等),缓存失效时间分布均匀。

缓存击穿

描述:缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮,缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key
问题:大并发的请求可能会瞬间把后端DB压垮
解决方案:
1.加互斥锁(降低系统吞吐量,多个线程进行等待)
在这里插入图片描述

2.设置热点数据永远不过期,定时去更新最新数据(推荐)
最后用一张图来加深记忆吧
在这里插入图片描述
在这里插入图片描述

总结,关于缓存需要注意地方挺多的,数据不一致的问题也很复杂,也有很多不同的方案,最简单可靠的方式还是给缓存添加合理的过期时间。 本地缓存和分布式缓存还有一些不同之处,有时间再去整理下分布式缓存(redis)等一些知识点。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值