2020-05-23

Redis

总体概述

“两大维度”就是指系统维度和应用维度,“三大主线”也就是指高性能、高可靠和高可扩展(可以简称为“三高”)。

问题画像图:

基础概念

Redis本质上是一个 Key-Value 类型的内存数据库,支持String、List、Set、Sorted Set、hashes。

Redis 的通信协议是 Redis 序列化协议,简称 RESP。它具有如下特征:1.在 TCP 层;2.二进制安全;3.基于请求 - 响应模式。

Redis 会将事务中的多个命令一次性、按顺序一次执行,在执行期间可以保证不会中断事务去执行其他命令。Redis 的事务满足一致性和隔离性,但不支持原子性和持久性,事务不会回滚。

键值对存储结构

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

在下图中,可以看到,哈希桶中的 entry 元素中保存了key和value指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过*value指针被查找到。

Hash冲突

Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。

所以,Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。那具体怎么做呢?

其实,为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:

给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;

把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;

释放哈希表 1 的空间。

底层数据结构

数据类型

String

SDS动态字符串,二进制安全,最大512M

1.开发者不用担心字符串变更造成的内存溢出问题。

2.常数时间复杂度获取字符串长度len字段。

3.空间预分配free字段,会默认留够一定的空间防止多次重分配内存。

List

双向链表上扩展了头、尾节点、元素数等属性

1.可以直接获得头、尾节点。

2.常数时间复杂度得到链表长度。

3.双向链表

hash

在数组+链表的基础上,进行了一些rehash优化等

1.Redis的Hash采用链地址法来处理冲突

2.哈希表节点采用单链表结构。

3.rehash优化,渐进式rehash

set

通过 hashtable 实现

无序的自动去重

zset

内部使用 HashMap 和跳跃表(skipList)

HashMap 里放的是成员到 Score 的映射。而跳跃表里存放的是所有的成员

线程模型

Redis 在单线程下还可以支持高并发的一个重要原因就是 Redis 的线程模型:基于非阻塞的IO多路复用机制。(epoll/select/poll)

redis 内部使用文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以 redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket,根据 socket 上的事件来选择对应的事件处理器进行处理。

文件事件处理器

多个 socket

IO 多路复用程序

select/poll

采用轮询方式扫描文件描述符(select数组,poll链表),文件描述符越多,性能越差

需要复制大量的句柄数据结构,产生巨大的开销

需要遍历整个数组/链表,才能找到发生事件的句柄

水平触发,报告了发生事件的句柄若未被处理,下次还将上报

epoll

EPOLLLT和EPOLLET两种触发模式

没有最大并发连接的限制,能打开的FD的上限远大于1024

效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数

文件事件分派器

事件处理器

连接应答处理器

命令请求处理器

命令回复处理器

多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,会将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

来看客户端与 redis 的一次通信过程:

客户端 socket01 向 redis 的 server socket 请求建立连接,此时 server socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该事件压入队列中。文件事件分派器从队列中获取该事件,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 socket01,并将该 socket01 的 AE_READABLE 事件与命令请求处理器关联。

假设此时客户端发送了一个 set key value 请求,此时 redis 中的 socket01 会产生 AE_READABLE 事件,IO 多路复用程序将事件压入队列,此时事件分派器从队列中获取到该事件,由于前面 socket01 的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 socket01 的 key value 并在自己内存中完成 key value 的设置。操作完成后,它会将 socket01 的 AE_WRITABLE 事件与命令回复处理器关联。

如果此时客户端准备好接收返回结果了,那么 redis 中的 socket01 会产生一个 AE_WRITABLE 事件,同样压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如 ok,之后解除 socket01 的 AE_WRITABLE 事件与命令回复处理器的关联。

IO 多路复用程序

Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

下图就是基于多路复用的 Redis IO 模型。图中的多个 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。

高并发/可用

持久化

Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照。

AOF

AOF 即 Append-only file:把所有对 Redis 服务器进行修改的命令保存到 aof 文件中,命令的集合。(命令写入/文件同步/文件重写)。AOF 是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志,如下图所示:

写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况

除此之外,AOF 还有一个好处:它是在命令执行后才记录日志,所以不会阻塞当前的写操作

AOF 也有两个潜在的风险

首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时 Redis 是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。

其次,AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。

刷盘策略

这两个风险都是与系统刷盘有关的,AOF 机制提供了三个刷盘策略,也就是 AOF 配置项 appendfsync 的三个可选值。

Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;

Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;

No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

优缺点如下:

AOF 重写机制

简单来说,AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。

为什么重写机制可以把日志文件变小呢? 实际上,重写机制具有“多变一”功能。所谓的“多变一”,也就是说,旧日志文件中的多条命令,在重写后的新日志中变成了一条命令。

AOF 日志由主线程写回不同,重写过程是由后台子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。

总结来说,每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。

RDB内存快照

Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中。这样做的好处是,一次性记录了所有数据,一个都不少。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。

save:在主线程中执行,会导致阻塞;

bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。

处理复制时新产生的数据

Redis 会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。

简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。

此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。

优缺点

AOF:

优点:

相比于 RDB,AOF 更加安全,默认同步策略为每秒同步一次,最差就失去一秒的数据。

根据关注点不同,AOF 提供了不同的同步策略。

AOF 文件是以 append-only 方式写入,相比如RDB 全量写入的方式,它没有任何磁盘寻址的开销,写入性能非常高。

缺点:

由于 AOF 日志文件是命令级别的,所以相比于 RDB 紧致的二进制文件而言它的加载速度会慢些。

AOF 开启后,支持的写 QPS 会比 RDB 支持的写 QPS 低。

RDB

优点:

由于 RDB 文件是一个非常紧凑的二进制文件,所以加载的速度回快于 AOF 方式;

fork 子进程(bgsave)方式,不会阻塞

RDB 文件代表着 Redis 服务器的某一个时刻的全量数据,所以它非常适合做冷备份和全量复制的场景

缺点:没办法做到实时持久化,会存在丢数据的风险。定时执行持久化过程,如果在这个过程中服务器崩溃了,则会导致这段时间的数据全部丢失。

触发机制

全量复制

debug reload:进行一个debug级别的重启 不需要清空内存 并且在该过程仍会触发RDB文件的生成

shutdown:进行关闭的时候会进行 RDB文件的生成

多机实现

主从复制(保障高并发)

默认情况下,Redis所有节点都是主节点,节点之间互不干涉,而主从复制的节点则是划分了主节点(master)和从节点(slave)。

Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。

读操作:主库、从库都可以接收;

写操作:首先到主库执行,然后,主库将写操作同步给从库。

主从库间如何进行第一次同步?

当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。

哨兵机制

基本流程

哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知

监控是指哨兵进程在运行时,周期性地给所有的主从库发送 PING 命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的 PING 命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。

选主。 主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。

通知。在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

三项任务与目标如下图:

另一个角度阐释来说:

功能

1.集群监控,即时刻监控着redis的master和slave进程是否是在正常工作。

2.消息通知,就是说当它发现有redis实例有故障的话,就会发送消息给管理员。

3.故障自动转移

1.多个sentinel发现master有问题

2.选举一个sentinel作为领导

3.选举一个slave作为master

4.通知其它slave作为新的master的slave

5.通知客户端主从变化

6.等待老的master复活成为新的master的slave

4.充当配置中心,如果发生了故障转移,它会通知将master的新地址写在配置中心告诉客户端。

下线判断

哨兵对主库的下线判断有“主观下线”和“客观下线”两种。

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记为“主观下线

在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线

新库选择

一般来说,把哨兵选择新主库的过程称为“筛选 + 打分”。简单来说,我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:

哨兵集群

基于 pub/sub 机制的哨兵集群组成

哨兵实例之间可以相互发现,要归功于 Redis 提供的 pub/sub 机制,也就是发布 / 订阅机制。哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP 和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端口。然后多个哨兵实例之间就可以相互建立连接。

哨兵是如何知道从库的 IP 地址和端口的呢?这是由哨兵向主库发送 INFO 命令来完成的。就像下图所示,哨兵 2 给主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵 1 和 3 可以通过相同的方法和从库建立连接。

基于 pub/sub 机制的客户端事件通知

从本质上说,哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供 pub/sub 机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。

可以让客户端从哨兵这里订阅消息了。具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息。

哨兵leader选举,在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。以 3 个哨兵为例,假设此时的 quorum 设置为 2,那么,任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以了。

切片集群

切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。回到我们刚刚的场景中,如果把 25GB 的数据平均分成 5 份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。如下图所示:

Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。

具体的映射过程分为两大步:首先根据键值对的 key,按照CRC16 算法计算一个 16 bit 的值;然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

哈希槽分配:

我们在部署 Redis Cluster 方案时,可以使用 cluster create 命令创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。

当然, 我们也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。

客户端如何定位数据?

Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

在集群中,实例有新增或删除,Redis 需要重新分配哈希槽;

为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍。

实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致了。

Redis Cluster 方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

具体细节:接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己。若为否返回MOVED错误,指引客户端转向(redirect)至正确的节点。

哨兵(Sentinel)

1.哨兵集群至少要 3 个节点,来确保自己的健壮性。2.redis主从 + sentinel的架构,是不会保证数据的零丢失的,它是为了保证redis集群的高可用。

功能

1.集群监控,即时刻监控着redis的master和slave进程是否是在正常工作。

2.消息通知,就是说当它发现有redis实例有故障的话,就会发送消息给管理员。

3.故障自动转移

1.多个sentinel发现master有问题

2.选举一个sentinel作为领导

3.选举一个slave作为master

4.通知其它slave作为新的master的slave

5.通知客户端主从变化

6.等待老的master复活成为新的master的slave

4.充当配置中心,如果发生了故障转移,它会通知将master的新地址写在配置中心告诉客户端。

集群

当数据量过大一个主机放不下的时候,就需要对数据进行分区,将key按照一定的规则进行计算,并将key对应的value分配到指定的Redis实例上,这样的模式简称Redis集群。

主从节点的分布式器群,具有复制分片和高可用特性

针对海量数据+高并发+高可用的场景

数据分布算法

hash算法(大量缓存重建)

一致性hash算法(自动缓存迁移)+虚拟节点(自动负载均衡)

redis cluster的hash slot算法

步骤

将各个独立的节点连接起来,构成一个包含多个节点的集群。

集群的整个数据库被分为16384个槽(slot),将槽分配给不同节点。

接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己。若为否返回MOVED错误,指引客户端转向(redirect)至正确的节点。

常见问题

缓存雪崩

缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。(Redis崩溃导致的缓存失效也包括在内)

解决方法

事前

1.缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。

2.如果缓存数据库是分布式部署,将热点数据均匀分布在不同Redis库中。

3.设置热点数据永远不过期。

4.redis高可用,主从+哨兵,redis cluster,避免全盘崩溃

事中

本地ehcache缓存 + hystrix限流&降级,避免MySQL被打死

事后

redis持久化,快速恢复缓存数据

缓存击穿

缓存击穿是指缓存中没有但数据库中有的数据(热点key),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

解决方法:1.设置热点数据永远不过期。2.加互斥锁。

缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求 。

解决方法

1.接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;

2.空值设置缓存,key-null;

3.布隆过滤器。

过期策略

定时删除

定期删除

redis 默认是每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。

惰性删除

获取 key 的时候,如果此时 key 已经过期,就删除,不会返回任何东西

内存淘汰机制

allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key

allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key

volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key

volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key

volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

classLRUCacheextendsLinkedHashMap{

privatefinalintCACHE_SIZE;

/**

* 传递进来最多能缓存多少数据

* @param cacheSize 缓存大小

*/

publicLRUCache(intcacheSize) {

// true 表示让 linkedHashMap 按照访问顺序来进行排序,最近访问的放在头部,最老访问的放在尾部。

super((int)Math.ceil(cacheSize/0.75)+1,0.75f,true);

CACHE_SIZE=cacheSize;

}

@Override

protectedbooleanremoveEldestEntry(Map.Entryeldest) {

// 当 map中的数据量大于指定的缓存个数的时候,就自动删除最老的数据。

returnsize()>CACHE_SIZE;

   }

}

你可能感兴趣的:(2020-05-23)