猿创征文|Redis 面试必知必会(下):数据库、集群和常见问题
1. Redis 数据库原理
我们先分析 Redis 数据库的组成结构,分析各部分的功能和相应的操作原理。然后分析 Redis 数据库如何进行数据持久化。
1.1. 数据库结构
我们从服务端和客户端两个层面来分析 Redis 的数据库结构,服务端是用来存储数据的,Redis 将所有的数据库都保存在 redis.h/redisServer 结构中。而客户端主要是用来读取或写入数据的,会记录自己连接的数据库,这部分数据保存在 redis.h/redisClient 结构中:
// Redis 服务端
struct redisServer {
// 数据库数量
int dbnum;
// 数组:保存服务器中的所有数据库
redisDb *db;
// ...
};
// Redis 客户端
struct redisClient {
// 记录当前正在使用的数据库
redisDb *db;
} redisClient;
可以用下图来描述:
上图中的每个 redisDb 结构主要包括两部分:键空间和过期字典。键空间保存着这个数据库的所有键值对,过期字典保存这这个数据库所有键的过期时间:
typedef struct redisDb {
// 键空间
dict *dict;
// 过期字典
dict *expires;
}
内存结构如下图所示:
对数据库的键值对进行增删改查,实质是对数据库键空间进行数据的插入、删除、更新或者查找,原理很直观,这里不再赘述。下面重点分析如何删除过期的键。
过期键的删除有三种可选策略:
策略 | 描述 | 缺点 |
---|---|---|
即时删除 | 对一个键设置过期时间时,后台启动一个定时器,当定时器到达过期时间时,即时删除过期键值对 | 占用CPU时间,影响服务器的响应时间和吞吐量 |
惰性删除 | 当客户端获取键时检查是否过期,如果过期则删除该键 | 浪费内存,有内存泄漏风险 |
定期删除 | 每隔一段时间对数据库检查一次,删除过期键 | 需要确定删除操作的时长和频率 |
综合以上分析,Redis 删除过期键采用了惰性删除+定期删除的策略。
1.2. 数据持久化
Redis 是内存数据库,我们往 Redis 数据库插入一个键值对,它会保存在内存中。但是这有一个问题,如果服务器重启了,内存中的数据就丢失了,Redis 服务器为解决这个问题,提供了 RDB 和 AOF(Append Only File) 两种持久化功能,可以将内存中的数据刷到磁盘里面,避免数据丢失。
1.2.1. RDB 持久化
RDB 持久化既可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将 Redis 在内存中的数据保存到一个 RDB 文件中,这是一个压缩过的二进制文件,该文件还可以在需要时还原成数据库的数据:
我们俗称 RDB 文件为 Redis 的数据快照,记录某一时刻下 Redis 中的数据,数据持久化就是在需要时把这个数据快照一次性写到磁盘上就可以了:
创建
RDB 文件的创建有两个命令:SAVE 和 BGSAVE。SAVE 命令会阻塞 Redis 服务器进程,直到 RDB 文件创建完毕为止;BGSAVE 命令会派生出一个子进程,然后由子进程负责创建 RDB 文件,服务器进程(父进程)继续处理命令请求。
载入
Redis 服务器只有在启动时才会检测并自动载入 RDB 文件,没有专门用于载入 RDB 文件的命令。载入过程如下:
其中,AOF 持久化后面介绍。服务器在载入 RDB 文件过程中,会一直处于阻塞状态,直到载入工作完成为止。
自动保存
Redis 允许用户通过设置服务器配置的 save 选项,让服务器每隔一段时间执行一次 BGSAVE 命令。
例如我们向服务器提供如下命令:
save 900 1
save 300 10
save 60 10000
满足那么以下任意一个条件,都会执行 BGSAVE 命令:
- 服务器在 900s 之内,对数据库至少修改了 1 次
- 服务器在 300s 之内,对数据库至少修改了 10 次
- 服务器在 60s 之内,对数据库至少修改了 10000 次
和 BGSAVE 相关的结构除了 save 选项,还有 dirty 计数器和 lastsave 属性,都被保存在 redisServer 结构中:
struct redisServer {
// ...
// 记录 save 选项的数组
struct saveparam *saveparams;
// 修改计数器:每修改一次数据库,计数器加1
long long dirty;
// 上一次执行保存的时间
time_t lastsave;
// ...
}
文件结构
下图展示了 RDB 文件结构:
其中 value 的编码由 TYPE 的取值决定:
1.2.2. AOF 持久化
AOF 持久化是通过保存 Redis 服务器所执行的写命令来记录数据库数据的:
例如:
redis> SET msg "hello"
OK
redis> SADD fruites "apple" "banana" "cherry"
(integer) 3
RDB 持久化的方法是将 msg、fruites 两个键值对保存到 RDB 文件中;而 AOF 持久化的方法则是将服务器执行的 SET、SADD 两条命令以 Redis 的命令请求协议(纯文本格式)保存到 AOF 文件中。
AOF 持久化可以分为**命令追加(append)、文件写入、文件同步(sync)三步。
命令追加
当 AOF 持久化功能打开时,服务器在执行完一个写命令后,会以协议格式将被执行的命令追加到服务器状态的 aof_buf 缓冲区的末尾:
struct redisServer {
// ...
// AOF 缓冲区
sds aof_buf;
// ...
}
例如客户端向服务器发送如下命令:
redis> SET KEY VALUE
OK
那么服务器在执行这个 SET 命令之后, 会将以下协议内容追加到 aof_buf 缓冲区的末尾:
*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n
- AOF rewrite
数据实时写入 AOF,随着时间的推移,AOF 文件会越来越大,那使用 AOF 恢复时变得非常慢,这该怎么办?
Redis 提供了 AOF rewrite 方案,俗称 AOF 瘦身,顾名思义,就是压缩 AOF 的体积:因为 AOF 里记录的都是每一次写命令,例如执行 set k1 v1,set k1 v2,其实我们只关心数据的最终版本 v2 就可以了。AOF rewrite 正是利用了这个特点,在 AOF 体积越来越大时(超过设定阈值),Redis 就会定期重写一份新的 AOF,这个新的 AOF 只记录数据的最终版本就可以了:
文件写入和同步
Redis 服务器就是一个事件驱动程序,一直循环处理监听到的事件。Redis 需要处理的事件分为两类:
- 文件事件(file event):负责接收客户端的命令请求, 以及向客户端发送命令回复。
- 时间事件(time event):负责执行像 serverCron 函数这样需要定时运行的函数。
因为服务器在处理文件事件时可能会执行写命令, 使得一些内容被追加到 aof_buf 缓冲区里面, 所以在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile 函数, 考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面, 这个过程可以用以下伪代码表示:
def eventLoop():
while True:
# 处理文件事件,接收命令请求以及发送命令回复
# 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
processFileEvents()
# 处理时间事件
processTimeEvents()
# 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
flushAppendOnlyFile()
flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定, 各个不同值产生的行为如表 TABLE_APPENDFSYNC 所示。
appendfsync 选项的值 | flushAppendOnlyFile 函数的行为 |
---|---|
always | 将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件。 |
everysec(默认) | 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 如果上次同步 AOF 文件的时间距离现在超过一秒钟, 那么再次对 AOF 文件进行同步, 并且这个同步操作是由一个线程专门负责执行的。 |
no | 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 但并不对 AOF 文件进行同步, 何时同步由操作系统来决定。 |
整体流程如下图所示:
1.2.3. 混合持久化
上面我们介绍了 RDB 和 AOF 两种数据持久化功能,二者各自的优势在哪里?
- RDB:持久化文件体积小(二进制 + 压缩);写盘频率低(定时写入)
- AOF:记录每一次写命令,数据最全
那能否采用各自的优势,将二者合二为一作为 Redis 数据持久化的方案呢?答案是可以,这就是 Redis 的混合持久化。
要想数据完整性更高,肯定就不能只用 RDB 了,重点还是要放在 AOF 优化上。具体来说,当 AOF 在做 rewrite 时,Redis 先以 RDB 格式在 AOF 文件中写入一个数据快照,再把在这期间产生的每一个写命令,追加到 AOF 文件中。
因为 RDB 是二进制压缩写入的,这样 AOF 文件体积就变得更小了,你在使用 AOF 恢复数据时,这个恢复时间就会更短了!
Redis 4.0 以上版本才支持混合持久化。
注意:混合持久化是对 AOF rewrite 的优化,这意味着使用它必须基于 AOF + AOF rewrite。
1.3. 事件
1.3.1.文件事件
Redis 文件事件处理器底层使用 Reactor 模式进行事件处理:
- 文件事件处理器使用 **I/O 多路复用(multiplexing)**程序来同时监听多个套接字,然后通过队列交给文件事件分配器。
- 文件事件分派器接收 I/O 多路复用程序传来的套接字, 并根据套接字产生的事件的类型, 调用相应的事件处理器。
虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
文件事件类型
I/O 多路复用程序可以监听多个套接字的 ae.h/AE_READABLE 事件和 ae.h/AE_WRITABLE 事件, 这两类事件和套接字操作之间的对应关系如下:
- 当套接字变得可读时(客户端对套接字执行 write 操作,或者执行 close 操作), 或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行 connect 操作), 套接字产生 AE_READABLE 事件。
- 当套接字变得可写时(客户端对套接字执行 read 操作), 套接字产生 AE_WRITABLE 事件。
文件事件处理器
Redis 为文件事件编写了多个处理器,下面介绍最常用的三个:
- 连接应答处理器
当 Redis 服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的 AE_READABLE 事件关联起来, 当有客户端用 sys/socket.h/connect 函数连接服务器监听套接字的时候,套接字就会产生 AE_READABLE 事件,引发连接应答处理器执行。
- 命令请求处理器
当一个客户端通过连接应答处理器成功连接到服务器之后, 服务器会将客户端套接字的 AE_READABLE 事件和命令请求处理器关联起来, 当客户端向服务器发送命令请求的时候, 套接字就会产生 AE_READABLE 事件, 引发命令请求处理器执行。
- 命令回复处理器
当服务器有命令回复需要传送给客户端的时候, 服务器会将客户端套接字的 AE_WRITABLE 事件和命令回复处理器关联起来, 当客户端准备好接收服务器传回的命令回复时, 就会产生 AE_WRITABLE 事件, 引发命令回复处理器执行。
一次完整的客户端与服务器连接事件示例如下图所示:
1.3.2.时间事件
Redis 的时间事件分为两类:
- 定时事件:让一段程序在指定时间之后执行一次;
- 周期性事件:让一段程序每隔指定时间就执行一次。
一个时间事件主要有三个属性构成:
- id:事件ID,全局唯一ID;
- when:事件到达时间,毫秒级 UNIX 时间戳;
- timeProc:时间事件处理器,一个函数。当前时间事件到达时,服务器会调用相应的处理器来处理事件。
实现原理:服务器将所有时间事件放在一个无需链表中,每当时间事件执行器运行时,它就会遍历该链表,查找所有已到达的时间事件,并调用相应的事件处理器。
目前版本中,正常模式下的 Redis 服务器只使用 serverCron 一个时间处理器,而在 benchmark 模式下也只有两个时间事件。这种情况下,无序链表几乎退化为了一个指针来用,不影响服务器性能。
1.4. 客户端
客户端状态包含的属性可以分为两类:
- 通用属性:这些属性很少与特定功能相关,无论客户端执行的是什么工作,它们都要用到这些属性。
- 特定属性:比如操作数据库时需要用到的 db 属性和 dictid 属性, 执行事务时需要用到的 mstate 属性, 以及执行 WATCH 命令时需要用到的 watched_keys 属性等等。
本章将对客户端状态中比较通用的那部分属性进行介绍:
typedef struct redisClient {
// ...
// 套接字描述符
int fd;
// 名字
robj *name;
// 标志
int flags;
// 输入缓冲区
sds querybuf;
// 命令参数
robj **argv;
// 命令参数的个数
int argc;
// 输出缓冲区
char buf[REDIS_REPLY_CHUNK_BYTES];
// buf 数组目前已使用的字节数
int bufpos;
// 身份验证
int authenticated;
// 客户端创建时间
time_t ctime;
// ...
} redisClient;
参数名称 | 描述 | 取值 |
---|---|---|
fd | 套接字描述符 | 伪客户端(fake client)值为 -1:伪客户端处理的命令请求来源于 AOF 文件或者 Lua 脚本,而不是网络, 所以这种客户端不需要套接字连接,自然也不需要记录套接字描述符。目前 Redis 服务器会在两个地方用到伪客户端,一个用于载入 AOF 文件并还原数据库状态,另一个则用于执行 Lua 脚本中包含的 Redis 命令。普通客户端值大于 -1:普通客户端使用套接字来与服务器进行通讯,所以服务器会用 fd 属性来记录客户端套接字的描述符。 |
name | 名字 | 在默认情况下, 一个连接到服务器的客户端是没有名字的,使用 CLIENT_SETNAME 命令可以为客户端设置一个名字, 让客户端的身份变得更清晰。 |
flags | 标志 | 记录客户端的角色或状态: REDIS_MASTER: 客户端是一个主服务 REDIS_BLOCKED: 客户端正在被列表命令阻塞 REDIS_MULTI |
querybuf | 输入缓存区 | 客户端向服务器发送的命令请求以命令协议格式存在输入缓存区。输入缓冲区的大小会根据输入内容动态地缩小或者扩大,但它的最大大小不能超过 1 GB,否则服务器将关闭这个客户端。 |
argv | 命令参数 | 一个数组, 数组中的每个项都是一个字符串对象: 其中 argv[0] 是要执行的命令, 而之后的其他项则是传给命令的参数。 |
argc | 命令参数个数 | argv 数组的长度。 |
buf | 输出缓冲区(固定) | 执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里,每个客户端都有两个输出缓冲区可用, 一个缓冲区的大小是固定的(16KB),用于保存长度较小的回复;另一个缓冲区的大小是可变的,用于保存长度较大的回复。 |
bufpos | buf已用字节数 | 记录了 buf 数组目前已使用的字节数量。 |
reply | 输出缓存区(可变) | 大于 16 KB 的回复,通过使用链表来保存。 |
authenticated | 身份验证 | authenticated = 0 表示客户端未通过身份验证;authenticated = 1 表示客户端通过了身份验证。 |
reply | 输出缓存区(可变) | 大于 16 KB 的回复,通过使用链表来保存。 |
ctime | 创建时间 | 记录了创建客户端的时间,可以用来计算客户端与服务器已经连接了多少秒。 |
1.5. 服务端
一个命令请求的执行过程可以分为 4 步:
- 发送命令请求
- 读取命令请求
- 执行命令
- 回复命令
下面分别介绍一下每一步的详细过程。
1.5.1. 发送命令请求
如果我们使用客户端执行以下命令:
redis> SET KEY VALUE
OK
客户端与服务器之间的连接套接字会变为可读。
1.5.2. 读取命令请求
服务器将调用命令请求处理器来执行以下操作:
- 读取套接字中协议格式的命令请求, 并将其保存到客户端状态的输入缓冲区里面。
- 对输入缓冲区中的命令请求进行分析, 提取出命令请求中包含的命令参数, 以及命令参数的个数, 然后分别将参数和参数个数保存到客户端状态的 argv 属性和 argc 属性里面。
- 调用命令执行器, 执行客户端指定的命令。
1.5.3. 执行命令
命令请求处理器是如何找到合适的命令执行器呢?通过命令表,命令表是一个字典, 字典的键是一个个命令名字,可以根据命令的名字找到对应的命令执行器。
在正式执行命令之前,程序还需要进行一些预备操作, 从而确保命令可以正确、顺利地被执行, 这些操作包括:
- 检查命令请求所给定的参数个数是否正确
- 检查客户端是否已经通过了身份验证
- 如果服务器打开了 maxmemory 功能, 那么在执行命令之前, 先检查服务器的内存占用情况
- …
1.5.4. 回复命令
命令实现函数会将命令回复保存到客户端的输出缓冲区里面, 并为客户端的套接字关联命令回复处理器, 当客户端套接字变为可写状态时, 服务器就会执行命令回复处理器, 将保存在客户端输出缓冲区中的命令回复发送给客户端。
2. Redis 集群
这一节我们介绍从单机版本、数据持久化、主从复制、哨兵一直到集群到演进之路。
2.1. 单机版本 Redis
我们在设计服务架构时,一开始业务极其简单,应用请求后端数据时,常常选择用 Redis 做缓存,用 Mysql 做数据库:
但是有一天 Redis 服务器因为某些原因宕机了,请求直接打到数据库,数据库压力倍增。这时候你要重启 Redis,但是你发现没有开启数据持久化功能,之前 Redis 的数据都存在内存中,服务宕机后数据丢失了,还是不能即时解决问题。这时,你要考虑开启数据持久化功能了。
2.2. Redis 数据持久化
如何进行数据持久化?那自然是要使用我们上面提到的 AOF、RDB 或者更强大的混合持久化功能了。
但在恢复数据时依旧是需要时间的,在这期间你的业务应用无法提供服务,这怎么办?
一个实例宕机,只能用恢复数据来解决,那我们是否可以部署多个 Redis 实例,然后让这些实例数据保持实时同步,这样当一个实例宕机时,我们在剩下的实例中选择一个继续提供服务就好了。
没错,这个方案就是接下来要讲的主从复制:多副本。
2.3. 主从复制:多副本
你可以部署多个 Redis 实例,设计一个简单的架构模型:
我们把实时读写的节点叫做 master,另一个实时同步数据的节点叫做 slave。
采用多副本方案可以缩短不可用时间:master 发生宕机,我们可以手动把 slave 提升为 master 继续提供服务。
此外我们进一步优化架构:还可以让 slave 分担一部分读请求,以提升应用的整体性能:
这个方案不仅节省了数据恢复的时间,还能提升性能。但它的问题在于:当 master 宕机时,我们需要手动把 slave 提升为 master,这个过程也是需要花费时间的。虽然比恢复数据要快得多,但还是需要人工介入处理。我们是否可以把这个切换的过程,变成自动化?
2.4. 哨兵
要想自动切换,肯定不能依赖人了。现在,我们可以引入一个“观察者”,让这个观察者去实时监测 master 的健康状态,这个观察者就是哨兵(Sentinel)。具体如何做?
- 哨兵每间隔一段时间,询问 master 是否正常
- master 正常回复,表示状态正常,回复超时表示异常
- 哨兵发现异常,发起主从切换
但这里还有一个问题,如果 master 状态正常,但这个哨兵在询问 master 时,它们之间的网络发生了问题,那这个哨兵可能会“误判”。
这个问题怎么解决?既然一个哨兵会误判,那我们可以部署多个哨兵,让它们分布在不同的机器上,让它们一起监测 master 的状态。所以流程是这样的:
- 多个哨兵每间隔一段时间,询问 master 是否正常
- master 正常回复,表示状态正常,回复超时表示异常
- 一旦有一个哨兵判定 master 异常(不管是否是网络问题),就询问其它哨兵,如果多个哨兵(设置一个阈值)都认为 master 异常了,这才判定 master 确实发生了故障
- 多个哨兵经过协商后,判定 master 故障,则发起主从切换
所以,我们用多个哨兵互相协商来判定 master 的状态,这样,就可以大大降低误判的概率。
哨兵协商判定 master 异常后,这里还有一个问题:由哪个哨兵来发起主从切换呢?
答案是,通过投票选出一个哨兵「领导者」,由这个领导者进行主从切换。在选举哨兵领导者时,我们可以制定这样一个选举规则:
- 每个哨兵都询问其它哨兵,请求对方为自己投票
- 每个哨兵只投票给第一个请求投票的哨兵,且只能投票一次
- 首先拿到超过半数投票的哨兵,当选为领导者,发起主从切换
这个选举的过程就是分布式系统领域中的共识算法。这个算法还规定节点的数量必须是奇数个,这样可以保证系统中即使有节点发生了故障,剩余超过「半数」的节点状态正常,依旧可以提供正确的结果,也就是说,这个算法还兼容了存在故障节点的情况。
共识算法在分布式系统领域有很多,例如 Paxos、Raft,哨兵选举领导者这个场景,使用的是 Raft 共识算法,因为它足够简单,且易于实现。
可见当写请求量越来越大时,一个 master 实例可能就无法承担这么大的写流量了怎么办?
要想完美解决这个问题,此时你就需要考虑使用分片集群了。
2.5. 分片集群
什么是分片集群?
简单来讲,一个实例扛不住写压力,那我们是否可以部署多个实例,然后把这些实例按照一定规则组织起来,把它们当成一个整体,对外提供服务,这样就可以解决集中写一个实例的瓶颈问题。所以,现在的架构模型就变成了这样:
但是这么多实例如何组织呢?
我们制定规则如下:
- 每个节点各自存储一部分数据,所有节点数据之和才是全量数据
- 制定一个路由规则,对于不同的 key,把它路由到固定一个实例上进行读写
数据分多个实例存储,那寻找 key 的路由规则需要放在客户端来做,具体就是下面这样:
这种方案也叫做客户端分片,这个方案的缺点是,客户端需要维护这个路由规则,也就是说,你需要把路由规则写到你的业务代码中。
如何做到不把路由规则耦合在客户端业务代码中呢?
继续优化,我们可以在客户端和服务端之间增加一个中间代理层,这就是我们经常听到的 Proxy,路由转发规则放在 Proxy 层来维护。这样,客户端只需要和这个 Proxy 交互即可,无需关心服务端有多少个 Redis 节点。
Proxy 会把你的请求根据路由规则,转发到对应的 Redis 节点上,而且,当集群实例不足以支撑更大的流量请求时,还可以横向扩容,添加新的 Redis 实例提升性能,这一切对于你的客户端来说,都是透明无感知的。
业界开源的 Redis 分片集群方案,例如 Twemproxy、Codis 就是采用的这种方案。
3. 常见问题
3.1. Redis 有哪些使用场景?
缓存、计数器、消息队列、排行榜、分布式锁。
3.2. 什么情况会用到 Redis 做缓存?
更新频率低,读取频率高 的数据才适合在数据库的前面使用缓存,一般按照82原则来评估,读8写2。
3.3. 多级缓存是什么?
通常,一个大型软件系统的缓存采用多级缓存方案:
请求过程:
- 浏览器向客户端发起请求,如果 CDN 有缓存则直接返回;
- 如果 CDN 无缓存,则访问反向代理服务器;
- 如果反向代理服务器有缓存则直接返回;
- 如果反向代理服务器无缓存或动态请求,则访问应用服务器;
- 应用服务器访问进程内缓存;如果有缓存,则返回代理服务器,并缓存数据(动态请求不缓存);
- 如果进程内缓存无数据,则读取分布式缓存;并返回应用服务器;应用服务器将数据缓存到本地缓存(部分);
- 如果分布式缓存无数据,则应用程序读取数据库数据,并放入分布式缓存。
(1)CDN 缓存
CDN 将数据缓存到离用户物理距离最近的服务器,使得用户可以就近获取请求内容。CDN 一般缓存静态资源文件(页面,脚本,图片,视频,文件等)。
国内网络异常复杂,跨运营商的网络访问会很慢。为了解决跨运营商或各地用户访问问题,可以在重要的城市,部署 CDN 应用。使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。
(2)反向代理
反向代理(Reverse Proxy)方式是指以代理服务器来接受 internet 上的连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给 internet 上请求连接的客户端,此时代理服务器对外就表现为一个反向代理服务器。
反向代理缓存的原理:
- 如果用户请求的页面在代理服务器上有缓存的话,代理服务器直接将缓存内容发送给用户。
- 如果没有缓存则先向 WEB 服务器发出请求,取回数据,本地缓存后再发送给用户。
这种方式通过降低向 WEB 服务器的请求数,从而降低了 WEB 服务器的负载。反向代理缓存一般针对的是静态资源,而将动态资源请求转发到应用服务器处理。常用的缓存应用服务器有 Varnish,Ngnix,Squid。
(3)进程内缓存
直接将数据存储到本机内存中,通过程序直接维护缓存对象,是访问速度最快的方式。常见的本地缓存实现方案:HashMap、Guava Cache、Caffeine、Ehcache。
(4)分布式缓存集群
分布式缓存解决了进程内缓存最大的问题:如果应用是分布式系统,节点之间无法共享彼此的进程内缓存。分布式缓存的应用场景:
- 缓存经过复杂计算得到的数据。
- 缓存系统中频繁访问的热点数据,减轻数据库压力。
常见的分部署缓存集群有 Memcached 和 Redis。
一个典型的二级缓存架构,可以使用进程内缓存(如:Caffeine/Google Guava/Ehcache/HashMap)作为一级缓存;使用分布式缓存(如:Redis/Memcached)作为二级缓存。
3.4. Redis 缓存有哪些异常?
常见的异常情况有缓存雪崩、缓存击穿和缓存穿透。
(1)缓存雪崩
一段时间内本应在 Redis 缓存中处理的大量请求,都发送到了数据库进行处理,导致对数据库的压力迅速增大,严重时甚至可能导致数据库崩溃,从而导致整个系统崩溃,就像雪崩一样,引发连锁效应,所以叫缓存雪崩。
主要原因:
a、大量缓存数据同时过期,导致本应请求到缓存的需重新从数据库中获取数据;
b、redis本身出现故障,无法处理请求,那自然会再请求到数据库那里。
解决措施:
a、针对大量缓存数据同时过期的情况:
- 实际设置过期时间时,应当尽量避免大量 key 同时过期的场景,如果真的有,那就通过随机、微调、均匀设置等方式设置过期时间,从而避免同一时间过期;
- 添加互斥锁,使得构建缓存的操作不会在同一时间进行;
- 双key策略,主key是原始缓存,备key为拷贝缓存,主key失效时,可以访问备key,主key缓存失效时间设置为短期,备key设置为长期;
- 后台更新缓存策略,采用定时任务或者消息队列的方式进行redis缓存更新或移除等。
b、针对redis本身出现故障的情况:
- 在预防层面,可以通过主从节点的方式构建高可用的集群,也就是实现主 Redis 实例挂掉后,能有其他从库快速切换为主库,继续提供服务;
- 如果事情已经发生了,那就要为了防止数据库被大量的请求搞崩溃,可以采用服务熔断或者请求限流的方法。
(2)缓存击穿
缓存击穿一般出现在高并发系统中,是大量并发用户同时请求到缓存中没有但数据库中有的数据,也就是同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
主要原因:
某个热点数据缓存过期,由于是热点数据,请求并发量又大,所以过期的时候还是会有大量请求同时过来,来不及更新缓存就全部打到数据库。
解决方案:
- 对热点数据不设置过期时间,这样不会过期,自然也就不会出现上述情况了,如果后续想清理,可以通过后台进行清理;
- 添加互斥锁,即当过期之后,除了请求过来的第一个查询的请求可以获取到锁请求到数据库,并再次更新到缓存中,其他的会被阻塞住,直到锁被释放,后续请求又会请求到缓存上,这样就不会出现缓存击穿了。
(3)缓存穿透
缓存穿透是指数据既不在 Redis 中,也不在数据库中,这样就导致每次请求过来,在缓存和数据库都要查询一遍,请求量大时会对数据库造成比较大的压力。
主要原因:
请求参数明显不符合业务逻辑,例如有人想恶意攻击系统,就可以故意使用空值或者其他不存在的值进行频繁请求。
主要措施:
- 非法请求的限制,主要是指参数校验、鉴权校验等,从而一开始就把大量的非法请求拦截在外,这在实际业务开发中是必要的手段;
- 缓存空值或者默认值,如果从缓存取不到的数据,在数据库中也没有取到,那我们仍然把这个空结果进行缓存,同时设置一个较短的过期时间。通过这个设置的默认值存放到缓存,这样第二次到缓存中获取就有值了,而不会继续访问数据库,可以防止有大量恶意请求是反复用同一个key进行攻击;
- 使用布隆过滤器快速判断数据是否存在。
布隆过滤器
布隆过滤器利用多个hash函数标识数据是否存在,该方法让较小的空间容纳较多的数据,且冲突可控。其工作原则是,过滤器判断不存在的数据则一定不存在。
在插入 Redis 的键 a、b、c 时,通过三个 hash 函数计算得到的 hash 值分布如上图黄色 hash 槽所示。
如果要查询键 d 的值,经过三个 hash 函数计算得到的结果在 hash 槽中值都是 1,说明值可能存在。
如果要查询键 e 的值,经过三个 hash 函数计算得到的结果在 hash 槽中的值有一个是 0,说明 e 肯定不在缓存中。
3.5. 什么是缓存预热?缓存降级?
缓存预热
缓存预热就是系统上线前后,将相关的缓存数据直接加载到缓存系统中去,而不依赖用户。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。这样可以避免那么系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。根据数据不同量级,可以有以下几种做法:
- 数据量不大:项目启动的时候自动进行加载;
- 数据量较大:后台定时刷新缓存;
- 数据量极大:只针对热点数据进行预加载缓存操作。
缓存降级
缓存降级是指当缓存失效或缓存服务出现问题时,为了防止缓存服务故障,导致数据库跟着一起发生雪崩问题,所以也不去访问数据库,但因为一些原因,仍然想要保证服务还是基本可用的,虽然肯定会是有损服务。因此,对于不重要的缓存数据,我们可以采取服务降级策略。一般做法有以下两种:
- 直接访问内存部分的数据缓存;
- 直接返回系统设置的默认值。