Loading

缓存架构设计

为什么使用缓存

在高并发的分布式的系统中,缓存是必不可少的一部分。没有缓存对系统的加速和阻挡大量的请求直接落到系统的底层,系统是很难撑住高并发的冲击,所以分布式系统中缓存的设计是很重要的一环。

最简单的后端系统只需要一个应用服务(比如 Tomcat)和持久化存储数据的数据库(如 MySQL),对于一个访问量很小的系统来说,这样的架构就足够了。

但随着系统的用户数和访问量的提升,数据库会收到越来越多的并发请求。由于数据库是从磁盘中读取数据,性能较低,随着请求数的增多,数据库受到的压力会越来越大。由于应用访问数据库的连接数有限,当数据库的处理能力跟不上请求数时,新的请求将排队等待,从而导致我们的后台程序也会阻塞。当并发请求数持续增大时,数据库甚至会挂掉

因此,我们需要性能更高的读取数据方式,可以在数据库之上增加一层缓存,当后台程序首次读取数据时,将得到的数据存入缓存中,那么后续的请求要读取相同数据时,只需从缓存中读取即可。由于缓存是将数据存入内存中,读取速度非常快,在成功提升性能的同时,替数据库分担了大量的压力。

缓存的类型

按照系统划分

应用级缓存

应用级缓存也就是我们平时写的应用程序中所使用的缓存。在平时程序中一般是按照如下操作流程来实现缓存的操 作,首先张三用户读取数据库,并将读取的数据存入到缓存中,其他用户读取的时候,直接从缓存中读取,而不用查询数据库,从而提高程序的执行速度和效率。

系统级别缓存

系统级别缓存是抛开我们应用程序之外硬件的缓存操作,例如某些CPU的缓存操作和多级缓存流程类似, CPU在操作数据的时候,先读取1级缓存,1级缓存如果没有数据则读取2级缓存,2级缓存没有数据则读取3级缓 存,3级缓存如果没有数据就直接从主存储器(存储指令和数据)读取数据

按照设计方式划分

本地缓存

直接运行在应用程序本地的缓存组件。

比如 JVM 中的 Map 数据结构,可以作为最简单的数据缓存。

如果你的应用程序只需要运行在一台服务器上,并且多个应用程序之间不需要共享缓存的数据(比如用户 token),可以直接采用本地缓存,访问缓存时不需要通过网络传输,非常地方便迅速。

但是本地缓存会和你的应用程序强耦合,应用程序停止,本地缓存也就停止了。而且如果是在分布式场景下,多个机器都要使用缓存,此时如果在每个服务器上单独维护一份本地缓存,不仅无法共享数据,而且非常浪费内存(因为每台机器可能缓存了相同的数据)。

分布式缓存

分布式缓存是指独立的缓存服务,不和任何一个具体的应用耦合,可以独立运行并搭建缓存集群。类似数据库,所有的应用程序都可以连接同一个缓存服务以获取相同的缓存数据。

除了数据共享外,分布式缓存的优点还有很多。比如不需要每台机器单独维护缓存、可以集中管理缓存和整体管控分析、便于扩展和容错等。但是应用必须要通过网络访问分布式缓存服务,会产生额外的网络开销成本;并且每台机器都有可能会对整个分布式缓存服务产生影响,而一旦分布式缓存挂了,所有的应用都可能出现瘫痪(缓存雪崩)。

多级缓存

上述两种缓存没有绝对的优劣,要根据实际的业务场景进行选型。其实还可以将本地缓存与分布式缓存相结合,形成多级缓存服务,架构如下:

当首次查询时(不存在缓存),会同时将数据写入本地缓存和分布式缓存。之后的查询优先查询分布式缓存,而如果分布式缓存宕机,则从本地缓存获取数据。通过多级缓存机制,能够起到兜底的作用,即使缓存挂掉,也能支撑应用运行一段时间

缓存应用场景

频繁查询数据缓存

有一些数据经常被访问,而且变更频率较低,实时性要求不高的数据,可以把它存储到缓存中,每次读取数据直接 读缓存即可,从而提升数据的加载速度和系统的性能。

列表排序分页数据

一些变更频率较低查询频次较高的列表、分页、排序数据,可以存入到Redis缓存,每次查询分页或者排序的时 候,直接从Redis缓存中获取

计数器

网站中用于统计访问频次、在线人数、商品抢购次数等,也可以使用缓存来实现。

详情内容

站点中,资讯内容、商品详情等较大变更频率又低的内容,可以采用缓存来加速数据的读取,降低IO操作。

分布式Session

实现会话共享的时候,可以使用Session来存储需要共享的会话,从而节省内存空间。

热点排名

我们可以使用ZSet来存储热数据,并实现热点数据的排名

发布订阅

用Redis也可以实现发布与订阅,但不推荐,推荐用MQ。

分布式锁

可以使用Redisson结合Redis实现分布式锁,Redis实现的分布式锁效率极高,得到了市场的广泛使用

缓存和DB的一致性问题

产生原因

缓存和 DB 的操作,不在一个事务中,可能只有一个操作成功,而另一个操作失败,导致不一致。

常见方案

更新缓存 VS 淘汰缓存(删除缓存)

什么是更新缓存:数据不但写入数据库,还会写入缓存

什么是淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉

更新缓存:这种适用于缓存的值相对简单,和数据库的值一一对应,这样更新比较快。

淘汰缓存:这种适用于缓存的值比较复杂的场景。比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据,并进行运算,才能计算出缓存最新的值的。这样更新缓存的代价是很高的,这个缓存的值如果不是被频繁访问,就得不偿失了。

先操作数据库 vs 先操作缓存

当写操作发生时,假设淘汰缓存作为对缓存通用的处理方式,又面临两种抉择:

1)先写数据库,再淘汰缓存

(2)先淘汰缓存,再写数据库

假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致

假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。

结论:数据和缓存的操作时序,结论是清楚的:先淘汰缓存,再写数据库。

我们会基于这个方案(先删缓存,再更新数据库)去实现缓存更新,但是不代表这个方案在并发情况下没问题:

同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
上述情况就会导致不一致的情形出现

解决方案(数据库与缓存更新与读取操作进行异步串行化)
更新数据的时候,将操作任务,发送到一个队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作任务,也发送同一个队列中。 每个个队列可以对应多个消费者,每个队列拿到对应的消费者,然后一条一条的执行。

其他策略讨论

先更新数据库,再更新缓存 这套方案,我们不考虑:

问题:同时有请求A和请求B进行更新操作,那么会出现
(1)A更新了数据库
(2)B更新了数据库
(3)B更新了缓存
(4)A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据

如何保证一致性

没有办法做到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,所以,我们得委曲求全,可以去做到BASE理论中说的最终一致性

最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

到达最终一致性的解决思路,主要是针对先删缓存,再更新数据库/先更新数据库,再删缓存的策略导致的脏数据问题,进行相应的处理,来保证最终一致性。

延时双删

先删缓存,再更新数据库,然后等待一段时间,在删除缓存,等待数据完全落盘后删除缓存完成同步操作

线程号

操作对象

操作描述

操作值

线程A

缓存

删除缓存

delete 5

线程B

查询发现缓存不存在

线程B

数据库

去数据库查询得到旧值

get 5

线程B

缓存

将旧值写入缓存

set 5

线程A

数据库

将新值写入数据库

set 3

线程A

启动定时任务,延时50ms

线程A

缓存

到达50ms删除缓存

delete 5

线程B

缓存

查询发现缓存不存在

线程B

去数据库查询得到旧值

get 3

缓存灾难问题

缓存穿透

缓存穿透是指查询一个一定不存在的数据,由于缓存不命中,并且处于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次都请求到要到存储层去查询,失去了缓存的意义.

  • 危害:对底层数据源(mysql,hbase,http接口,rpc调用等等)压力过大,有些底层数据源不具备高并发性.
  • 原因: 可能是代码本身或者数据存在的问题造成的,也很有可能是一些恶意攻击,爬虫等等(因为http读接口都是开放的)
  • 如何发现:可以分别记录cache命中数,以及总调用量,如果发现空命中(cache都没有命中)较多,则可能就会是缓存穿透问题.


解决思路:

  1. 缓存空对象

不存在,缓存空数据,设置过期时间一般为 30 秒(这里的空数据,可以直接设置数据为空的情况的值,也可以是空值标识,过期时间根据业务需求调整)

优点: 数据为空时能够快速返回,不需要将压力落到数据库上,避免了数据库压力过大,甚至崩溃的情况

缺点: 缓存层和存储层的数据会有一段时间窗口的不一致,就是数据库明明没值,缓存上却有值,一致性要求高的业务需要注意,做好校验工作

  1. 布隆过滤器

假如我们添加值到布隆过滤器中,布隆过滤器会通过计算多种不同的哈希函数来计算出多个 hash 值,然后将对应位置的值设置为 1。因此这个 hash 值是有被多个不同的值对应的可能,所以说布隆过滤器如果命中了,说明值可能存在,如果不命中,肯定不存在。误判率为3%。

Reids不存在,则从布隆过滤器中获取,将获取到的值,更新到 Redis(首先布隆过滤器存在值,也不一定是正确的,因此并非所有场景都适用,其数据需要刷回 Redis,因为 Redis 没值依然会来布隆过滤器上取值,因此数据本身需要对正确性的要求不是很高)

优点

  • 等同于用布隆过滤器代替了数据库,因此性能高
  • 由于顶替了数据库,规避了数据库宕机的风险

缺点:

  • 代码逻辑较为复杂
  • 需要对布隆过滤器预先做初始化布隆过滤器的初始化与更新较为复杂
  • 布隆过滤器不支持删值操作
  • 适用场景有限,由于布隆过滤器获取的数据存在不正确的可能性,因此需要数据对本身值的正确性要求较低,适用于注重数据是否存在的场景,比如:IP 访问白名单,之前是否访问过;垃圾邮件,垃圾短信过滤等

缓存击穿

缓存击穿,是指某个极度热点数据在某个时间点过期时,恰好在这个时间点对这个KEY有大量的并发请求过来,这些请求发现缓存过期一般都会从DB加载数据并回设到缓存,但是这个时候大并发的请求可能会瞬间压垮DB

如何避免缓存击穿:
  1. 互斥锁

请求发现缓存不存在后,去查询DB前,使用锁,保证有且仅有一个请求去查询DB,并更新到缓存。

(比如Redis的SETNX或者Memcache的ADD 去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法)

  1. 主动更新缓存

默认缓存是被动更新的.只有在中端请求发现缓存失效时,它才会去数据库查询新的数据.那么,如果我们把缓存的更新,从被动改为主动,创建一个定时器去定时更新,也就可以直接绕开缓存风暴的问题了。
缺点: 每一个缓存都要对应一个周期性的任务,而且缓存过期时间和计划任务的周期时间还要对应好,如果这中间出现了什么纰漏,终端就可能一直获取到的都是空数据.

缓存雪崩

缓存服务器宕机或者大量缓存集中某个时间段失效,导致请求全部去到数据库,造成数据库压力倍增的情况,这个是针对多个key而言

解决方案:

  • 事前:高可用架构。主从架构,操作主节点,读写,数据同步到从节点,一旦主节点挂掉,从节点跟上。
  • 事中:多级缓存。redis cluster已经彻底崩溃了,缓存服务实例本地缓存还能起到作用。
  • 事后:redis数据可以恢复,做了备份,redis数据备份和恢复,redis重新启动起来。

分布式重建缓存的并发冲突问题

假如数据在所有的缓存中都不存在了(LRU算法同时处理了),就需要重新查询数据写入缓存。对于分布式的重建缓存,在不同的机器上,不同的服务实例中,去做上面的事情,就会出现多个机器分布式重建去读取相同的数据,然后写入缓存中。

问题: 可能2个实例获取到的数据快照不一样,但是新数据先写入缓存,如果这个时候另外一个实例的缓存后写入,就有问题了

解决方案:使用分布式锁

分布式锁当然很多种不同的实现方案,redis分布式锁,zookeeper分布式锁等。

  • 变更缓存重建更新redis之前,都需要先对获取对应商品id的分布式锁
  • 拿到分布式锁之后,需要根据时间版本去比较一下,如果自己的版本新于redis中的版本,那么就更新,否则就不更新
  • 如果拿不到分布式锁,那么就等待,不断轮询等待,直到自己获取到分布式锁

大型缓存全量更新问题

一般的业务场景,会对一些页面的数据全量打包为一个kv,比如说商品详情页面,会把商品的基本信息,店家商铺信息,商品分类信息等这些信息组成的json全部塞到一个kv中,然后,当我们对其中的某项数据进行修改的时候(比如说修改了商品的分类),需要把这个kv取出来,修改里面的数据,然后才是放到redis中,很明显这样操作是对redis造成很大的性能影响的,每一次小更新,都需要去操作这个比较大的kv值,对redis造成了一定的压力

如何解决大value缓存的全量更新效率低下问题?答:缓存维度化

举个例子:商品详情页分三个维度:商品维度,商品分类维度,商品店铺维度。将每个维度的数据都存一份,比如说商品维度的数据存一份,商品分类的数据存一份,商品店铺的数据存一份。那么在不同的维度数据更新的时候,只要去更新对应的维度就可以了。大大减轻了redis的压力。

posted @ 2022-04-13 17:10  字符串爱了数组  阅读(72)  评论(0编辑  收藏  举报