抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

缓存架构体系设计

img

前言

​ 在高并发的分布式的系统中,缓存是必不可少的一部分。没有缓存对系统的加速和阻挡大量的请求直接落到系统的底层,系统是很难撑住高并发的冲击,所以分布式系统中缓存的设计是很重要的一环。下面就来聊聊分布式系统中关于缓存的设计以及过程中遇到的一些问题。

为什么使用缓存

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

img

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

​ 因此,我们需要性能更高的读取数据方式,能够帮助数据库分担压力,缓存登场了

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

img

因此想要提升系统的整体性能,缓存是不可或缺的。

缓存的类型

按照系统划分

应用级缓存

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

image-20210424194151497

系统级别缓存

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

image-20210424194247784

按照设计方式划分

本地缓存

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

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

1
2
3
4
5
6
class LocalCache {
private static Map<String, Object> cache = new HashMap<>();
private LocalCache() {}
public static void put(String key, Object value) { cache.put(key, value); }
public static Object get(String key) { return cache.get(key); }
}

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

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

img

分布式缓存

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

img

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

多级缓存

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

img

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

缓存淘汰策略

下面我们思考一个问题,如何去实现一个本地缓存呢?

​ 刚刚提到的 Map 数据结构是一个思路,但是和我们自己的电脑存储文件、或者是和 JVM 存储对象一样,内存当然不是无限的。因此在实现缓存时,必须要设计一套缓存淘汰策略,按照某种机制回收缓存占用的内存,保证缓存数据不会无限地增长直到撑爆内存。

下面介绍几种常见的缓存淘汰策略,关键问题就是当缓存空间已满时,应该选择哪些缓存进行删除。

LRU

最近最少使用

​ LRU(Least Recently Used)是最经典的内存淘汰策略,其设计原则是 “如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小”。即根据数据的最近访问时间来进行淘汰。缺点是可能会由于一次冷数据的批量查询而误删除大量的热点数据。

近似 LRU 算法

类似 LRU 算法,只是每次随机选择一批数据进行 LRU 淘汰,而不是全量 LRU 运算,牺牲部分准确度,以提升算法执行效率。

​ Redis 3.0 之后对其进行了优化,维护了一个侯选池,将随机选择的数据放入侯选池中进行 LRU 运算。当侯选池放满后,新随机的数据会替换掉池中最近被访问的数据。

TTL

超时时间

​ TTL(Time To Live)是指用户为缓存设置的过期时间,当前时间到达过期时间时,将删除缓存;如果缓存空间已满,则优先淘汰最接近过期时间的数据。

LFU

最近最不经常使用

​ LFU(Least Frequently Used)策略会记录每个缓存数据的最近访问次数(频率),并优先清除使用次数较少的数据。这种算法存在的显著缺点是,最新写入的数据由于访问次数少,常常刚被缓存就删除了。

FIFO

先进先出

​ FIFO(First In First Out)先进先出策略会将数据按照写入缓存的顺序进行排队,当缓存空间不足时,最先进入缓存的数据会被优先删除。是一种比较死板的策略,不考虑数据热度,可能会淘汰大量的热点数据,但是实现起来相对容易。

Random

随机淘汰策略,没啥好说的,一般不建议使用。

缓存应用场景

频繁查询数据缓存

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

列表排序分页数据

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

计数器

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

详情内容

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

分布式Session

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

热点排名

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

发布订阅

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

分布式锁

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

缓存灾难问题

缓存穿透

key中对应的缓存数据不存在,导致去请求数据库,造成数据库的压力倍增的情况

产生原因

​ 当我们查询一个缓存不存在的数据,就去查数据库,但此时如果数据库也没有这个数据,后面继续访问依然会再次查询数据库,当有用户大量请求不存在的数据,必然会导致数据库的压力升高,甚至崩溃。

如何解决
异常参数校验

​ 比如发起一个 id 为 “-1” 的请求,这一看就是有问题的,哪有可能 id 为负数的。或者一些非空参数,传了空值,极有可能是某人在发起攻击,直接后台判断拦截。

优点: 最快,也是最简单的拦截方式,没有额外的资源开销

缺点: 适用的场景有限

缓存空对象

在这里插入图片描述

  1. 直接跳到判断 Redis 是否存在
  2. 不存在,缓存空数据,设置过期时间一般为 30 秒(这里的空数据,可以直接设置数据为空的情况的值,也可以是空值标识,过期时间根据业务需求调整)
  3. 服务端直接返回前端

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

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

布隆过滤器

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

在这里插入图片描述

  1. 直接跳到判断 Redis 是否存在
  2. 不存在,则从布隆过滤器中获取
  3. 将获取到的值,更新到 Redis(首先布隆过滤器存在值,也不一定是正确的,因此并非所有场景都适用,其数据需要刷回 Redis,因为 Redis 没值依然会来布隆过滤器上取值,因此数据本身需要对正确性的要求不是很高)
  4. 服务端返回数据

优点

  • 等同于用布隆过滤器代替了数据库,因此性能高

  • 由于顶替了数据库,规避了数据库宕机的风险

缺点:

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

缓存击穿

redis过期后的一瞬间,有大量用户请求同一个缓存数据,导致这些请求都去请求数据库,造成数据库压力倍增的情,针对一个key而言

产生原因

​ 当缓存在某一刻过期了,一般如果再查询这个缓存,会从数据库去查询一次再放到缓存,如果正好这一刻,大量的请求该缓存,那么请求都会打到数据库中,可能导致数据库打垮。

如何解决
使用互斥锁

​ 业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。

​ SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。在redis2.6.1之前版本未实现setnx的过期时间,所以这里给出两种版本代码参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 假定有并发请求1、2、3
* 请求1先进入synchronized代码块,请求2、3等待
* 请求1会查询数据库并更新缓存,退出synchronized
* 请求2、3依次进入synchronized并命中缓存
*/
// 先从缓存读取对应id的信息流
var record = cache.get("feed:" + id)
if (record == null) {
// 缓存未命中时加锁,要求串行化执行
synchronized {
// 再次尝试从缓存读取对应id的信息流
record = cache.get("feed:" + id)
if (record == null) {
// 从数据库读取并更新缓存
record = db.feed.get(id)
cache.put("feed:" + id, record)
}
}
}

缓存雪崩

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

在这里插入图片描述

一致性问题

产生原因

主要有两种情况,会导致缓存和 DB 的一致性问题:

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

常见方案

我们讨论二种更新策略:

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

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

问题:同时有请求A和请求B进行更新操作,那么会出现

线程号 操作对象 操作描述 操作值
线程A 数据库 更新了数据库 set 5
线程B 数据库 更新了数据库 set 3
线程B 缓存 更新了缓存 set 3
线程A 缓存 更新了缓存 set 5

这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据

先删缓存

先删缓存,再更新数据库

​ 我们会基于这个方案去实现缓存更新,但是不代表这个方案在并发情况下没问题

​ 该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

线程号 操作对象 操作描述 操作值
线程A 缓存 删除缓存 delete 5
线程B 查询发现缓存不存在
线程B 数据库 去数据库查询得到旧值 get 5
线程B 缓存 将旧值写入缓存 set 5
线程A 数据库 将新值写入数据库 set 3

上述情况就会导致不一致的情形出现

如何保证一致性

​ 没有办法做到绝对的一致性,这是由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

评论