Redis学习笔记(二)
洪笳淏 Lv4

两大纬度,三大主线

avatar

  • 两大维度

  • 应用纬度

  • 系统纬度

  • 三大主线

  • ==高性能主线==,包括线程模型、数据结构、持久化、网络框架

  • ==高可靠主线==,包括主从复制、哨兵机制

  • ==高可扩展主线==,包括数据分片、负载均衡

Redis的组件和功能

avatar

  1. Redis 主要通过网络框架进行访问,而不再是动态库了,这也使得 Redis 可以作为一个基础性的网络服务进行访问,扩大了 Redis 的应用范围。
  2. Redis 数据模型中的 value 类型很丰富,因此也带来了更多的操作接口,例如面向列表的 LPUSH/LPOP,面向集合的 SADD/SREM 等。
  3. Redis 的持久化模块能支持两种方式:日志(AOF)和快照(RDB),这两种持久化方式具有不同的优劣势,影响到 Redis 的访问性能和可靠性。
  4. Redis 支持高可靠集群和高可扩展集群

快速的Redis有哪些慢操作

Redis快在哪儿

  • Redis是内存数据库, 所有操作都在内存上完成,内存的访问速度本身就很快。
  • 归功于它的数据结构。键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,所以高效的数据结构是 Redis 快速处理数据的基础。

Redis的数据结构

底层数据结构

  底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。它们和数据类型的对应关系如下图所示:
avatar
  可以看到,String 类型的底层实现只有一种数据结构,也就是简单动态字符串。而 List、Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构。通常情况下,我们会把这四种类型称为集合类型,它们的特点是一个键对应了一个集合的数据

键和值用什么数据结构组织

  为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一 个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。
  在下图中,可以看到,哈希桶中的 entry 元素中保存了 key 和 value 指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过 value 指针被查找到。

avatar

  因为这个哈希表保存了所有的键值对,所以,我也把它称为==全局哈希表==。哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。
  查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系。也就是说, 不管哈希表里有 10 万个键还是 100 万个键,我们只需要一次计算就能找到相应的键。但是,当你往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险点,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。

为什么哈希表操作变慢了

  当你往哈希表中写入更多数据时,哈希冲突是不可避免的问题。这里的哈希冲突,也就是指,两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。毕竟,哈希桶的个数通常要少于 key 的数量,这也就是说,难免会有一些 key的哈希值对 应到了同一个哈希桶中。Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。如下图所示:entry1、entry2 和 entry3 都需要保存在哈希桶 3 中,导致了哈希冲突。此时,entry1 元素会通过一个next指针指向 entry2,同样,entry2 也会通过next指针指向 entry3。这样一来,即使哈希桶 3 中的元素有 100 个,我们也可以通过 entry 元素中的指针,把它们连起来。这就形成了一个链表,也叫作哈希冲突链。
avatar

  但是,这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。对于追求“快”的 Redis 来说,这是不太能接受的。所以,Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。那具体怎么做呢?
  为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:

  1. 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
  2. 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
  3. 释放哈希表 1 的空间。

  这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。为了避免这个问题,Redis 采用了渐进式 rehash。简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
avatar
  这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操 作,保证了数据的快速访问。

集合类数据的操作效率

  和 String 类型不同,一个集合类型的值,==第一步是通过全局哈希表找到对应的哈希桶位置==,==第二步是在集合中再增删改查==。那么,集合的操作效率和哪些因素相关呢?首先,与集合的底层数据结构有关。例如,使用哈希表实现的集合,要比使用链表实现的 集合访问效率更高。其次,操作效率和这些操作本身的执行特点有关,比如读写一个元素 的操作要比读写所有元素的效率高。

集合类数据的底层数据结构

  集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表。
  压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
avatar
  在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

我们再来看下跳表。

  有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:
avatar

  如果我们要在链表中查找 33 这个元素,只能从头开始遍历链表,查找 6 次,直到找到 33 为止。此时,复杂度是 O(N),查找效率很低。
  为了提高查找速度,我们来增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。例如,从前两个元素中抽取元素 1 作为一级索引,从第三、四个元素中抽取元素 11 作为一级索引。此时,我们只需要 4 次查找就 能定位到元素 33 了。
  如果我们还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。例如,从一级索引中抽取 1、27、100 作为二级索引,二级索引指向一级索引。这样,我们只需要 3 次查找,就能定位到元素 33 了。
  可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)。
avatar

不同操作的复杂度

  1. ==单元素操作==,是指每一种集合类型对单个数据实现的增删改查操作。例如,Hash 类型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等。这些操作的复杂度由集合采用的数据结构决定,例如,HGET、HSET 和 HDEL 是对哈希表做操作,所以它们的复杂度都是 O(1);Set 类型用哈希表作为底层数据结构时,它的 SADD、 SREM、SRANDMEMBER 复杂度也是 O(1)。集合类型支持同时对多个元素进行增删改查,例如 Hash 类型的 HMGET 和 HMSET,Set 类型的 SADD 也支持同时增加多个元素。此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的。例如,HMSET 增加 M 个元素时,复杂度就从 O(1) 变成 O(M) 了。
  2. ==范围操作==,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,比如 List 类型的 LRANGE 和 ZSet 类型的 ZRANGE。这类操作的复杂度一般是 O(N),比较耗时, 我们应该尽量避免。不过,Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于 HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞。
  3. ==统计操作==,是指集合类型对集合中所有元素个数的记录,例如 LLEN 和 SCARD。这类操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。
  4. ==例外情况==,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。

Redis选择不同数据结构的考量

  Redis 之所以能快速操作键值对,一方面是因为 O(1) 复杂度的哈希表被广泛使用,包括 String、Hash 和 Set,它们的操作复杂度基本由哈希表决定,另一方面,Sorted Set 也采用了 O(logN) 复杂度的跳表。不过,集合类型的范围操作,因为要遍历底层数据结构,复 杂度通常是 O(N)。这里,我的建议是:用其他命令来替代,例如可以用 SCAN 来代替, 避免在 Redis 内部产生费时的全集合遍历操作。
  当然,我们不能忘了复杂度较高的 List 类型,它的两种底层实现结构:双向链表和压缩列表的操作复杂度都是 O(N)。但是它的 POP/PUSH 效率很高,那么就将它主要用于 FIFO 队列场景,而不是作为一个可以随机读写的集合。另外,List底层使用压缩列表本质上是将所有元素紧挨着存储,所以分配的是一块连续的内存空间,虽然数据结构本身没有时间复杂度的优势,但是这样节省空间而且也能避免一 些内存碎片,它比链表占用的内存要更少。 Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。

高性能 IO 模型

  首先要厘清一个事实,说 Redis 是单线程模型,主要是指 Redis 的网络 IO 和键值对的读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。 但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

为什么使用单线程

  1. ==为什么不使用多线程==:并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。
  2. ==单线程 Redis 为什么快==:一方面,Redis 的大部分操作在内存上完成,再加上它使用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是 Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。

基本 IO 模型与阻塞点

  Redis 处理一个客户端发来的 Get 请求,要经过以下步骤:要监听客户端请求 (bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向 socket 中写回数据(send)。
  下图显示了这一过程,其中,bind/listen、accept、recv、parse 和 send 属于网络 IO 处理,而 get 属于键值数据操作。既然 Redis 是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操作。
avatar
  但是,在这里的网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直==未能成功建立起连接==时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果==数据一直没有到达==,Redis 也会一直阻塞在 recv()。这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。不过,幸运的是, socket 网络模型本身支持非阻塞模式。

非阻塞模式

  在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。
avatar
  针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,要注意的是,调用 accept() 时,已经存在监听套接字了。
  虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连 接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机 制继续监听该已连接套接字,并在有数据达到时通知 Redis。这样才能保证 Redis 线程,既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据。

到此,Linux 中的 IO 多路复用机制就要登场了。

基于多路复用的高性能 I/O 模型

  Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
  下图就是基于多路复用的 Redis IO 模型。图中的多个 FD 就是刚才所说的多个套接字。 Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理 上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
avatar
  为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来, Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时, Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
  以连接请求和读数据请求为例,具体解释一下:
  这两个请求分别对应 Accept 事件和 Read 事件,Redis 分别对这两个事件注册 accept 和 get 回调函数。当 Linux 内核监听到有连接请求或读数据请求时,就会触发 Accept 事件 和 Read 事件,此时,内核就会回调 Redis 相应的 accept 和 get 函数进行处理。

多线程模型

  随着硬件性能提升,Redis 的性能瓶颈可能出现网络 IO 的读写,也就是:单个线程处理网络读写的速度跟不上底层网络硬件的速度。读写网络的 read/write 系统调用占用了Redis 执行期间大部分CPU 时间,瓶颈主要在于网络的 IO 消耗。所以,Redis6.0版本 采用多个 IO 线程来处理网络请求,提高网络请求处理的并行度。需要注意的是,Redis 多 IO 线程模型只用来处理网络读写请求,对于 Redis 的读写命令,依然是单线程处理。这是因为,网络处理经常是瓶颈,通过多线程并行处理可提高性能。
架构图如下:
avatar
主线程与 IO 多线程是如何实现协作呢?
avatar
主要流程:

  1. 主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列;
  2. 主线程通过轮询将可读 socket 分配给 IO 线程;
  3. 主线程阻塞等待 IO 线程读取 socket 完成;
  4. 主线程执行 IO 线程读取和解析出来的 Redis 请求命令;
  5. 主线程阻塞等待 IO 线程将指令执行结果回写回 socket完毕;
  6. 主线程清空全局队列,等待客户端后续的请求。

思路:将主线程 IO 读写任务拆分出来给一组独立的线程处理,使得多个 socket 读写可以并行化,但是 Redis 命令还是主线程串行执行。

模型缺陷:
  Redis 的多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers 模型。Redis 的多线程方案中,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,却没有真正去执行命令。所有客户端命令最后还需要回到主线程去执行,因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。

AOF 日志

AOF 日志是如何实现的

  对于SQL这类的数据库,基本都是写前日志(Write Ahead Log, WAL),也就是在实际写数据前,先将数据记录在日志中,以便故障时恢复。AOF 正好相反,它是写后日志,也就是说先执行 Redis 命令写入数据,再记录日志。如下图所示:
avatar

  传统的数据库日志,记录的是修改后的数据,而 AOF 记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存下来的。我们以 Redis 收到“set testkey testvalue”命令后记录的日志为例,看看 AOF 日志的内容。其中,“*3”表示当前命令有三个部分,每部分都是由“$+数字”开头,后面紧跟着 具体的命令、键或值。这里,“数字”表示这部分中的命令、键或值一共有多少字节。例 如,“$3 set”表示这部分有 3 个字节,也就是“set”命令。
avatar

  AOF 采用写后日志的方式有两个好处:第一,不需要消耗额外的资源检查命令是否正确,只需要在成功写入之后更新日志即可;第二,因为是在写入后更新日志,不必担心被阻塞当前的写操作。
  但是这种方式也有天然的缺点,第一,如果在一条命令成功执行后服务器突然宕机,此时还没来得及在 AOF 日志中保存下这条命令,那么再重启服务后这条命令的写入/更新操作就不会被执行;第二,虽然不会阻塞当前的写操作,但是写日志也是在主进程中执行的,如果磁盘的IO压力很大导致写日志很慢,那么之后的 Redis 命令就会被阻塞。
  这两个风险都是和 AOF 写回磁盘的时机相关的。这也就意味着,如果我们能够控制一个写命令执行完后 AOF 日志写回磁盘的时机,这两个风险就解除了。

三种写回策略

  • Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
  • Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
  • No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

三种写回策略的比较:
avatar
  这时我们就要根据我们的系统的需求来进行 trade-off 了,如果想要保证高可靠,那么就采取 Always 的策略;如果追求的是高性能,那么久采用 no 策略,将控制写回的时机完全交给操作系统;如果想要兼顾两者,那就选择 everysec 策略。

AOF 重写机制

  随着时间的推移,Redis 的 AOF 日志文件会越来越大,此时可能有三种因素共同制约日志文件的大小:

  1. 操作系统对每个文件的大小上限有限制;
  2. 日志文件过大,每次要打开文件往里面写入,对性能的损耗就越大;
  3. 当使用 AOF 日志恢复数据时,由于 AOF 日志过大,整个恢复过程就会很慢。

  这个时候,AOF 重写机制就登场了。AOF 会根据日志的现状重新创建一个日志文件,对一个键值对的修改可能是经过很多条 Redis 命令后形成的最终的结果,此时只需要一条命令就能替换掉之前所有的命令。这样一来,一个键值对在重写日志中只用一条命令就行了,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。

下面这张图就是一个例子:
avatar

  重写机制虽然能够大大缩减日志文件的大小,但是可以想见,这必定是一个非常消耗资源的操作,那么 AOF 重写会阻塞主线程吗?为避免 AOF 重写阻塞主线程,重写过程会在后台起一个线程来完成。可以总结为一句话:“一个拷贝,两处日志”。

  • 一个拷贝:主线程 fork 一个线程来重写 AOF 日志时,会将主线程内存拷贝一份给子线程,这里面包含了数据库的最新数据,子线程就可以在不影响主线程的前提下逐一把拷贝的数据写成操作存入重写日志。
  • 两处日志:如果有写操作,第一处日志就是指正在使用的 AOF 日志,Redis 会把这个操作写到它的缓冲区。这样一来,即使宕机了,这 个 AOF 日志的操作仍然是齐全的,可以用于恢复;而第二处日志,就是指新的 AOF 重写日志。这个操作也会被写到重写日志的缓冲区。这 样,重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日 志记录的这些最新操作也会写入新的 AOF 文件,以保证数据库最新状态的记录。此时,我 们就可以用新的 AOF 文件替代旧文件了。
    avatar
       总结来说,每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用两个 日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。

注意点

  1. AOF 日志重写的时候,是由 bgrewriteaof 子进程来完成的,不用主线程参与,我们今天说的非阻塞也是指子进程的执行不阻塞主线程。但是,你觉得,这个重写过程有没有其他潜在的阻塞风险呢?如果有的话,会在哪里阻塞?
    答:fork子进程,fork这个瞬间一定是会阻塞主线程的(注意,fork时并不会一次性拷贝所有内存数据给子进程),fork采用操作系统提供的写实复制(Copy On Write)机制,就是为了避免一次性拷贝大量内存数据给子进程造成的长时间阻塞问题,但fork子进程需要拷贝进程必要的数据结构,其中有一项就是拷贝内存页表(虚拟内存和物理内存的映射索引表),这个拷贝过程会消耗大量CPU资源,拷贝完成之前整个进程是会阻塞的,阻塞时间取决于整个实例的内存大小,实例越大,内存页表越大,fork阻塞时间越久。拷贝内存页表完成后,子进程与父进程指向相同的内存地址空间,也就是说此时虽然产生了子进程,但是并没有申请与父进程相同的内存大小。那什么时候父子进程才会真正内存分离呢?“写实复制”顾名思义,就是在写发生时,才真正拷贝内存真正的数据,这个过程中,父进程也可能会产生阻塞的风险,就是下面介绍的场景。

fork出的子进程指向与父进程相同的内存地址空间,此时子进程就可以执行AOF重写,把内存中的所有数据写入到AOF文件中。但是此时父进程依旧是会有流量写入的,如果父进程操作的是一个已经存在的key,那么这个时候父进程就会真正拷贝这个key对应的内存数据,申请新的内存空间,这样逐渐地,父子进程内存数据开始分离,父子进程逐渐拥有各自独立的内存空间。因为内存分配是以页为单位进行分配的,默认4k,如果父进程此时操作的是一个bigkey,重新申请大块内存耗时会变长,可能会产阻塞风险。另外,如果操作系统开启了内存大页机制(Huge Page,页面大小2M),那么父进程申请内存时阻塞的概率将会大大提高,所以在Redis机器上需要关闭Huge Page机制。Redis每次fork生成RDB或AOF重写完成后,都可以在Redis log中看到父进程重新申请了多大的内存空间。

  1. AOF 重写也有一个重写日志,为什么它不共享使用 AOF 本身的日志呢?
    答:一个原因是父子进程写同一个文件必然会产生竞争问题,控制竞争就意味着会影响父进程的性能。二是如果AOF重写过程中失败了,那么原本的AOF文件相当于被污染了,无法做恢复使用。所以Redis AOF重写一个新文件,重写失败的话,直接删除这个文件就好了,不会对原先的AOF文件产生影响。等重写完成之后,直接替换旧文件即可。

RDB 内存快照

  上一节说到 AOF 日志将 Redis 的写命令逐一记录下来,在服务重启时再逐一执行恢复数据。如果 AOF 日志文件过大,这一过程通常是很缓慢的。这一节将讨论另一种持久化方式:内存快照。所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。这就类似于照片,当你给朋友拍照时,一张照片就能把朋友一瞬间的形象完全记下来。对 Redis 来说,它实现类似照片记录效果的方式,就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这个快照文件就称为 RDB 文件,其中,RDB 就是 Redis DataBase 的缩写。和 AOF 相比,RDB 记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我 们可以直接把 RDB 文件读入内存,很快地完成恢复。听起来好像很不错,但内存快照也并不是最优选项。为什么这么说呢?有另个问题要考虑:

  1. 我们应该对那些数据做快照?这关系到快照的执行效率问题;
  2. 做快照时,数据还能被增删改吗?这关系到 Redis 是否被阻塞,能否同时正常处理请求。

拿拍照片来举例子。我们在拍照时,通常要关注两个问题:

  1. 如何取景?也就是说,我们打算把哪些人、哪些物拍到照片中;
  2. 在按快门前,要记着提醒朋友不要乱动,否则拍出来的照片就模糊了。

给哪些数据做快照?

  Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中,这就类似于给 100 个人拍合影,把每一个人都拍进照片里。这样做的好处是,一次性记录了所有数据,一个都不少。同样,给内存的全量数据做快照, 把它们全部写入磁盘也会花费很多时间。而且,全量数据越多,RDB 文件就越大,往磁盘上写数据的时间开销就越大。对于 Redis 而言,它的单线程模型就决定了,我们要尽量避免所有会阻塞主线程的操作, 所以,针对任何操作,我们都会提一个灵魂之问:“它会阻塞主线程吗?”。RDB 文件的生成是否会阻塞主线程,这就关系到是否会降低 Redis 的性能。

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

  • save:在主线程中执行,会导致阻塞;
  • bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。

  我们就可以通过 bgsave 命令来执行全量快照,这既提供了数据的可靠性保证,也避免了对 Redis 的性能影响。

  接下来,我们要关注的问题就是,在对内存数据做快照时,这些数据还能“动”吗? 也就是说,这些数据还能被修改吗? 这个问题非常重要,这是因为,如果数据能被修改,那就意味着 Redis 还能正常处理写操作。否则,所有写操作都得等到快照完了才能执行,性能一下子就降低了。

快照时数据能修改吗?

  当我们采用 bgsave 方式来生成快照时,其实是 fork 了一个子进程。根据 Linux 中 fork 系统调用的规则,子进程在创建后并不会将父进程所有的内存数据都单独拷贝一份,而是拷贝一份页表,背后指向和父进程一样的物理地址。当有新的 Redis 写入命令时,原来的键值对发生了修改,那么就会启用 copy-on-write 思想,子进程会在内存中复制一份该键值对,并通过页表指向该新创建的地址,这就做到了在进行快照时,数据发生了修改,子进程仍然可以把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。
avatar
  这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影 响。到这里,我们就解决了对“哪些数据做快照”以及“做快照时数据能否修改”这两大问 题:Redis 会使用 bgsave 对当前内存中的所有数据做快照,这个操作是子进程在后台完 成的,这就允许主线程同时可以修改数据。

  现在,我们再来看另一个问题:多久做一次快照?我们在拍照的时候,还有项技术叫“连 拍”,可以记录人或物连续多个瞬间的状态。那么,快照也适合“连拍”吗?

可以每秒做一次快照吗?

  对于快照来说,所谓“连拍”就是指连续地做快照。这样一来,快照的间隔时间变得很短,即使某一时刻发生宕机了,因为上一时刻快照刚执行,丢失的数据也不会太多。但是,这其中的快照间隔时间就很关键了。
  如下图所示,我们先在 T0 时刻做了一次快照,然后又在 T0+t 时刻做了一次快照,在这期间,数据块 5 和 9 被修改了。如果在 t 这段时间内,机器宕机了,那么,只能按照 T0 时刻的快照进行恢复。此时,数据块 5 和 9 的修改值因为没有快照记录,就无法恢复了。
avatar
  所以,要想尽可能恢复数据,t 值就要尽可能小,t 越小,就越像“连拍”。那么,t 值可以小到什么程度呢,比如说是不是可以每秒做一次快照?毕竟,每次快照都是由 bgsave 子进程在后台执行,也不会阻塞主线程。这种想法其实是错误的。虽然 bgsave 执行时不阻塞主线程,但是,如果频繁地执行全量快照,也会带来两方面的开销

  1. 一方面,对于磁盘而言,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
  2. 另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后 不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越 大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了。

  此时,我们可以做增量快照,所谓增量快照,就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。

  在第一次做完全量快照后,T1 和 T2 时刻如果再做快照,我们只需要将被修改的数据写入快照文件就行。但是,这么做的前提是,我们需要记住哪些数据被修改了。你可不要小瞧 这个“记住”功能,它需要我们使用额外的元数据信息去记录哪些数据被修改了,这会带来额外的空间开销问题。如下图所示:
avatar
  如果我们对每一个键值对的修改,都做个记录,那么,如果有 1 万个被修改的键值对,我们就需要有 1 万条额外的记录。而且,有的时候,键值对非常小,比如只有 32 字节,而记录它被修改的元数据信息,可能就需要 8 字节,这样的画,为了“记住”修改,引入的额外空间开销比较大。这对于内存资源宝贵的 Redis 来说,有些得不偿失。

  到这里,你可以发现,虽然跟 AOF 相比,快照的恢复速度快,但是,快照的频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销,那么,还有什么方法既能利用 RDB 的快速恢复,又能以较小的开销做到尽量少丢数据呢?我们再仔细想一想,使用 AOF 日志不是能很好的记录修改吗。

混合使用 AOF 日志和内存快照

  简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
avatar
  这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势。

主从库同步

  当启动多个 Redis 实例的时候,它们之间就可以通过 replicaof (Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据:

1
replicaof 172.16.19.3 6379

主从库第一次同步

三个阶段:
avatar

  • 阶段一:
      主从库建立连接,协商同步,主要是为全量复制做准备。具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。
    runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设 为“?”。
    offset,此时设为 -1,表示第一次复制。

  • 第二阶段
      主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库主库执行 bgsave 命令,把当前所有的数据都复制给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。

  • 第三阶段
      在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

主从级联分担全量复制时主库的压力

  对于主库来说,完成第一次主从同步,即一次全量复制,有两个耗时的操作:生成 RDB 文件和传输 RDB 文件。如果从库过多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量同步。fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。可以使用“主-从-从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。
avatar

主从库间的网络断了怎么办?

  在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。听名字大概就可以猜到它和全量复制的不同:全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。那么,增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于 repl_backlog_buffer 这个缓冲区。我们先来看下它是如何用于增量命令的同步的。
  当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己 已经读到的位置。刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大。同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。
avatar
avatar
  ,因为 repl_backlog_buffer 是一个环形缓冲区,所以在 缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。针对这种情况,一方面,可以根据 Redis 所在服务器的内存资源再适当增加 repl_backlog_size 值,比如说设置成缓冲空间大小的 4 倍,另一方面,可以考虑使用切片集群来分担单个主库的请求压力。

为什么使用 RDB 文件进行主从复制,而不是用 AOF?

  1. 相同数据下,AOF 文件比 RDB 更大,因此需要的网络带宽更多;
  2. 在恢复数据时,使用RDB更快。RDB 是数据库的快照文件,记录的是某一时刻数据库中所有键值对的状态,而 AOF 文件记录的是所有的写 Redis 命令,在做全量复制的时候会需要大量的时间;
  3. 如果使用AOF文件来同步相对来说丢的数据更少,但是不表示不丢数据。即也需要第三 个阶段来保证数据的一致性。因此相对来说使用RDB开销更小些。

哨兵机制

  在主从模式下。如果从库挂了,那么客户端的请求会被其他正常工作的从库接收。但如果是主库挂了,那么客户端的写请求将不再能够被处理,如下图所示:
avatar
  无论是写服务中断,还是从库无法同步数据,都是不可接受的。所以,如果主库挂了,我们就需要运行一个新的主库,比如手将一个从库切换为主库。这就涉及三个问题:

  1. 主库真的挂了吗?
  2. 该选择哪个从库作为主库?
  3. 怎么把新主库的相关信息通知给从库和客户端呢?

基本流程

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

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

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

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

avatar

监控

主观下线和客观下线

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

  首先,我们要知道啥叫误判。很简单,就是主库实际并没有下线,但是哨兵误以为它下线了。误判一般会发生在集群网络压力较大、网络拥塞,或者是主库本身压力较大的情况下。哨兵机制通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群来减少误判。引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一个客观事实了。这个判断原则就是:少数服从多数。同时,这会进一步触发哨兵开始主从切换流程。
avatar

  • 客观下线
      简单来说,“客观下线”的标准就是,当有 N 个哨兵实例时,最好要有 N/2 + 1 个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”。这样一来,就可以减少误判 的概率,也能避免误判带来的无谓的主从库切换。(当然,有多少个实例做出“主观下线”的判断才可以,可以由 Redis 管理员自行设定)。

选主

  我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:
avatar

  • 一定的筛选条件
      在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。

  • 一定的规则
      我们可以分别按照三个规则依次进行三轮打分,这三个规则分别是从库优先级、从库复制进度以及从库 ID 号。只要在某一轮中,有从库得分最高,那么它就是主库了,选主过程到此结束。如果没有出现得分最高的从库,那么就继续进行下一轮。

  1. 第一轮:优先级最高的从库得分高。
     &esmsp;用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。比如,你有两个从库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。在选主时,哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。如果从库的优先级都一样,那么哨兵开始第二轮打分。

  2. 第二轮:和旧主库同步程度最接近的从库得分高。
      这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数据。主从库同步时有个命令传播的过程。在这个过程中,主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 这个值记录当前的复制进度。此时,我们想要找的从库,它的 slave_repl_offset 需要最接近 master_repl_offset。如果在所有从库中,有从库的 slave_repl_offset 最接近 master_repl_offset,那么它的得分就最高,可以作为新主库。就像下图所示,旧主库的 master_repl_offset 是 1000,从库 1、2 和 3 的 slave_repl_offset 分别是 950、990 和 900,那么,从库 2 就应该被选为新主库。
    avatar
    当然,如果有两个从库的 slave_repl_offset 值大小是一样的(例如,从库 1 和从库 2 的 slave_repl_offset 值都是 990),我们就需要给它们进行第三轮打分了。

  3. 第三轮:ID 号小的从库得分高。

哨兵集群的原理

  我们通常通过使用哨兵集群来对主从库的状态进行监控并在主库挂掉时选出新的主库。那么哨兵出现故障呢?实际上,一旦多个实例组成哨兵集群,即使一个实例挂掉,剩下的哨兵依然会继续履行原始的职能,通过哨兵集群的配置项(如下所示),我们可以发现,哨兵在建立的一开始对彼此是没有感知的,它们各自知道的只有 Redis 主库的信息。

1
sentinel monitor <master-name> <ip> <redis-port> <quorum>

  在配置哨兵的信息时,我们只需要用到下面的这个配置项,设置主库的 IP 和端口,并没有配置其他哨兵的连接信息。

基于 Pub/Sub 机制的哨兵集群的相互感知

  哨兵之间能够相互发现,要归功于 Redis 的pub/sub 机制,即发布/订阅机制。哨兵只要和主库建立了连接,就可以在主库上发布消息了,比如说发布它自己的连接信 息(IP 和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端口。
  除了哨兵实例,我们自己编写的应用程序也可以通过 Redis 进行消息的发布和订阅。所以,为了区分不同应用的消息,Redis 会以频道的形式,对这些消息进行分门别类的管理。所谓的频道,实际上就是消息的类别。当消息类别相同时,它们就属于同一个频道。 反之,就属于不同的频道。只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换。
  在主从集群中,主库上有一个名为“__sentinel__:hello”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
  举个例子,具体说明一下。在下图中,哨兵 1 把自己的 IP(172.16.19.3)和端口 (26579)发布到“__sentinel__:hello”频道上,哨兵 2 和 3 订阅了该频道。那么此时,哨兵 2 和 3 就可以从这个频道直接获取哨兵 1 的 IP 地址和端口号。然后,哨兵 2、3 可以和哨兵 1 建立网络连接。通过这个方式,哨兵 2 和 3 也可以建立网络连接,这样一来,哨兵集群就形成了。它们相互间可以通过网络连接进行通信,比如说对主库有没有下线这件事儿进行判断和协商。
avatar
  哨兵除了彼此之间建立起连接形成集群外,还需要和从库建立连接。这是因为,在哨兵的监控任务中,它需要对主从库都进行心跳判断,而且在主从库切换完成后,它还需要通知从库,让它们和新主库进行同步。

哨兵如何何知道从库的 IP 地址和端口

  哨兵向主库发送 INFO 命令来完成的。就像下图所示,哨兵 2 给主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵 1 和 3 可以通过相同的方法和从库建立连接。
avatar
  通过 pub/sub 机制,哨兵之间可以组成集群,同时,哨兵又通过 INFO 命令,获得了从库连接信息,也能和从库建立连接,并进行监控了。

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

  哨兵不能只和主、从库连接。因为,主从库切换后,客户端也需要知道新主库的连接信息,才能向新主库发送请求操作。所以,哨兵还需要完成把新主库的信息告诉客户端这个任务。而且,在实际使用哨兵时,我们有时会遇到这样的问题:如何在客户端通过监控了解哨兵进行主从切换的过程呢?比如说,主从切换进行到哪一步了?这其实就是要求,客户端能够获取到哨兵集群在监控、选主、切换这个过程中发生的各种事件。
  从本质上说,哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供 pub/sub 机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。
avatar
  知道了这些频道之后,你就可以让客户端从哨兵这里订阅消息了。具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息。

举个例子,你可以执行如下命令,来订阅“所有实例进入客观下线状态的事件”:

1
SUBSCRIBE +odown

当然,你也可以执行如下命令,订阅所有的事件:

1
PSUBSCRIBE *

  当哨兵把新主库选择出来后,客户端就会看到下面的 switch-master 事件。这个事件表示主库已经切换了,新主库的 IP 地址和端口信息已经有了。这个时候,客户端就可以用这里面的新主库地址和端口进行通信了。

1
switch-master <master name> <oldip> <oldport> <newip> <newport>

由哪个哨兵执行主从切换?

  实际上,选主和执行主从切换这两个流程是分裂的,并不是由发现主库下线的哨兵直接执行,而也是通过“投票”的方式选出执行的哨兵。哨兵集群要判定主库“客观下线”,需要有一定数量的实例都认为该主库已经“主观下线”了。任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送 is-master-downby-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相 当于赞成票,N 相当于反对票。
avatar

  一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。这个所需的赞成票数是通过哨兵配置文件中的 quorum 配置项设定的。例如,现在有 5 个哨兵,quorum 配置的是 3,那么,一个哨兵需要 3 张赞成票,就可以标记主库为“客观下线”了。这 3 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。
  此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader 选举”。因为最终执行主从切换的哨兵 称为 Leader,投票过程就是确定 Leader。在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。以 3 个哨兵为例,假设此时的 quorum 设置为 2,那么,任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以了。

下面展示一下 3 个哨兵、quorum 为 2 的选举过程。
avatar
  如果 S3 没有拿到 2 票 Y,那么这轮投票就不会产生 Leader。哨兵集群会等待一段时间(也就是哨兵故障转移超时时间的 2 倍),再重新选举。这是因为,哨兵集群能够进行成功投票,很大程度上依赖于选举命令的正常网络传播。如果网络压力较大或有短时堵塞, 就可能导致没有一个哨兵能拿到半数以上的赞成票。所以,等到网络拥塞好转之后,再进行投票选举,成功的概率就会增加。

思考

  假设有一个 Redis 集群,是“一主四从”,同时配置了包含 5 个哨兵实例的集群,quorum 值设为 2。在运行过程中,如果有 3 个哨兵实例都发生故障了,此时,Redis 主库如果有故障,还能正确地判断主库“客观下线”吗?如果可以的话,还能进行主从库自动切换吗?
答:1、可以正确的判断主库“客观下线”,以为其中一个哨兵已经获得了“客观下线”所需要 的投票数;2、不能进行自动的主从切换,因为在主从切换的时候,必须选择出一个主哨兵,但是选择 主哨兵有2个条件:2.1、获得半数以上的票数;2.2、票数大于等于 quorum 值。在已经有3个哨兵挂掉的情况下不可能获得超过半数的票。

Redis 切片集群

  我们先设想一个场景,如果要存储一个很大规模的数据,例如要用 Redis 保存 5000 万个键值对,每个键值对大约是 512B,粗略地计算了一下,这些键值对所占的内存空间大约是 25GB(5000 万 *512B)。选择一台 32 GB 内存的主机来部署 Redis,32GB 的内存能保存所有数据,而且还留有 7GB,可以保证系统的正常运行。同时采用 RDB 对数据做持久化,以确保 Redis 实例故障后,还能从 RDB 恢复数据。但是,在实际的使用场景中,这种方式可能会导致 Redis 的响应相当慢,这跟 Redis 的持久化机制有关系。在使用 RDB 进行持久化时,Redis 会 fork 子进程来完成,fork 操作的用时和 Redis 的数据量是正相关的,而 fork 在执行时会阻塞主线程。数据量越大,fork 操作造成的主线程阻塞的时间越长。所以,在使用 RDB 对 25GB 的数据进行持久化时,数据量较大,后台运行的子进程在 fork 创建时阻塞了主线程,于是就导致 Redis 响应变慢了。
  这样看来,光是通过扩容内存来解决数据量大的问题会引入新的问题。这时我们可以考虑 Redis 的切片集群,它可以保存大量数据,而且对 Redis 主线程的阻塞影响较小。

基本概念

  切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。回到我们刚刚的场景中,如果把 25GB 的数据平均分成 5 份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。如下图所示:
avatar
  那么,在切片集群中,实例在为 5GB 数据生成 RDB 时,数据量就小了很多,fork 子进程一般不会给主线程带来较长时间的阻塞。采用多个实例保存数据切片后,我们既能保存25GB 数据,又避免了 fork 子进程阻塞主线程而导致的响应突然变慢。

如何保存更多数据

  为了保存大量数据,我们可以使用大内存云主机和切片集群两种方法。实际上,这两种方法分别对应着 Redis 应对数据量增多的两种方案:纵向扩展(scale up)和横向扩展(scale out)。

  • 纵向扩展:升级单个 Redis 实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的 CPU。就像下图中,原来的实例内存是 8GB,硬盘是 50GB,纵向扩展后, 内存增加到 24GB,磁盘增加到 150GB。
  • 横向扩展:横向增加当前 Redis 实例的个数,就像下图中,原来使用 1 个 8GB 内存、 50GB 磁盘的实例,现在使用三个相同配置的实例。
    avatar

数据切片和实例的对应分布关系

  在切片集群中,数据需要分布在不同实例上,那么,数据和实例之间如何对应呢?这就和接下来我要讲的 Redis Cluster 方案有关了。不过,我们要先弄明白切片集群和 Redis Cluster 的联系与区别。实际上,切片集群是一种保存大量数据的通用机制,这个机制可以有不同的实现方案。在 Redis 3.0 之前,官方并没有针对切片集群提供具体的方案。从 3.0 开始,官方提供了一个名为 Redis Cluster 的方案,用于实现切片集群。Redis Cluster 方案中就规定了数据和实例的对应规则。
  Redis Cluster 方案采用哈希槽来处理数据与实例的映射关系。一个 Redis 切片集群共有 16384 个哈希槽,,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。
  具体的映射分为两步:

  1. 根据键值对的 key 计算出一个哈希值,匹配到某一个哈希槽上
  2. 将具体的哈希槽与 Redis 切片实例关联上

  我们在部署 Redis Cluster 方案时,可以使用 cluster create 命令创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。我们也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。假设集群中不同 Redis 实例的内存大小配置不一,如果把哈希槽均分在各个实例上,在保存相同数量的键值对时,和内存大的实例相比,内存小的实例就会有更大的容量压力。遇到这种情况时,你可以根据不同实例的资源配置情况,使用 cluster addslots 命令手动分配哈希槽。
  数据、哈希槽、实例这三者的映射分布情况如下图所示:
avatar
  示意图中的切片集群一共有 3 个实例,同时假设有 5 个哈希槽,我们首先可以通过下面的命令手动分配哈希槽:实例 1 保存哈希槽 0 和 1,实例 2 保存哈希槽 2 和 3,实例 3 保存哈希槽 4。

1
2
3
redis-cli -h 172.16.19.3 –p 6379 cluster addslots 0,1 
redis-cli -h 172.16.19.4 –p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5 –p 6379 cluster addslots 4

客户端如何定位数据

  一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?这是因为,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。
  但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

  1. 在集群中,实例有新增或删除,Redis 需要重新分配哈希槽;
  2. 为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍。

  此时,实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致了。Redis Cluster 方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。那客户端又是怎么知道重定向时的新实例的访问地址呢?当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址。

1
GET hello:key (error) MOVED 13320 172.16.19.5:6379

  其中,MOVED 命令表示,客户端请求的键值对所在的哈希槽 13320,实际是在 172.16.19.5 这个实例上。通过返回的 MOVED 命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。这样一来,客户端就可以直接和 172.16.19.5 连接,并发送操作请求了。

  如下图所示,由于负载均衡, Slot 2 中的数据已经从实例 2 迁移到了实例 3,但是,客户端缓存仍然记录着“Slot 2 在实例 2”的信息,所以会给实例 2 发送命令。实例 2 给客户端返回一条 MOVED 命令,把 Slot 2 的最新位置(也就是在实例 3 上),返回给客户端,客户端就会再次向实例 3 发送请求,同时还会更新本地缓存,把 Slot 2 与实例的对应关系更新过来。
avatar

  在实际的使用过程中还会出现一种情况,客户端向实例 2 发送请求,但此时,Slot 2 中的数据只有一部分迁移到了实例 3,还有部分数据没有迁移。在这种迁移部分完成的情况下,客户端就会收到一条 ASK 报错信息,如下所示:

1
2
GET hello:key 
(error) ASK 13320 172.16.19.5:6379

  这个结果中的 ASK 命令就表示,客户端请求的键值对所在的哈希槽 13320,在 172.16.19.5 这个实例上,但是这个哈希槽正在迁移。此时,客户端需要先给 172.16.19.5 这个实例发送一个 ASKING 命令。这个命令的意思是,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送 GET 命令,以读取数据。
avatar

  和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。所以,在上图中,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求。这也就是说,ASK 命令的作用只是让客户端能给新实例发送一次请求,而不像 MOVED 命令那样,会更改本地缓存,让后续所有命令都发往新实例。

  • Post title:Redis学习笔记(二)
  • Post author:洪笳淏
  • Create time:2022-01-05 11:10:00
  • Post link:https://jiahaohong1997.github.io/2022/01/05/Redis学习笔记(二)/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.
 Comments