Redis学习笔记

此文章用来记录Redis学习笔记,学习路径是极客时间上蒋德钧老师的《Redis核心技术与实战》 

1.解构键值型数据库

我们知道Redis是一个典型的键值型数据库,那一个普通的键值型数据库整体上应该有哪几个模块组成呢?

一个键值型数据库主要包括以下几个模块:访问框架、操作模块、索引模块、存储模块

如下图所示:

 

2.Redis的数据结构

Redis支持的数据结构,也即数据存储类型是我们熟知的五大种:字符串(string)、列表(list)、哈希(hash)、集合(set)、有序集合(sorted set)

其实,我们常说的这五种数据结构只是Redis在键值对中存储的数据形式,可以理解为是经过Redis底层操作或者修改之后展示给客户端看到的数据结构。那Redis底层真正的数据结构是什么呢?

简单来说,Redis底层的数据结构是6种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。他们和数据类型的对应关系如下图所示:

Redis用一个全局哈希表来存储键值对,每一个键值对作为一个哈希桶被保存在全局哈希表中,即可以通过O(1)的时间复杂度找到这个哈希桶,即找到键值对的数据。但是哈希桶中存的并不是真正的键值对数据,而是指向他们的指针。所以,哈希桶中存的数据就不受数据类型的限制,所有的数据类型都存在哈希桶中,并通过指针找到真正的数据。

那底层的这几种数据类型是怎么组织数据呢? 

我们先介绍一下压缩列表和跳表这两种数据结构。

  • 压缩列表其实类似数组,但是他和普通数组不一样的地方在于,压缩列表在表头有三个字段 zlbytes、zltail和zllen,分别表示列表长度、列表尾的偏移量和列表中entry的个数,在表尾还有一个zlend,表示列表结束。

      这样的数据结构,保证了再查找压缩列表查找第一个和最后一个元素的时候是O(1)的时间复杂度,但是查找其他的元素的时候仍然是O(n)的复杂度。

  • 跳表是基于有序链表的改进,是通过增加多级索引来实现快速查找数据,整个过程是跳着找数据,所以被称为跳表。如下图所示:

现在我们来整理下这几个数据结构的时间复杂度:

Redis 数据类型丰富,每个类型的操作繁多,我们通常无法一下子记住所有操作的复杂度。所以,最好的办法就是掌握原理,以不变应万变。

 

3.单线程的Redis为什么那么”快“

我们知道,Redis是单线程的,那为什么单线程的Redis能做到那么快呢?因为在我们的认知里,多线程要优于单线程。下面我们就来分下下底层的原因。

  • 首先,我们所说的Redis是单线程是说Redis的网络IO和键值对的读写是通过一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程。但是Redis的其他功能,比如持久化,异步删除,集群数据同步等都是由额外的线程来执行的。所以严格意义上说,Redis并不是单纯的单线程。
  • 我们通常说的多线程更有优势是基于多线程的应用建立在很优秀的架构和设计上的,如果没有良好的架构设计,多线程的性能可能并没有我们想象中的那么好,最简单的一点就是多线程意味着更多的资源消耗,而解决和协调多线程之间的资源会是一个很麻烦的程序,而这个麻烦的过程本身就会消耗很多的资源。下面这个图能展示实际中线程数和吞吐率的关系:

下面我们来看看为什么单线程的Redis能做到那么快~

概括起来主要是两点。

  1. 第一是Redis大部分操作是在内存上操作的,而且得益于Redis底层高效的数据结构,比如哈希表和跳表,这是其中的一个原因。
  2. 第二就是Redis采用了多路复用的技术,使其在网络IO中能并发处理大量的客户端请求。

在了解多路复用技术时,我们先来了解下一个IO网络模型都包括哪些环节,并且哪些环节是容易造成阻塞的。

以一个get请求为例,Redis接收客户端的一个get请求,大致经历了以下几个环节。

  1. Redis需要监听客户端请求(bind/listen);
  2. 和客户端建立链接(accept);
  3. 从socket中读取请求(recv);
  4. 解析客户端发送的请求(parse);
  5. 根据请求类型读取键值数据(get);
  6. 给客户端返回结果,即往socket中写回数据(send)。

下图总结了下以上的几个环节:

以上的环节中,存在阻塞风险的分别是acceptrecv环节。

在Redis监听到客户端有一个请求过来,但是一直没有建立请求,将会阻塞在accept环节,这会导致后面的请求都被阻塞。

当Redis通过recv从客户端读取数据的时候,如果一直没有数据读取进来,也将会阻塞在recv环节。

不过,还有一种非阻塞的网络模式,那就是socket网络模式。

socket之所以能做到非阻塞是因为有三个函数的存在。

在socket模型中,不同的请求会返回不同类型的套接字。

socket()方法会返回主动套接字,然后调用linsten()方法,将主动套接字转换为监听套接字,此时,可以监听客户端请求。最后,调用accept()方法接收客户端的请求,并返回已连接套接字。

Redis套接字类型与非阻塞设置

当然,我们针对已连接的套接字也可以设置非阻塞模式。

多路复用的高性能IO模型就是以上的模式的实现

Linux中的IO多路复用机制指的是允许一个线程处理多个IO流,就是我们常听说的select/epoll机制。简单来说,就是允许Redis在单线程运行中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的链接请求和数据请求,一旦有请求到达,就通知Redis处理,从而达到一个Redis线程处理多个IO流的效果。

下图解释了多路复用机制的实现:

 

FD即为套接字。

 

4.Redis的数据备份机制之AOF

我们知道,Redis有两种数据备份机制,AOFRDB。我们先来看看AOF机制。

AOF即Append Only File,从命名也可以看出来AOF模式就是文件存储的方式。

Redis在每次写入数据之后,都会将执行的命令写入日志中。和数据库的redo log(重做日志)记录修改之后的数据不同的是,Redis的日志文件中记录的是一条条的执行命令,这些命令以文本形式保存。

我们以set testkey testvalue这条命令来看下Redis的日志中记录的是什么内容:

其中,“*3”表示当前命令有三个部分,每部分都是由“$+数字”开头,后面紧跟着具体的命令、键或值。这里,“数字”表示这部分中的命令、键或值一共有多少字节。例如,“$3 set”表示这部分有 3 个字节,也就是“set”命令。

为了避免检查语句的开销,Redis在每次将执行命令写入日志中的时候,并不会检查命令的正确与否。所以Redis采用的是命令执行成功之后才会将其写入日志中。这样还有一个好处,在命令执行完之后再写入日志,不会阻塞当前的写操作。

但是AOF还是存在两个潜在的风险:

  1. 一是如果刚执行完一条命令,还没来得及写入日志就宕机了,就会导致这条命令在日志中保存不上,如果发生恢复数据的操作,就会导致这条数据丢失。
  2. 二是写日志的操作,虽然不会对当前的写操作造成阻塞,但是会会下一个请求造成阻塞,因为写日志的操作是在磁盘上进行的,磁盘写入压力太大时,会造成后续操作的阻塞。

以上两个风险都是基于日志写入磁盘的风险,Redis针对这个问题,提供了三种日志回写策略:

Always:同步写回,每个命令执行完立即写入日志

Everysec:每秒写回,每个命令执行完,先写入内存缓冲区,每隔一秒写入日志一次

No:由操作系统控制写回,操作系统控制何时将内存缓冲区的内容写入日志

下图总结了三种方式的优缺点:

我们知道,一般的日志文件都是很大的,不管是业务上的日志文件,还是MySQL等数据的binlog和redolog日志,因为每次的操作都会被记录下来。同样,Redis的AOF日志也会有很多,那当日志文件太大时就会出现性能问题,这与Redis追求高性能的特点是不相符的。所以,针对这个问题,Redis有一个AOF重写机制。

重写机制就是Redis会新建一个AOF日志文件,并根据当前数据库键值对的数据情况,为每个键值对创建一个命令,来写入重写日志中。重写日志还有一个好处,那就是”多变一“,就是把原来旧文件中的多条命令合并成了一个命令。这个怎么理解呢?我们知道,旧的AOF日志文件是每次操作都会写入,那就会有一个命令重复更新多次,就会被记录多次的操作日志,而重新日志是根据当前的状态生成的一个命令,所以,就保存当前的状态就可以,而不用去记录之前的操作过程,所以就省去了很多没用的操作记录。

具体如下图的例子:

AOF日志是有主线程写入,但是重写机制是在后台子进程gbrewriteaof来完成的,这也是为了避免阻塞主线程。

Redis的AOF重写机制,可以总结为”一个拷贝 两处日志“

  • 一个拷贝即是,每次执行重写时,主线程都会fork出后台bgrewriteaof子进程。fork会把主线程的内存拷贝一份给子进程,这样子进程就拿到了数据库的最新数据,就可以做重写操作了,又不会阻塞主线程的正常操作。
  • 两处日志即是,主线程继续将新的操作写入AOF日志,而子进程也会进行日志重写操作,将”简化后“的日志写入新的AOF日志,完成日志重写过程。

在重写过程中新写入的操作也会被记录到重写日志的缓冲区,这样就保证了重写日志也能记录到最新的操作请求,等重写日志完成,我们就可以用新的AOF日志来替代旧的日志。

这样可以在保证请求不中断的情况下,给AOF日志”瘦身“和”减负“。

 

5.Redis备份机制之RDB

Redis的AOF日志机制最大程度保证了数据的完整和安全,但是有一个问题就是,在执行AOF日志恢复的时候需要一条条的命令执行写入,这样的操作是很慢的,会导致数据恢复的很慢,这在有些业务场景会影响到正常使用。所以,我们就需要第二种能快速恢复数据的备份机制--RDB(Redis DataBase)

RDB机制是快照机制,就是将某个时间点的数据做个快照,我们知道快照恢复是很快的。

RDB有两个问题需要考虑,一个是对哪些数据做快照,另一个问题是对数据做快照时,还能正常写入操作数据吗?

首先肯定是要对全量数据做快照,这里就有另一个问题,全量快照会是一个很大的工程,必将会很耗时耗资源,那Redis是怎么处理的呢?

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

  • save:在主线程执行,这会导致阻塞
  • bgsave:开辟一个子进程来生成RDB文件,这也是Redis 默认的方式

接下来看第二个问题,生成快照时,如何能保证快照数据的实时完整又不影响数据正常的读取呢?

这里就要借助操作系统提供的写时复制技术(Copy-On-Write,COW),在执行快照的时候正常处理读写操作。

Redis开辟了一个子进程bgsave来执行执行RDB,如果是读操作,那子进程和主线程互不影响。那主要解决的问题就是写操作如何保证同步,并不影响主线程。

解决方式就是,在主线程有写操作的时候会复制一份数据出来,生成该数据的副本。然后子进程bgsave就可以把这个副本数据写进快照中,这样就解决了写操作同步的问题。

如下图所示:

解决完上面两个问题,还有一个问题需要我们考虑。应该多久做一次快照?

  • 如果时间太久,则数据的完整性就不能很好的保证。
  • 如果时间太短,则会导致资源消耗过多,而且全量快照需要fork子进程,虽然子进程不会阻塞主线程,但是fork子进程这个过程本身会阻塞主线程,所以频繁的fork子进程来创建快照也是会阻塞主线程的,进而影响性能。

有一种解决办法是我们在第一次全量快照之后,后续的快照用增量快照的方式进行,就是我们只保存修改的数据,对没有改动的数据快照也不动。但是这样的话,我们需要去知道哪些数据没有被修改,这个”记住“数据没有被修改,就需要用额外的元数据去记录哪些数据被修改了,这将会带来更多额外的空间消耗。这样做是有些得不偿失的。

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

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。

这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势,颇有点“鱼和熊掌可以兼得”的感觉。

 

6.主从库如何实现数据一致

我们知道Redis是以集群的方式部署服务,具体实现就是读写分离的主从库模式。那主从库之间是怎么做到数据同步的呢?

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

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

例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据:


replicaof  172.16.19.3  6379

接下来,我们就要学习主从库间数据第一次同步的三个阶段了。你可以先看一下下面这张图,有个整体感知,接下来我再具体介绍。

1.第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。

  具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。

  主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。

2.在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。

  具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。

  但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。

3.最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。

  具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

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

从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。

当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。

repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

  • 刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大。
  • 同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。

不过,有一个地方我要强调一下,因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。因此,我们要想办法避免这一情况,一般而言,我们可以调整 repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值。

 

7.主库挂了如何保证服务不中断--哨兵机制

Redis的主从集群模式中,如果主库挂掉了如何能保证服务保持正常呢?这里面涉及到几个问题,一个是如何判断主库是否挂了。二是如果主库挂了应该选择哪个从库作为主库。三是如何把新主库的信息传给从库和客户端。

以上三个问题就需要哨兵机制来解决,那哨兵的主要任务就对应以上三个问题:监控、选主、通知

监控任务中,哨兵通过周期性的给主库和其他从库发送ping命令来判断其服务是否正常。

如果是从库,没有在规定时间内响应哨兵的ping命令,那哨兵就把从库标记为”主观下线“。从库的影响不大,一个哨兵标记为下线即为下线状态,对整体服务影响不大。但是主库就不一样了,因为这个下线状态会存在”误判“的情况。如果集群网络压力较大,网络拥塞,或者主库本身压力比较大的情况下,容易导致误判。

那为了解决误判的情况,就需要引入多个哨兵,来组成哨兵集群来判断主库的状态。这里就要引入”主观下线“和”客观下线“两个概念。如果一个哨兵判断主库下线,那会把主库标记为”主观下线“,如果有多个哨兵都判断主库下线,那主库才会被标记为”客观下线”,如果被标记为客观下线,那就表示主库是真的挂了,就要开始选择新的主库。

下面的图可以帮助理解,哨兵集群少数服从多数地判断主库“客观下线”的过程:

如何选定新主库?

哨兵选主的过程可以总结为“筛选“+”打分“的过程。

即按一定的条件把从库筛选一遍,然后再按一定的条件,给筛选出来的从库打分,获得分数高的从库即成为新的主库。

在筛选从库的时候,除了要检查从库当前的网络连接状况,还要判断它之前的网络连接状态。具体怎么判断呢?

我们使用配置项 down-after-milliseconds * 10。其中,down-after-milliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。

筛选完,就要开始给从库打分,打分共分为三轮。分别是从库优先级、从库复制进度和从库ID。只要在某一轮中出现最高分,即选用这个从库作为新主库。

第一轮:优先级最高的从库得分高。

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

第二轮:和旧主库同步程度最接近的从库得分高。

上节课我们介绍过,主从库同步时有个命令传播的过程。在这个过程中,主库会用 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 就应该被选为新主库。

当然,如果有两个从库的 slave_repl_offset 值大小是一样的(例如,从库 1 和从库 2 的 slave_repl_offset 值都是 990),我们就需要给它们进行第三轮打分了。

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

每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。

至此,新主库就被选出来了,“选主”这个过程就完成了。

 

8.哨兵挂了,主从库还能切换吗?--哨兵集群

我们知道Redis主从库切换是通过哨兵来实现的,那如果哨兵挂了,怎么办呢?这时候就要引入哨兵集群了。

如果你部署过哨兵集群的话就会知道,在配置哨兵的信息时,我们只需要用到下面的这个配置项,设置主库的 IP 和端口,并没有配置其他哨兵的连接信息。

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

这些哨兵实例既然都不知道彼此的地址,又是怎么组成集群的呢?要弄明白这个问题,我们就需要学习一下哨兵集群的组成和运行机制了。

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

哨兵实例之间可以相互发现,要归功于 Redis 提供的 pub/sub 机制,也就是发布 / 订阅机制。

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

除了哨兵实例,我们自己编写的应用程序也可以通过 Redis 进行消息的发布和订阅。所以,为了区分不同应用的消息,Redis 会以频道的形式,对这些消息进行分门别类的管理。所谓的频道,实际上就是消息的类别。当消息类别相同时,它们就属于同一个频道。反之,就属于不同的频道。只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换。

在主从集群中,主库上有一个名为“__sentinel__:hello”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。

如下图所示:

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

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

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

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

频道有这么多,一下子全部学习容易丢失重点。为了减轻你的学习压力,我把重要的频道汇总在了一起,涉及几个关键事件,包括主库下线判断、新主库选定、从库重新配置

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

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

SUBSCRIBE +odown

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

PSUBSCRIBE *

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


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

好了,有了 pub/sub 机制,哨兵和哨兵之间、哨兵和从库之间、哨兵和客户端之间就都能建立起连接了,再加上我们上节课介绍主库下线判断和选主依据,哨兵集群的监控、选主和通知三个任务就基本可以正常工作了。不过,我们还需要考虑一个问题:主库故障以后,哨兵集群有多个实例,那怎么确定由哪个哨兵来进行实际的主从切换呢?

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

确定由哪个哨兵执行主从切换的过程,和主库“客观下线”的判断过程类似,也是一个“投票仲裁”的过程。

在具体了解这个过程前,我们再来看下,判断“客观下线”的仲裁过程。哨兵集群要判定主库“客观下线”,需要有一定数量的实例都认为该主库已经“主观下线”了。

任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。

如下图所示:

一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。

这个所需的赞成票数是通过哨兵配置文件中的 quorum 配置项设定的。例如,现在有 5 个哨兵,quorum 配置的是 3,那么,一个哨兵需要 3 张赞成票,就可以标记主库为“客观下线”了。这 3 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader 选举”。因为最终执行主从切换的哨兵称为 Leader,投票过程就是确定 Leader。

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:

  1. 拿到半数以上的赞成票;
  2. 拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

以 3 个哨兵为例,假设此时的 quorum 设置为 2,那么,任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以了。

需要注意的是,如果哨兵集群只有 2 个实例,此时,一个哨兵要想成为 Leader,必须获得 2 票,而不是 1 票。所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。因此,通常我们至少会配置 3 个哨兵实例。这一点很重要,你在实际应用时可不能忽略了。

 

9.数据增多,是该加内存还是加实例

保存大量数据,通常有扩大云主机内存和切片集群两种方法。实际上,这两种方法分别对应Redis应对数据量增多的两种方案:纵向扩展(scale up)横向扩展(scale out)

纵向扩展的好处是实施起来简单,快捷

潜在的问题是,内存数据增多时,进行RDB数据持久化的时候,主线程fork子进程的时候可能会造成阻塞。还有就是硬件和成本的控制,毕竟升级服务器资源是需要不少花费的,而且我们知道,内存和磁盘越大的服务器越贵。

对比起来的话,横向扩展是扩展性更好的一种方案。在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。

不过,在横向扩展组建切片集群的时候也有几个问题需要解决。

  • 数据切片后,在多个实例之间如何分布?
  • 客户端怎么确定想要访问的数据在哪个实例上?

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

从Redis3.0开始官方提供了一个名为Redis Cluster的方案,用于切片集群。

具体来说,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 实例的内存大小配置不一,如果把哈希槽均分在各个实例上,在保存相同数量的键值对时,和内存大的实例相比,内存小的实例就会有更大的容量压力。遇到这种情况时,你可以根据不同实例的资源配置情况,使用 cluster addslots 命令手动分配哈希槽。

如下图所示:

示意图中的切片集群一共有 3 个实例,同时假设有 5 个哈希槽,我们首先可以通过下面的命令手动分配哈希槽:实例 1 保存哈希槽 0 和 1,实例 2 保存哈希槽 2 和 3,实例 3 保存哈希槽 4。

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

在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 5 取模,再根据各自的模数结果,就可以被映射到对应的实例 1 和实例 3 上了。

另外,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。

客户端如何定位数据?

在定位键值对数据时,它所处的哈希槽是可以通过计算得到的,这个计算可以在客户端发送请求时来执行。但是,要进一步定位到实例,还需要知道哈希槽分布在哪个实例上。

一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。

那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?这是因为,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

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

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

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

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

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

重定向机制分为两种情况,分别对应两个命令,这里我就用两个命令分别代表两种情况。MOVED和ASK

  • MOVED

那客户端又是怎么知道重定向时的新实例的访问地址呢?当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址。

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

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

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

  • ASK

需要注意的是,在上图中,当客户端给实例 2 发送命令时,Slot 2 中的数据已经全部迁移到了实例 3。在实际应用时,如果 Slot 2 中的数据比较多,就可能会出现一种情况:客户端向实例 2 发送请求,但此时,Slot 2 中的数据只有一部分迁移到了实例 3,还有部分数据没有迁移。在这种迁移部分完成的情况下,客户端就会收到一条 ASK 报错信息,如下所示:

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

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

看起来好像有点复杂,我再借助图片来解释一下。

在下图中,Slot 2 正在从实例 2 往实例 3 迁移,key1 和 key2 已经迁移过去,key3 和 key4 还在实例 2。客户端向实例 2 请求 key2 后,就会收到实例 2 返回的 ASK 命令。

ASK 命令表示两层含义:第一,表明 Slot 数据还在迁移中;第二,ASK 命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例 3 发送 ASKING 命令,然后再发送操作命令。

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

 

 

 

 

 

 

 

 

 

 


以上内容是从蒋德钧老师的《Redis核心技术与实战》课程中总结归纳的知识点。有想一起学习的同学,可以扫描下面的二维码购买课程,可以享受拼团价~

 

或者点下面这个链接也可以

http://gk.link/a/10o4u

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值