Redis设计与实现-笔记(三)

文章目录

第三部分 多机数据库的实现

第15章 复制

有两个Redis服务器,地址分别为127.0.0.1:12345和127.0.0.1:6379,如果我们想服务器127.0.0.1:12345发送以下命令:

127.0.0.1:12345> SLAVEOF 127.0.0.1 6379

那么127.0.0.1:12345将成为127.0.0.1:6379的从服务器,而127.0.0.1:6379将成为127.0.0.1:12345的主服务器。

15.1 旧版复制功能的实现

Redis的复制功能分为 同步(sync)和命令传播(command propagate) 两个操作:

  • 同步操作,用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
  • 命令传播操作,则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态。
15.1.1 同步

当客户端向从服务器发送SLAVEOF命令时,要求从服务器复制主服务器时,从服务器首先要执行同步操作。
从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令完成,以下是SYNC命令的执行步骤:

  1. 从服务器向主服务器发送SYNC命令
  2. 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
  3. 当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,从服务器接收并载入RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态
  4. 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。
15.1.2 命令传播

主从服务器之间如果有不一致时,会进行命令传播。

15.1.3 旧版复制功能的缺陷

在Redis中,从服务器对主服务器的复制可以分为以下两种情况:

  • 初次复制:从服务器以前没有复制过任何主服务器,后者从服务器当前要复制的主服务器和上一次复制的主服务器不同。
  • 断线后重复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器通过自动重连接重新连接上了主服务器,并继续复制主服务器。

对于初次复制,旧版能够很好的完成,但对于断线后重复制来说,虽然也能但是效率非常低(完整重同步)。

SYNC命令时一个非常耗费资源的操作 每次执行SYNC命令,主从服务器需要执行以下动作:
1)主服务器需要执行BGSAVE命令来生成RDB文件,这个生成操作会耗费主服务器大量的CPU、内存和磁盘I/O资源
2)主服务需要将自己已生成的RDB文件发送给从服务器,这个发送操作会耗费主从服务器大量的网络资源(带宽和流量),并对主服务器响应请求的时间产生影响。
3)接收到RDB文件的从服务器需要载入主服务发来的RDB文件,并且在载入期间,从服务器会因为阻塞而没办法处理命令请求。

15.3 新版复制功能的实现

使用 PSYNC命令 来执行复制时的同步操作。
PSYNC命令具有 完整重同步部分重同步 两种模式:

  • 其中完整重同步,用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,他们都是通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步。
  • 而部分重同步,则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。

PSYNC命令的部分重同步模式解决了旧版复制功能在处理断线后重复制时出现的低效问题。

15.4 部分重同步的实现

PSYNC命令同步功能由三个部分构成:

  • 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
  • 主服务器的复制积压缓冲区(replication backlog)
  • 服务器的运行ID(run ID)
PSYNC <runid> <offset>
15.4.1 复制偏移量

执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:

  • 主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N
  • 从服务器每次收到主服务器传播来的N个字节的数据时,就将自己的复制偏移量的值加上N

通过对比主从服务器的复制偏移量,程序可以很容易地知道主从服务器是否处于一致状态:

  • 如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的
  • 相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态。

从服务器在断线之后重新连接主服务器成功后,从服务器将向主服务器发送PSYNC命令,并报告从服务器当前的复制偏移量。

15.4.2 复制积压缓冲区

是执行完整重同步还是部分重同步?
如果执行部分重同步,主服务如何补偿从服务器在断线期间丢失的数据?
复制积压缓冲区,是由主服务器维护的一个固定长度(fixed-size)先进先出队列,默认大小为1MB。
当从服务器重新连上主服务器时,从服务器会通过PSYNC命令将自己的复制偏移量offset发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器
执行何种同步操作:

  • 如果offset偏移量之后的数据(也即是偏移量offset+1开始的数据)仍然存在于复制积压缓冲区里面(之后的数据存在于复制积压缓冲区中),那么主服务器将对从服务器执行部分重同步操作。
  • 相反,如果offset偏移量之后的数据已经不存在与复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作。

断线重连过程:

  • 当从服务器断线,立即重新连接主服务器,并向主服务器发送PSYNC命令,报告自己的复制偏移量。
  • 主服务器收到从服务器发来的PSYNC命令以及偏移量之后,主服务器经检查偏移量之后的数据是否存在于复制积压缓冲区里面,结果发现这些数据仍然存在,于是主服务器想从服务器发送+CONTITNUE回复,表示数据同步将以部分重同步模式来进行。
  • 接着主服务器会将复制积压缓冲区偏移量之后的所有数据都发送给从服务器。
  • 从服务器只要接收这个确实的数据,就可以回到与主服务器一致的状态。

根据需要调整复制积压缓冲区的大小
Redis为复制积压缓冲区设置的默认大小为1MB,如果主服务器需要执行大量写命令,又或者主从服务器断线后重连接所需的时间比较长,那么这个大小不合适。
最小大小可以根据second * write_size_per_second来估算,可设置2倍。

15.4.3 服务器运行ID

实现部分重同步需要复制偏移量和复制积压缓冲区,还需要用到服务器运行ID:

  • 每个Redis服务器,不论主服务器还是从服务器,都会有自己的运行ID
  • 运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成。

当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器则会将这个运行ID保存起来
当从服务器断线后重新连接上一个个主服务器时,从服务器会向当前连接的主服务器发送之前保存的运行ID:

  • 如果从服务器保存的运行ID和当前连接的主服务器的运行ID相同,那么说明从服务器断线之前复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作。
  • 反之,从服务器保存的运行ID和当前连接的主服务器的运行ID不相同,说明从服务器断线之前复制的不是当前连接的主服务器,主服务执行完整重同步操作。
15.5 PSYNC命令的实现

PSYNC命令的调用方法有两种:

  • 如果从服务器以前没有复制过任何主服务器,或者之前执行过 SLAVEOF no one 命令,那么从服务器在开始一次新的复制时将向主服务器发送 PSYNC ? -1 命令,主动请求主服务器进行完整重同步。
  • 相反的,如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时将向主服务器发送 PSYNC <runid> <offset> 命令:其中runid是上一次复制的主服务器运行的ID,而offset则是从服务器当前的复制偏移量,接收到这个命令的主服务器会通过这两个参数来判断应该对从服务器执行哪种同步操作。

接收到PSYNC命令的主服务器会向从服务器返回以下三种回复之一:

  • 如果主服务器返回 +FULLRESYNC <runid> <offset> 回复,那么表示主服务器将从服务器执行 完整重同步 操作:那么表示从服务器会将这个主服务器的运行ID保存起来,在下一次发送PSYNC命令时使用;而offset则是主服务器当前的复制偏移量,从服务器会将这个值作为自己的初始化偏移量。
  • 如果主服务器返回 +CONTINUE 回复,那么表示主服务器将与从服务器执行 部分重同步 操作,从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了
  • 如果主服务器返回 -ERR 回复,那么表示主服务器的版本低于Redis2.8,它识别不了PSYNC命令,从服务器将向主服务器发送SYNC命令,并于主服务器执行完整同步操作。
15.6 复制的实现

通向从服务器发送SLAVEOF命令,我们可以让一个从服务器去复制一个主服务器:

SLAVEOF <master_ip> <master_port>
15.6.1 步骤1:设置主服务器的地址和端口

从服务器首先要做的就是将客户端给定的主服务器IP地址127.0.0.1以及端口6379保存到服务器状态的masterhost属性和masterport属性里面:

struct redisServer {
	// 主服务器的地址
	char *masterhost;
	// 主服务器的端口
	int masterport;
}

SLAVEOF命令时一个异步命令,在完成以上两个属性工作设置工作之后,从服务器将向发送SLAVEOF命令的客户端返回OK,表示复制命令已经被接收,而实际的复制工作将在OK返回之后才真正开始执行。

15.6.2 步骤2: 建立套接字连接

在SLAVEOF命令之后,从服务器将根据命令所设置的IP地址和端口,创建连向主主服务器的套接字连接。
从服务器创建的套接字能成功连接到主服务器,
从服务器,

  • 为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责执行后续的复制工作,比如接收RDB文件,以及接收主服务器传播来的写命令。

主服务器,

  • 将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来对待,这时从服务器将同时具有服务器和客户端两个身份
15.6.3 步骤3: 发送PING命令

做的第一件事就是向主服务器发送一个PING命令。
PING命令的作用:

  • 虽然主从服务器成功建立了套接字连接,但双方并未使用该套接字通信,通过发送PING命令可以检查套接字的读写状态是否正常
  • 接下来的步骤都必须主服务器可以正常处理命令请求的状态下才能进行,通过发送PING命令可以检查主服务器能否正常处理命令请求。

PING命令发出后遇到以下三种情况,其中一种:

  • 如果主服务器向从服务器返回了一个命令回复,但从服务器却不能再规定的时限(timeout)内读取出命令的内容,那么表示主从服务器之间的网络连接情况不佳,不能继续执行后续步骤。遇到这种情况,从服务器断开并重新创建连向主服务器的套接字。
  • 如果主服务器向从服务器返回一个错误,那么便是主服务器暂时没办法处理从服务器的命令请求,不能继续执行复制工作的后续步骤。遇到这种情况,从服务器断开并重新创建连向主服务器的套接字。
  • 如果从服务器读取到“PONG”回复,那么表示主从服务器网络连接状态正常,并且主服务器可以正常处理从服务器(客户端)发送的命令请求。在这种情况下,可以继续执行复制工作的下个步骤。
15.6.4 步骤4: 身份验证

从服务器在收到主服务器返回的“PONH”回复之后,下一步要做的就是决定是否进行身份验证:
如果从服务器设置了masterauth选项,那么进行身份验证。
在需要身份验证的情况下,从服务器向主服务器发送一条AUTH命令,命令的参数为从服务器masterauth选项。
从服务器验证阶段可能遇到的情况:

  • 如果主服务器没有设置requirepass选项,并且从服务器也没有设置masterauth选项,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。
  • 如果从服务器通过AUTH命令发送的密码和主服务器requirepass选项所设置的密码相同,那么主服务器将继续执行从服务器的命令,复制工作可以继续进行。反之,主从服务器设置的密码不相同,则主服务器返回一个invalid password错误。
  • 如果主服务器设置了requirepass选项,但从服务器却没有设置masterauth选项,那么主服务器将返回一个NOAUTH错误。另一方面,如果主服务器没有设置requirepass选项,但是从服务器却设置了masterauth选项,那么主服务器将返回一个no password is set错误。
15.6.5 步骤5: 发送端口信息

在身份验证步骤之后,从服务器将执行命令REPLCONF listening-port ,向主服务器发送从服务器的监听端口号。
主服务器将接受到这个命令记录在对应的客户端状态的slave_listening_port属性中

typedef struct redisClient {
	// 从服务器的监听端口号
	int slave_listening_port;
}

slave_listenign_port,目前唯一的作用就是在主服务器执行INFO replication命令时打印出从服务器的端口号。

15.6.6 步骤6: 同步

在这一步,从服务器将向主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态。
在执行同步操作之前,只有从服务器是主服务器的客户端,
但在执行同步操作之后,主服务器也会成为从服务器的客户端:

  • 如果PSYNC命令执行的是完整重同步操作,那么主服务器需要成为从服务器的客户端,才能将保存在缓冲区里面的写命令发送给从服务器执行。
  • 如果PSYNC命令执行的是部分重同步操作,那么主服务器需要成为从服务器的客户端,才能向从服务器发送保存在复制积压缓冲区里面的写命令。
15.6.7 步骤7: 命令传播

完成同步之后,主从服务器就会就入命令传播阶段。

15.7 心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:

REPLCONF ACK <replication_offset>

replication_offset是从服务器当前的复制偏移量。
发送REPLCONF ACK命令对于主从服务器有三个作用:

  • 检测主从服务器的网络连接状态。
  • 辅助实现min-slaves选项。
  • 检测命令丢失。
15.7.1 检测主从服务器的网络连接状态

如果主服务器超过一秒钟没有收到从服务器发来的REPLCONF ACK命令,那么主服务器就知道主从服务器之间的连接出现问题了。

127.0.0.1:6379> INFO replication

通过想主服务器发送该命令可以知道相应从服务器最后一次向主服务器发送REPLCONF ACK命令距离现在过了多少秒。

15.7.2 辅助实现min-slaves配置选项

Redis的min-salves-to-write和min-slaves-max-lag两个选项可以防止主服务器在不安全的情况下执行写命令。

min-salves-to-write 3
min-slaves-max-lag 10

在从服务器数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10秒时,主服务将拒绝执行写命令。

15.7.3 检测命令丢失

主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。
与部分重同步操作的原理相似,区别在于,补发缺失数据操作在主从服务器没有断线的情况下执行,而部分重同步操作则在主从服务器断线并重连之后执行。

15.8 重点回顾
  • Redis2.8以前的复制功能不能高效的处理断线后重复制情况,但Redis2.8新添加的部分重同步功能可以解决这个问题。
  • 部分重同步通过复制偏移量、复制积压缓冲区、服务器运行ID三个部分来实现
  • 在复制操作刚开始的时候,从服务器会成为主服务器的客户端,并通过向主服务器发送命令请求来执行复制步骤,而在复制操作的后期,主从服务器会互相成为对方的客户端。
  • 主服务器通过向从服务器传播命令来更新从服务器的状态,保持主从服务器一致,而从服务器则通过向主服务器发送命令来进行心跳检测,以及命令丢失的检测。

第16章 Sentinel

Sentinel(哨岗、哨兵)是Redis的高可用性解决方案:由一个或多个Sentinel实例组成的Sentinel系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入线下状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
当主服务器故障转移操作:

  • 首先,Sentinel系统会挑选主服务server1属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器。
  • 之后,Sentinel系统会向主服务器server1属下的所有从服务器发送新的复制指令,让它们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作执行完成。
  • 另外,Sentinel还会继续监视已下线的主服务器server1,并在它重新上线时,将它设置为新的主服务器的从服务器。
16.1 启动并初始化Sentinel

启动一个Sentinel可以使用命令:

redis-sentinel /path/to/your/sentinel.conf
或者
redis-server /path/to/your/sentinel.conf --sentinel

当一个Sentinel启动时,它需要执行以下步骤:
1)初始化服务器
2)将普通Redis服务器使用的代码替换成Sentinel专用代码
3)初始化Sentinel状态
4)根据给定的配置文件,初始化Sentinel的监视主服务器列表
5)创建连向主服务器的网络连接

16.1.1 初始化服务器

首先,因为Sentinel本质上只是一个运行在特殊模式下的Redis服务器,所以启动Sentinel的第一步,就是初始化一个普通的Redis服务器,具体的步骤和Redis类似。
不过,因为Sentinel执行的工作和普通Redis服务器执行的工作不同,所以Sentinel的初始化过程和普通Redis服务器的初始化过程并不完全相同。

16.1.2 使用Sentinel代码

启动Sentinel的第二个步骤就是将一部分Redis服务器使用的代码替换成Sentinel专用代码。
服务器端口:sentinel.c/REDIS_SENTINEL_PORT常量
服务器的命令表:sentinel.c/sentinelcmds作用服务器的命令表
INFO命令:使用sentinel.c/sentinelInfoCommand函数

16.1.3 初始化Sentinel状态

服务器会初始化一个sentinel.c/ssentinelState结构,这个结构保存了服务器中所有和Sentinel功能有关的状态:

struct sentinelState {
	// 当前纪元,用于实现故障转移
	uint64_t current_epoch;
	// 保存了所有被这个sentinel监视的主服务器
	// 字典的键时主服务器的名字
	// 字典的值则是一个指向sentinelRedisInstance结构的指针
	dict *masters;
	// 是否进入TILT模式?
	int tilt;
	// 目前正在执行的脚本的数量
	int running_scripts;
	// 进入TILT模式的时间
	mstime_t tilt_start_time;
	// 最后一次执行时间处理器的时间
	mstime_t previous_time;
	// 一个FIFO队列,包含了所有需要执行的用户脚本
	list *scripts_queue;
}	sentinel;
16.1.4 初始化Sentinel状态的masters属性

Sentinel状态中的masters字典记录了所有被Sentinel监视的主服务器的相关信息。其中:

  • 字典的键,是被监视主服务器的名字
  • 字典的值,是被监视主服务器对应的sentinel.c/sentinelRedisInstance结构。

每个sentinelRedisInstance结构代表一个被Sentinel监视的Redis服务器实例,这个实例可以是主服务器、从服务器,或者另外一个Sentinel。

typedef struct sentinelRedisInstance {
	// 标识值,记录了实例的类型,以及该实例的当前状态
	int flags;
	// 实例的名字
	// 主服务器的名字由用户在配置文件中设置
	// 从服务器以及Sentinel名字由Sentinel自动设置
	// 格式为ip:port
	char *name;
	// 实例的运行ID
	char *runid;
	// 配置纪元,用于实现故障转移
	uint64_t config_epoch;
	// 实例的地址
	sentinelAddr *addr;
	// SENTINEL down-after-milliseconds选项设定的值
	// 实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)
	mstime_t down_after_period;
	// SENTINEL monitor <master-name> <IP> <port> <quorum> 选项中的quorum参数
	// 判断这个实例为客观下载(objectively down)所需的支持投票数量
	int quorum;
	// SENTINEL parallel-syncs <master-name> <number>选项的值
	// 在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
	int parallel_syncs;
	// SENTINEL failover-timeout <master-name> <ms>选项的值
	// 刷新故障前一状态的最大时限
	mstime_t failover_timeout;
} sentinelRedisInstance;

sentinelRedisInstance.addr属性,是一个指向sentinel.c/sentinelAddr结构的指针,这个结构保存着实例的IP地址和端口号:

typedef struct sentinelAddr {
	char *ip;
	int port;
} sentinelAddr;

对Sentinel状态的初始化将引发对master字典的初始化,而masters字典的初始化时根据被载入的Sentinel配置文件来进行的。

16.1.5 创建连向主服务器的网络连接

最后一步,Sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息。
对每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步网络连接

  • 一个是命令连接,这个连接专门用于向主服务器发送命令,并接受命令回复。
  • 另一个是订阅连接,这个连接专门用于订阅主服务器的__sentinel__:hello频道。
16.2 获取主服务器信息

Sentinel默认会以 每十秒一次 的频率,通过命令连接向被监视的主服务发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息。
通过分析主服务器返回的INFO命令回复,Sentinel可以获取以下两个方面的信息:

  • 一方面是关于主服务器本身的信息,包括run_id域记录的服务器运行ID,以及role域记录的服务器角色
  • 另一方面是关于主服务器属下所有从服务器的信息,每个从服务器都由一个“slave”字符串开头的行记录,每行的ip=域记录了从服务器的IP地址,而port=域则记录了从服务器的端口号。根据这些IP地址和端口号,Sentinel无须用户提供从服务器的地址信息,就可以自动发现从服务器。

Sentinel将对主服务器的实例结构进行更新,run_id域和role域记录的信息。记录了主服务器属下从服务器的名单。
至于主服务器返回的从服务器信息,则会被用于更新主服务器实例结构的slaves字典,这个字典。键:ip:port,值:从服务器对应的实例结构
Sentinel在分析INFO命令中包含的从服务器信息时,会检查从服务器对应的实例结构是否已经存在于slaves字典中,存在,则更新,不存在,则新创建。
主服务器实例结构和从服务器实例结构之间的区别:

  • 主服务器实例结构的flags属性的值为SRI_MASTER,而从服务器实例结构的flags属性的值为SRI_SLAVE。
  • 主服务器实例结构的name属性的值是用户使用Sentinel配置文件设置的,而从服务器实例结构的name属性的值则是Sentinel根据从服务器的IP地址和端口号自动设置的。
16.3 获取从服务器信息

当Sentinel发现主服务器有新的从服务器出现时,Sentinel 除了会为这个新的从服务器创建相应的实例结构外,Sentinel还会创建连接到从服务器的命令连接和订阅连接。
创建命令连接之后,Sentinel在默认的情况下,会以每十秒一次的频率通过命令连接向从服务器发送INFO命令,并获得回复:
回复中Sentinel会提取的信息:

  • 从服务器的运行ID run_id。
  • 从服务器的角色role。
  • 主服务器的IP地址master_host,以及主服务器的端口号master_port。
  • 主从服务器的连接状态master_link_status。
  • 从服务器的优先级slave_priority。
  • 从服务器的复制偏移量slave_repl_offset。

根据这些信息,Sentinel会对从服务器的实例结构进行更新。

16.4 向主服务器和从服务器发送信息

默认情况下,Sentinel会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:

PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"

各个参数:

  • s_开头的参数记录的事Sentinel本身的信息,其中s_epoch(当前的配置纪元)
  • m_开头的参数记录的则是主服务器的信息(如果是从服务器,则是从服务器正在复制的主服务器的信息)
16.5 接收来自主服务器和从服务器的频道信息

当Sentinel与一个主服务器或者从服务器建立起订阅连接之后,Sentinel就会通过订阅连接,向服务器发送以下命令

SUBSCRIBE __sentinel__:hello;

Sentinel对__sentinel__:hello频道的订阅会一直持续到Sentinel与服务器的连接断开为止。
Sentinel既通过命令连接向服务器的__sentinel__:hello频道发送信息,又通过订阅连接从服务器__sentinel__:hello频道接收信息。
对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知,也会被用于更新其他Sentinel对被监视服务器的认知。

当一个Sentinel从__sentinel__:hello频道收到一条信息是,会对这条信息进行分析,提取并进行一下检查:

  • 如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID相同,那么说明这条信息是Sentinel自己发送的,Sentinel将丢弃,不做处理。
  • 不相同,那么说明这条信息是监视同一个服务器的其他Sentinel发来的,接收信息的Sentinel将根据信息中的各个参数,对相应主服务器的实例结构进行更新。
16.5.1 更新Sentinel字典

Sentinel为主服务器创建的实例结构中的sentinels字典保存了除Sentinel本身之外所有相同监视这个主服务器的其他Sentinel的资料:

  • sentinels字典的键是其中一个Sentinel的名字,格式为ip:port
  • sentinels字典的值则是键所对应Sentinel的实例结构

当一个Sentinel接收到其他Sentinel发来的信息时,目标Sentinel会从信息中分析并提取以下两种参数:

  • 与Sentinel有关的参数
  • 与主服务器有关的参数

根据信息中提取出的主服务器参数,目标Sentinel会在自己的Sentinel状态的masters字典中查找相应的主服务器实例结构,然后根据提取出的Sentinel参数,检查主服务器实例结构的sentinels字典中,源Sentinel实例结构是否存在:

  • 如果源Sentinel的实例已经存在,那么对源Sentinel的实例结构进行跟新。
  • 不存在,说明源Sentinel刚刚开始监视主服务器的Sentinel,目标Sentinel会为源Sentinel创建一个新的实例结构,并将这个结构添加到sentinels字典里面。

所以用户在使用Sentinel的时候并不需要提供各个Sentinel的地址信息,监视同一个主服务器的多个Sentinel可以自动发现对方。

16.5.2 创建连向其他Sentinel的命令连接

当Sentinel通过频道信息发现一个新的Sentinel时,不仅会为新Sentinel在sentinels字典中创建相应的实例结构,还会创建一个连向新Sentinel的命令连接,而新Sentinel也同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器的多个Sentinel将形成相互连接的网络:SentinelA有连向SentinelB的命令连接,而SentinelB也有连向SentinelA的命令连接。
Sentinel之间不会创建订阅连接。

16.6 检测主观下线状态

在默认情况下,Sentinel会以 每秒一次 的频率向所有与它创建了命令连接的实例(主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。
实例对PING命令的回复分为两种情况:

  • 有效回复:实例返回+PONG、-LOADING、-MASTERDOWN三种回复的其中一种
  • 无效回复:实例返回除+PONG、-LOADING、-MASTERDOWN三种回复之外的其他回复,或者在指定时限内没有返回任何回复。

Sentinel配置文件中的down-after-milliseconds选项指定了Sentinel判断实例进入主观下线所需的时间长度:如果一个实例在down-after-milliseconds毫秒内,连续向Sentinel返回无效回复,那么Sentinel会修改这个实例所对应的实例结构,在结构的flags属性中打开SRI_S_DOWN标识,一次来表示这个实例已经进入主观下线状态。
多个Sentinel设置的主观下线时长可能不同。

16.7 检查客观下线状态

当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态。当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作。

16.7.1 发送SENTINEL is-master-down-by-addr命令

Sentinel使用:

SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>

命令询问其他Sentinel是否同意主服务器已下线。
current_epoch,Sentinel当前的配置纪元,用于选举领头Sentinel。

16.7.2 接收SENTINEL is-master-down-by-addr命令

当一个Sentinel接收到另一个Sentinel发来的SENTINEL is-master-down-by-addr命令时,目标Sentinel会分析并取出命令中包含的各个参数,并根据其中的主服务器IP和端口号,检查主服务器是否已下线,然后向源Sentinel返回一条包含三个参数的Multi Bulk回复作为SENTINEL is-master-down-by-addr命令的回复:1)<down_state>;2)<leader_runid>;3)<leader_epoch>;

参数意义
down_state返回目标Sentinel对主服务器的检查结果,1代表主服务器已下载,0代表主服务器未下载
leader_runid可以是*符号或者目标Sentinel的局部领头Sentinel的运行ID:*符号代表仅仅用于检测主服务器的下线状态,而局部领头Sentinel运行ID则用于选举领头Sentinel。
leader_epoch目标Sentinel的局部领头Sentinel的配置纪元,用于选举领头Sentinel。仅在leader_runid的值不为时有效,如果leader_runid的值为,那么leader_epoch总为0
16.7.3 接收SENTINEL is-master-down-by-addr命令的回复

根据其他Sentinel发送的SENTINEL is-master-down-by-addr命令回复,Sentinel将统计其他Sentinel同意主服务器已下线的数量,当这一数量达到配置指定的判断客观下线所需的数量时,Sentinelhi将主服务器实例结构flags属性的SRI_O_DOWN标识打开,表示主服务器已经进入客观下线状态。

// 指定的判断客观下线的所需的数量
sentinel monitor master 127.0.0.1 6379 2

只要总共有两个Sentinel认为主服务器已经进入下线状态,那么当前Sentinel就将主服务器判断为客户端下线。
不同的Sentinel判断客观下线的条件可能不同

16.8 选举领头Sentinel

当主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头Sentinel对下线主服务器执行故障转移操作。
Redis‘选举领头Sentinel的规则和方法:

  • 所有在线的Sentinel都有被选为领头Sentinel的资格,换句话说,监视同一个主服务器的多个在线Sentinel中的任意一个都有可能成为领头Sentinel。
  • 每次进行领头Sentinel选举之后,不论选举是否成功,所有Sentinel的配置纪元configuration epoch的值都会自增一次。配置纪元实际上就是一个计数器,并没有什么特别的。
  • 在一个配置纪元里面,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,并且局部领头一旦设置,这个配置纪元里面就不能再更改。
  • 每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel。
  • 当一个Sentinel向另一个Sentinel发送SENTINEL is-master-down-by-addr命令,并且命令中的runid参数不是*符号而是源Sentinel的运行ID时,这表示源Sentinel要求目标Sentinel将前者设置为后者的局部领头Sentinel。
  • Sentinel设置局部领头Sentinel的规则是先到先得:最先向目标Sentinel发送设置要求的源Sentinel将成为目标Sentinel的局部领头Sentinel,而之后接收到的所有设置要求都会被目标Sentinel拒绝
  • 目标Sentinel在接收到SENTINEL is-master-down-by-addr命令之后,将向源Sentinel返回一条命令回复,回复中的leader_runid参数和leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元。
  • 源Sentinel在接收到目标Sentinel返回的命令回复之后,会检查回复中leader_epoch参数的值和自己的配置纪元是否相同,如果相同,那么源Sentinel继续取出回复中的leader_runid参数,如果leader_runid的值和源Sentinel的运行ID一致,那么表示目标Sentinel将源Sentinel设置成了局部领头Sentinel。
  • 如果有某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel成为领头Sentinel。
  • 因为领头Sentinel的产生需要半数以上Sentinel的支持,并且每个Sentinel在每个配置纪元里面只能设置一次局部领头Sentinel,所以在一个配置纪元里面,只会出现一个领头Sentinel。
  • 如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后,再次进行选举,直到选出领头Sentinel为止。
16.9 故障转移

在选举出领头Sentinel之后,领头Sentinel将对已下线的主服务器执行故障转移操作,三个步骤:

  1. 在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器。
  2. 让已下线主服务器属下的所有从服务器改为复制新的主服务器。
  3. 将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器。
16.9.1 选出新的主服务器

这个是故障转移的第一步,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送SLAVEOF no one命令,将这个从服务器转换为主服务器。

新的主服务器时怎样挑选出来的?
领头Sentinel会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则进行一项一项过滤:

  1. 删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。
  2. 删除列表中所有最近5秒内没有回复过领头Sentinel的INFO命令的从服务器,这可以保证列表中剩余的从服务器都是最近成功进行过通信的。
  3. 删除所有与已下线主服务器连接断开超过down-after-milliseconds * 10 毫秒的从服务器:down-after-milliseconds选项指定了判断主服务器下线所需的时间,而删除断开时长超过down-after-milliseconds * 10 毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地域主服务器断开连接。列表中剩余的服务器保存的数据都是比价新的。

之后,领头Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。
如果有多个具有相同最高优先级的从服务器,则领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,并选出其中偏移量最大的从服务器。
最后,如果有多个优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行ID对这些从服务器进行排序,并选出其中运行ID最小的从服务器。

在发送SLAVEOF no one命令之后,领头Sentinel会以每秒一次的频率(平时是每10秒一次),向被升级的从服务器发送INFO命令,并观察命令回复中的角色(role)信息,当被升级服务器的role从原来的slave变为master时,领头Sentinel就知道被选中的从服务器已经顺利升级为主服务器了。

16.9.2 修改从服务器的复制目标

当有新的主服务器之后,接着就是让已下线主服务器属下的所有从服务器去复制新的主服务器,这一动作可以通过向从服务器发送SLAVEOF命令来实现。

16.9.3 将旧的主服务器变为从服务器

最后一步,将已下线的主服务器设置为新的主服务器的从服务器。
因为旧的主服务器已经下线,所以这种设置是保存在server1对应的实例结构里面的,当server1重新上线后,Sentinel就会向它发送SLAVEOF命令,让它成为新主服务器的从服务器。

16.10 重点回顾
  • Sentinel 只是一个运行在特殊模式下的Redis服务器,它使用了和普通模式不同的命令表,所以Sentinel模式能够使用的命令和普通Redis服务器能够使用的命令不同。
  • Sentinel会读入用户指定的配置文件,为每个要被监视的主服务器创建相应的实例结构,并创建连向主服务器的命令连接和订阅连接,其中命令连接用于向主服务器发送命令请求,而订阅连接则用于接收指定频道的信息。
  • Sentinel通过向主服务器发送INFO命令来获得主服务器属下所有从服务器的地址信息,并为这些从服务器创建相应的实例结构,以及连向这些从服务器的命令连接和订阅连接。
  • 在一般情况下,Sentinel以每十秒一次的频率向被监视的主服务器和从服务器发送INFO命令,当主服务器处于下线状态,或者Sentinel正在对主服务器进行故障转移操作时,Sentinel向从服务器发送INFO命令的频率会改为每秒一次
  • 对于监视同一个主服务器和从服务器的多个Sentinel来说,它们会以每两秒一次的频率,通过向被监视服务器的__sentinel__:hello频道发送消息来向其他Sentinel宣告自己的存在。
  • 每个Sentinel也会从__sentinel__:hello频道中接收其他Sentinel发来的信息,并根据这些信息为其他Sentinel创建相应的实例结构,以及命令连接。
  • Sentinel只会与主服务器和从服务器创建命令连接和订阅连接,Sentinel与Sentinel之间则只创建命令连接。
  • Sentinel以每秒一次的频率向实例(包括主服务器、从服务器、其他Sentinel)发送PING命令,并根据实例对PING命令的回复来判断实例是否在线,当一个实例在指定的时长中连续向Sentinel发送无效回复时,Sentinel会将这个实例判断为主观下线。
  • 当Sentinel将一个主服务器判断为主观下线是,它会向同样监视这个主服务器的其他Sentinel进行询问,看它们是否同意这个主服务器已经进入主观下线状态
  • 当Sentinel收到足够多的主观下线投票之后,它会将主服务器判断为客观下线,并发起一次针对主服务器的故障转移操作。

第17章 集群

Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。

17.1 节点

一个Redis集群通常由多个节点(node)组成,每个节点都是相互独立的,他们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。
连接各个节点的工作可以使用CLUSTER MEET命令来完成:

CLUSTER MEET <ip> <port>

向资格节点node发送CLUSTER MEET命令,可以让node节点与ip和port所指定的节点进行握手,当握手成功时,node节点就会将ip和port指定的节点添加到node节点当前所在的集群中。
通过发送CLUSTER NODE命令可以看到,集群目前包含的节点。

17.1.1 启动节点

一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式。
节点会继续使用所有在单机模式中使用的服务器组件:

  • 节点会继续使用文件事件处理器来处理命令请求和返回命令回复。
  • 节点会继续使用时间事件处理器来执行serverCron函数,而serverCron函数又会调用集群模式特有的clusterCron函数。clusterCron函数负责执行在集群模式下需要执行的常规操作,例如向集群中的其他节点发送Gossip消息,检查节点是否断线,或者检查是否需要对下线节点进行自动故障转移等。
  • 节点会继续使用数据库来保存键值对数据,键值对依然会使各种不同类型的对象。
  • 节点会继续使用RDB持久化和AOF持久化模块来执行持久化工作。
  • 节点会继续使用发布与订阅模块来执行PUBLISH、SUBSCRIBE等命令
  • 节点会继续使用复制模块来进行节点的复制工作。
  • 节点会继续使用Lua脚本环境来执行客户端输入的Lua脚本。

除此之外,节点会继续使用redisServer结构来保存服务器的状态,使用redisClient结构来保存客户端的状态,至于那些只有在集群模式下才会用到的数据,节点将它们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构,以及cluster.h/clusterState结构里面。

17.1.2 集群数据结构

clusterNode结构保存了一个节点的当前状态,如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号等。
每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(包括主、从节点)都创建一个相应的clusterNode结构,一次来记录其他节点的状态:

struct clusterNode {
	// 创建节点的时间
	mstime_t ctime;
	// 节点的名字、由40个十六进制字符组成
	char name[REDIS_CLUSTER_NAMELEN];
	// 节点标识
	// 使用各种不同的标示值记录节点的角色(比如主节点或者从节点)
	// 以及节点目前所处的状态(比如在线或者下线)
	int flags;
	// 节点当前的配置纪元,用于实现故障转移
	uint64_t configEpoch;
	// 节点的IP地址
	char ip[REDIS_IP_STR_LEN];
	// 节点的端口号
	int port;
	// 保存连接节点所需要的有关信息
	clusterLink *link;
	...
	
}

clusterNode结构中的link属性是一个clusterLink结构,保存了连接节点所需的有关信息,如套接字描述符,输入缓冲区和输出缓冲区:

typedef struct clusterLink {
	// 连接的创建时间
	mstime_t ctime;
	// TCP 套接字描述符
	int fd;
	// 输出缓冲区,保存着等待发送给其他节点的消息
	sds sndbuf;
	// 输入缓冲区,保存着从其他节点接收到的信息
	sds rcvbuf
	// 与这个连接相关联的节点,如果没有的话就为NULL
	struct clusterNode *node;
} clusterLink;

redisClient和clusterLink结构的相同和不同之处
redisClient和clusterLink结构都有自己的套接字描述符和输入、输出缓冲区
区别:RedisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字则是用于连接节点。

最后,每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元。

typedef struct clusterState {
	// 指向当前节点的指针
	clusterNode *myself;
	// 集群当前的配置纪元,用于实现故障转移
	uint64_t currentEpoch;
	// 集群当前的状态:是在线还是下线
	int state;
	// 集群中至少处理着一个槽的节点的数量
	int size;
	// 集群节点名单(包括myself节点)
	// 字典的键为节点的名字,字典的值为节点对应的clusterNode结构
	dict *nodes;
	...
	unsigned char slots[16384/8];
	int numslots;
} clusterState;

显示其他节点的状态:

  • 结构的currentEpoch属性的值为0,表示集群当前的配置纪元为0。
  • 结构的size属性的值为0,表示集群目前没有任何节点在处理槽,因此结构的state属性的值为REDIS_CLUSTER_FAIL,这表示集群目前处于下线状态。
  • 结构的nodes字典记录了集群目前包含的三个节点,这三个节点分别由三个clusterNode结构表示,其中myself指针指向代表节点的clusterNode结构,而字典中的另外两个指针分别代表其他两个节点的clusterNode结构。
  • 三个节点的clusterNode结构的flags属性是REDIS_NODE_MASTER,说明三个节点都是主节点。

其他两个节点也会创建类似的clusterState结构。

17.1.3 CLUSTER MEET 命令的实现

通过发送CLUSTER MEET命令,添加节点到集群。
收到命令的节点会与当前节点进行握手,以此来确认彼此的存在,并为将来的进一步通信打好基础:

  1. 节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
  2. 之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息。
  3. 如果一切顺利,节点B将接收到节点A发送的MEET消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
  4. 之后,节点B将向节点A返回一条PONG消息。
  5. 如果一切顺利,节点A将接受到节点B返回的PONG消息,通过这条PONG消息节点A可以知道节点B已经成功地接受到了自己发送的MEET消息
  6. 之后,节点A将向节点B返回一条PING消息
  7. 如果一切顺利,节点B将接收到节点A返回的PING消息,通过这条PING消息节点B可以知道节点A已经成功地接收到了自己返回的PONG消息,握手完成。

之后,节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点也与及节点B进行握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识。

17.2 槽指派

Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的每个键都属于这个16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。
通过向节点发送CLUSTER ADDSLOTS命令,我们可以将一个或多个槽指派(assign)给节点负责:

127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
127.0.0.1:7001> CLUSTER ADDSLOTS 5001 5002 5003 5004 ... 10000
127.0.0.1:7002> CLUSTER ADDSLOTS 10001 10002 10003 10004 ... 16383

执行完之后,数据库中的16384个槽都已经被指派给了相应的节点,集群进入上线状态。

17.2.1 记录节点的槽指派信息

clusterNode结构的slots属性和numslot属性记录了节点负责处理的槽.
slots属性,是一个二进制位数组(bit array),这个数组的长度16384/8=2048个字节,共包含16384个二进制位。

17.2.2 传播节点的槽指派信息

它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。
集群中每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。

17.2.3 记录集群所有的槽的指派信息

clusterState结构中的slots数组记录了集群中所有16384个槽的指派信息
slots数组包含16384个项,每个数组都是一个指向clusterNode结构的指针

  • 如果slots[i]指针指向NULL,那么表示槽i尚未指派给任何节点。
  • 如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点。

通过将所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽i是否已经被指派,又或者取得负责处理槽i的节点,只需要访问clusterState.slots[i]的值。这个操作的复杂度仅为O(1)。

17.2.4 CLUSTER ADDSLOTS命令的实现

CLUSTER ADDSLOTS命令接收一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责。

17.3 在集群中执行命令

在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。
客户端向节点发送数据库键命令,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:

  • 如果键所在的槽正好指派给了当前节点:那么节点就只执行这个命令。
  • 如果键所在的槽没有指派给了当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向(redict)至正确的节点,并再次发送之前想要的执行的命令。
17.3.1 计算键属于哪个槽

节点使用以下算法来计算给定键key属于哪个槽:

def slot_number(key):
	return CRC16(key) & 16383

其中CRC16(key)语句用于计算键key的CRC-16校验和,而 & 16383语句则用于计算出一个介于0-16383之间的整数作为键key的槽号。
使用CLUSTER KEYSLOT 命令查看一个给定键属于哪个槽。用的就是上述算法。

17.3.2 判断槽是否由当前节点负责处理

当节点计算出键所属的槽i之后,节点就会检查自己在clusterState.slots数组中的i项,判断键所在的槽是否由自己负责:

  1. 如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令。
  2. 如果clusterState.slots[i]不等于clusterState.myself,那么说明槽i由并非当前节点负责,节点会根据clusterState.slots[i]之心的clusterNode结构所记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽i的节点。
17.3.3 MOVED错误

MOVED错误的格式为

MOVED <slot> <ip>:<port>

集群不会打印MOVED错误,单机会打印,因为不识别。

17.3.4 节点数据库的实现

集群节点保存键值对以及键值对过期时间的方式,与单机Redis服务器的方式完全相同。
节点和单机服务器的数据库方面的区别是,节点只能使用0号数据库,而单机Redis服务器则没有这一限制。
处理将键值对保存在数据库里面之外,节点还会用clusterState结构中的slots_to_keys跳跃表来保存槽和键之间的关系。
slots_to_keys跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员(member)都是一个数据库:

  • 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到slots_to_keys跳跃表
  • 当节点删除数据库中的某一个键值对时,节点就会在slots_to_keys跳跃表解除被删除键与槽号的关联。

通过跳跃表,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作,CLUSTER GETKEYSINSLOT 命令,返回最多count个属于槽slot的数据库键。

17.4 重新分片

Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
重新分片操作可以在线进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。

重新分片的实现原理
Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行分片操作。
redis-trib对集群的单个槽slot进行重新分片的步骤:

  1. redis-trib对目标节点发送CLUSTER SETSLOT IMPORTING <source_id>命令,让目标节点准备好从源节点导入(import)属于槽slot的键值对。
  2. redis-trib对源节点发送CLUSTER SETSLOT MIGRATING <target_id>命令,让源节点准备好将属于槽slot的键值对迁移(migrate)至目标节点。
  3. redis-trib向源节点发送CLUSTER GETKEYSINSLOT 命令,获得最多count个属于槽slot的键值对的键名(key name)。
  4. 对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE <target_ip> <target_port> <key_name> 0 命令,将被选中的键原子地从源节点迁移至目标节点。
  5. 重复执行步骤3和步骤4,知道源节点保存的所有属于槽slot的键值对都被迁移至目标节点为止。
  6. redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT NODE <target_id>命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会知道槽slot已经指派给了目标节点。

涉及多个槽,对每个给定的从分别执行上述步骤。

17.5 ASK错误

在重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。
当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好久属于正在被迁移的槽时:

  • 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
  • 相反,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。
17.5.1 CLUSTER SETSLOT IMPORTING 命令的实现

clusterState结构的importing_slots_from数组记录了当前节点正在从其他节点导入的槽,
如果importing_slots_from[i]的值部位NULL,而是指向一个clusterNode结构,那么表示当前节点正在从clusterNode所代表的节点导入槽i。
发送命令后,可以将目标节点clusterState.importing_slots_from[i]的值设置为source_id所代表节点的clusterNode结构。

17.5.2 CLUSTER SETSLOT MIGRATING 命令的实现

clusterState结构的migrating_slots_to数组记录了当前节点正在迁移至其他节点的槽,
如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在将槽i迁移至clusterNode所代表的节点。
发送命令后,可以将源节点clusterState.migrating_slots_to[i]的值设置为target_id所代表节点的clusterNode结构。

17.5.3 ASK错误

如果节点收到一个关于key的命令请求,并且键key所属的槽i正好就指派给了这个节点,那么节点会尝试在自己的数据库里查找到键key,如果找到了的话,节点就直接执行客户端发送的命令。
相反,如果节点没有在自己的数据库里找到键key,那么节点会检查自己的clusterState.migrating_slots_to[i],看键key所属的槽i是否正在进行迁移,如果槽i的确在进行迁移的话,那么节点会向客户端发送一个ASK错误,引导客户端到正在导入槽i的节点去查找键可以。
先转向正在导入槽的目标节点,
然后首先向目标节点发送一个ASKING命令,
之后再重新发送原本想要执行的命令。

17.5.4 ASKING命令

ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识。
客户端的REDIS_ASKING标识是一个一次性标识。
客户端向节点发送关于槽i的命令,槽i是否指派给了节点:
——是,节点执行客户端发送的命令
——否,节点是否正在导入槽i
————否,节点向客户端返回MOVED命令
————是,客户端是否带有ASKING标识
——————是,节点执行客户端发送的命令
——————否,节点向客户端返回MOVED命令

17.5.5 ASK错误和MOVED错误的区别
  • MOVED错误代表槽的负责权已经从一个节点转移到另一个节点:在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点。
  • 于此相反,ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中讲关于槽i的命令请求发送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再次出现。
17.6 复制与故障转移

Redis集群中的节点分为主节点和从节点,其中主节点用于处理槽,而从节点用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。

17.6.1 设置从节点

命令:

CLUSTER REPLICATE <node_id>

可以让接受命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制:

  • 接收到该命令的节点首先会在自己的clusterState.nodes字典中找到node_id所对应节点的clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构,以此来记录这个节点正在复制的主节点。
  • 然后节点会修改自己在clusterState.myself.flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,标识这个节点已经由原来的主节点变成了从节点。
  • 最后,节点会调用复制代码,并根据clusterState.myself.slaveof指向的clusterNode结构保存的IP地址和端口号,对主节点进行复制。因为节点的复制功能和单机Redis服务器的复制功能使用了相同的代码,所以让从节点复制主节点相当于向从节点发送命令SLAVEOF <master_ip> <master_port>。

一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点。
集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves属性中记录正在复制这个主节点的从节点名单:

struct clusterNode {
	// 正在复制这个主节点的从节点数量
	int numslaves;
	// 一个数组
	// 每个数组项指向一个正在复制这个主节点的clusterNode结构
	struct clusterNode **slaves;
	...
}
17.6.2 故障检测

集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接受PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接受PING消息的节点标记为疑似下线(probable fail,PFAIL)。

集群中各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息。如,某个节点是处于在线状态、疑似下线状态(PFAIL),还是已下线状态(FAIL)。
当一个主节点A通过消息得知主节点B认为主节点C进入了疑似下线状态时,主节点A会在自己的clusterState.nodes字典中找到主节点C所对应的clusterNode结构,并将主节点B的下线报告(failure report)添加到clusterNode结构的fail_reports链表里面:

struct clusterNode {
	// 一个链表,记录了所有其他节点对该节点的下线报告
	list *fail_reports;
	//
};

每个下线报告由一个clusterNodeFailReport结构表示

struct clusterNodeFailReport {
	// 报告目标节点已经下线的节点
	struct clusterNode *node;
	// 最后一次从node节点收到下线报告的时间
	// 程序使用这个时间戳来检查下线报告是否过期
	// (与当前时间相差太久的下线报告会被删除)
	mstime_t time;
} typedef clusterNodeFailReport;

如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL),将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线。

17.6.3 故障转移

故障转移的步骤:

  1. 复制下线主节点的所有从节点里面,会有一个从节点被选中。
  2. 被选中的从节点会执行SLAVEOF no one命令,成为新的主节点。
  3. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
  4. 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
  5. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完毕。
17.6.4 选举新的节点

集群选举新的主节点的方法:

  1. 集群的配置纪元是一个自增计数器,它的初始值为0.
  2. 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一。
  3. 对于每个配置纪元,几区里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
  4. 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具有投票权的主节点想这个从节点投票。
  5. 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点。
  6. 每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,并根据自己收到了多少条这种消息来统计自己活得了多少主节点的支持。
  7. 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点。
  8. 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
  9. 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,知道选出新的主节点为止。

这个选举主节点的方法和选举领头Sentinel的方法非常相似,因为都是基于Raft算法的领头选举方法来实现的。

17.7 消息

集群中的各个节点通过发送和接收消息来进行通信,我们称发送消息的节点为发送者,接收消息的节点为接受者
节点发送的消息主要有以下五种:

  • MEET消息:
  • PING消息:
  • PONG消息:
  • FAIL消息:
  • PUBLISH消息:

一条消息由消息头(header)和消息正文(data)组成。

17.7.1 消息头

节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送者自身的一些信息,因为这些信息也会被消息接收者用到,所以严格来讲,我们可以认为消息头本身也是消息的一部分。
每个消息头都由一个cluster.h/clusterMsg结构表示:

typedef struct {
	// 消息的长度(包括这个消息头的长度和消息正文的长度)
	uint32_t totlen;
	// 消息的类型
	uint16_t type;
	// 消息正文包含的节点信息总数量
	// 只在发送MEET、PING、PONG这三种Gossip协议消息时使用
	uint16_t count;
	// 发送者所处的配置纪元
	uint64_t currentEpoch;
	// 如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
	// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元
	uint64_t configEpoch;
	// 发送者的名字(ID)
	char sender[REDIS_CLUSTER_NAMELEN];
	// 发送者目前的槽指派信息
	unsigned char myslots[REDIS_CLUSTER_SLOTS/8];
	// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
	// 如果发送者是一个主节点,那么这里记录的是REDIS_NODE_NULL_NAME
	// (一个40字节长,值全为0的字节数组)
	char slaveof[REDIS_CLUSTER_NAMELEN];
	// 发送者的端口号
	uint16_t port;
	// 发送者的标识值
	uint16_t flags
	// 发送者所处集群的状态
	unsigned char state;
	// 消息的正文(或者说,内容)
	union clusterMsgDate data;
} clusterMsg;

clsuterMsg.data属性指向联合cluster.h/clusterMsgData,这个联合就是消息的正文:

union clusterMsgData {
	// MEET、PING、PONG消息的正文
	struct {
		// 每条MEET、PING、PONG消息都包含两个
		// clusterMsgDataGossip结构
		clusterMsgDataGossip gossip[1];
	} ping;
	// FAIL消息的正文
	struct {
		clusterMsgDataFail about;
	} fail;
	// PUBLISH消息的正文
	struct {
		clusterMsgDataPublish msg;
	} publish;
	// 其他消息的正文...
}

clusterMsg结构的currentEpoch、sender、myslots等属性记录了发送者自身的节点信息,接受者会根据这些信息,在自己的clusterState.nodes字典里找到发送者对应点额clusterNode结构,并对结构进行更新。
如,
通过对比接收者为发送者记录的槽指派信息,以及发送者在消息头的myslots属性记录的槽指派信息,接收者可以知道发送者的槽指派信息是否发生变化。
通过 对比接收者为发送者记录的标识值,以及发送者在消息头的flags属性记录的标志值,接收者可以知道发送者的状态和角色是否发生了变化,如节点状态由原来的在线变成了下线,或者由主节点变成了从节点等。

17.7.2 MEET、PING、PONG消息的实现

Redis集群中的各个节点通过Gossip协议来交换各自关于不同节点的状态信息,其中Gossip协议由MEET、PING、PONG三种消息实现,这三种消息的正文都由两个cluster.h/clusterMsgGossip结构组成:

union clusterMsgData {
	// MEET、PING和PONG消息的正文
	struct {
		// 每条MEET、PING、PONG消息都包含两个
		// clusterMsgDataGossip结构
		clusterMsgDataGossip gossip[1];
	} ping;

	// 其他消息正文
}

因为MEET、PING、PONG三种消息都使用相同的消息正文,所以节点通过消息头大额type属性来判断一条消息时MEET消、PING消息还是PONG消息。
每次发送MEET、PING、PONG消息时,发送者`都从自己的已知节点列表中随机选出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个clusterMsgDataGossip结构里面。
clusterMsgDataGossip结构记录了被选中节点的名字,发送者与被选中节点最后一次发送和接收PING消息和PONG消息的时间戳,被选中节点的IP地址和端口号,以及被选中节点的标识值:

typedef struct {
	// 节点的名字
	char nodename[REDIS_CLUSTER_NAMELEN];
	// 最后一次向该节点发送PING消息的时间戳
	unit32_t ping_sent;
	// 最后一次从该节点接收到PONG消息的时间戳
	uint32_t pong_received;
	// 节点的IP地址
	char ip[16];
	// 节点的端口号
	uint16_t port;
	// 节点的标识值
	uint16_t flags;
} clusterMsgDataGossip;

当接收者收到MEET、PING、PONG消息时,接收者会访问消息正文中的两个clusterMsgDataGossip结构,并根据自己是否认识clusterMsgDataGossip结构中记录的被选中节点来选择进行哪种操作:

  • 如果被选中节点不存在与接收者的已知节点列表,那么说明接收者是第一次接触到被选中节点,接收者将根据结构中记录的IP地址和端口号等信息,与被选中节点进行握手。
  • 如果被选中节点已经存在于接收者的已知节点列表,那么说明接收者之前已经与被选中节点进行过接触,接收者将根据clusterMsgDataGossip结构记录的信息,对被选中节点所对应的clusterNode结构进行更新。
17.7.3 FAIL消息的实现

当集群里的主节点A将主节点B标记为已下线(FAIL)时,主节点A想集群广播条关于主节点B的FAIL消息,所有接收到这条FAIL消息的节点都会将主节点B标记为已下线。
在集群的节点数量比较大的情况下,单纯使用Gossip协议来传播节点的已下线信息会给节点的信息更新带来一点延迟,因为Gossip协议消息通常需要一段时间才能传播至整个集群,而发送FAIL消息可以让集群里的所有节点立即知道某个主机诶单已下线,从而尽快判断是否需要将集群标记为下线,又或者对下线主节点进行故障转移。
FAIL消息的正文由cluster.h/clusterMsgDataFail结构表示,这个结构只包含一个nodename属性,该属性记录了已下线节点的名字。

17.7.4 PUBLISH消息的实现

当客户端向及群众的某个节点发送命令:

PUBLISH <channel> <message>

接收到PUBLISH命令的节点不仅会向channel频道发送消息message,它还会想集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的及诶按都会想channel频道发送message消息。
即,向集群中的某个节点发送PUBLISH命令,将导致集群中的所有节点都向channel频道发送message消息。
PUBLISH消息的正文由cluster.h/clusterMsgDataPublish结构表示:

typedef struct {
	uint32_t channel_len;
	uint32_t message_len;
	// 定义为8字节只是为了对齐其他消息结构
	// 实际的长度由保存的内容决定
	unsigned char bulk_data[8];
	...
} clusterMsgDataPublish;

clusterMsgDataPublish结构的bulk_data属性是一个字节数组,这个字节数组保存了客户端通过PUBLISH命令发送给节点的channel参数和message参数,而结构的channel_len和message_len则分别保存了channel参数的长度和message参数的长度。

不直接向节点广播PUBLISH命令的原因:
这种做法并不符合Redis集群的“各个节点通过发送和接收消息来进行通信”这一规则。

17.8 重点回顾
  • 节点通过握手来将其他节点添加到自己所处的集群当中
  • 集群中的16384个槽可以分别指派给集群中的各个节点,每个节点都会记录哪些槽指派给了自己,而哪些槽又被指派给了其他节点。
  • 节点在街道一个命令请求时,会先检查这个命令请求要处理的键所在的槽是否由自己负责,如果不是的haul,节点将向客户端返回一个MOVED错误,MOVED错误携带的信息可以指引客户端转向至正在负责相关槽的节点。
  • 对Redis集群的重新分片工作是由redis-trib负责执行的,重新分片的关键是将属于某个槽的键值对从一个节点转移至另一个节点。
  • 如果节点A正在迁移槽i至节点B,那么当节点A没能在自己的数据库中找到命令指定的数据库键时,节点A向客户端返回一个ASK错误,指引客户端到节点B继续查找指定的数据库键。
  • MOVED错误表示表示槽的负责权已经从一个节点转移到了另一个节点,而ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施。
  • 集群里的从节点用于复制主节点,并在主节点下线时,代替主节点继续处理命令请求。
  • 集群中的节点通过发送和接收消息来进行通信,常见的消息包括MEET、PING、PONG、PUBLISH、FAIL五种。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值