读《Redis设计与实现》黄键宏著,笔记,第三部分。
第三部分 多机数据库的实现
3.1 复制
在 Redis 中,用户可以通过执行 SLAVEOF 命令或者配置 slaveof 选项,让一个服务器去复制(replicate)另一个服务器,我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被成为从服务器(slave)。
进行复制中的主从服务器双方的数据库将保存相同的数据,概念上将这种现象称为 “ 数据库状态一致 ”,或者简称 “ 一致 ”。
旧版复制功能的实现(2.8以前)
Redis 的复制功能分为 同步(sync)和 命令传播(command propagate)两个操作
同步
同步操作是将从服务器的数据库状态更新至主服务器当前的状态。
从服务器通过向主服务器发送 SYNC 命令来完成,SYNC 命令的执行步骤如下:
- 从服务器向主服务器发送 SYNC 命令。
- 收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
- 当主服务器的 BGSAVE 命令执行完毕时,主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器,从服务器接收并加载 RDB 文件,更新自己的数据库状态到主服务器执行 BGSAVE 命令时的数据库状态。
- 主服务器将记录在缓冲区的写命令发送给从服务器,从服务器执行这个写命令,并将自己数据库状态更新到主服务器当前数据库状态。
命令传播
主服务器的数据库状态被修改时,主服务器向从服务器执行命令传播操作:
主服务器会将自己执行的写命令,也即是造成主从服务器不知的那条写命令,发送给从服务器执行,当从服务器执行了相同的命令后,主从服务器的数据库状态就一致了。
旧版复制功能的缺陷
在 2.8 以前,从服务器从主服务器的复制分为两种:
- 初始复制:从服务器最开始复制主服务器。
- 断线后重复制:从服务器,由于网络断线后,重新连接主服务器时,自动的复制。
旧版这两种情况都是通过从服务器发送 SYNC 命令来实现的。
但是对于 断线后重复制,如果从服务器和主服务器断线很短,那么实际产生的差异很小,对于这种大部分数据库状态一致的情况,还是 SYNC 命令来实现,会显得效率低下。(SYNC 命令会全库快照并发送从服务器,非常耗费资源)
新版复制功能的实现(2.8开始)
为了解决旧版本的断线后重复制情况的低效问题,Redis 从 2.8 开始,使用 PSYNC 命令来代替 SYNC 命令来执行复制时的同步操作。
PSYNC 命令具有 完整重同步(full resynchronization)和 部分重同步(partal resynchronization)两种模式:
- 其中完整重同步用于处理初次复制的情况:完整重同步的执行操作和 SYNC 命令的执行步骤基本一样,他们都是通过让主服务器创建并发送 RDB 文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步的。
- 而部分重同步用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并在这些写命令,就可以将数据库更新至主服务器当前所处的状态。
PSYNC 命令在执行部分重同步所需要的资源,相对于 SYNC 命令要小的多。
部分重同步的实现
部分重同步功能由以下三个部分构成:
- 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量。
- 主服务器的复制积压缓冲区(replication bocklog)。
- 服务器的运行 ID(run ID)。
复制偏移量
执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:
- 主服务器每次向从服务器传播 N 个字节的数据时,就将自己的复制偏移量的值加上 N。
- 从服务器每次收到主服务传播来的 N 个字节的数据时,就将自己的复制偏移量的值加上 N。
通过对比主从服务器的复制偏移量,程序可以很容易地知道主从服务器是否处于一致状态:
- 如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的。
- 相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态。
复制积压缓冲区
复制积压缓冲区时由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小 1MB。
当从服务器重新连上主服务器时,从服务器会通过 PSYNC 命令将自己的复制偏移量 offset 发送给主服务器,主服务器会根据这个复制偏移来决定对从服务器执行何种同步操作:
- 如果 offset 偏移量之后的数据(也即是偏移量 offset+1 开始的数据)仍然存在于复制积压缓冲区里面,那么主服务器将对从服务器执行部分重同步操作。
- 相反,如果 offset 偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作。
复制积压缓存区的大小可以用 repl-backlog-size 选项来配置。
可以用公式 second * write_size_per_second 来估算。
- second 重连的平均时间
- write_size_per_second 主服务器每秒产生的写命令
一般为了安全起见使用 2 * second * write_size_per_second
服务器运行 ID
除了用到复制偏移量和复制积压缓冲区,还会用到服务器运行 ID:
- 每个 Redis 服务器,无论主服务器还是从服务器,都会有自己的运行 ID。
- 运行 ID 在服务器启动时自动生成,由 40 个随机的十六进制字符组成,如:f503b1a44e59116bb8d6b2e3b00adcc38a202b52
当从服务器对主服务器进行初次复制时,主服务器会将自己的运行 ID 传送给从服务器,而从服务器则会将这个运行 ID 保存起来。
当从服务器断线并重连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行 ID:
- 如果从服务器保存的运行 ID 和当前连接的主服务器的运行 ID 相同,那么说明从服务器断线之后复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作。
- 相反地,如果不相同,那么断线前的主服务器就不是当前主服务器,将执行完整重同步操作。
PSYNC 命令的实现
PSYNC 命令的调用方法由两种:
- 如果从服务器以前没有复制过任何主服务器,或者之前执行过
SLAVEOF no one
命令,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC ? -1
命令,主动请求主服务器进行完整重同步(因为这时不可能执行部分重同步)。 - 相反地,如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时将向主服务器发送
PSYNC <runid> <offset>
命令:启动 runid 是上一次复制的主服务器的运行 ID,而 offset 则是从服务器当前的复制偏移量,接收到这个命令的主服务器会通过这两个参数来判断应该对从服务器执行哪种同步操作。
根据情况,接受到 PSYNC 命令的主服务器会向从服务器返回以下三种回复的其中一种:
- 如果主服务器返回
+FULLRESYNC <runid> <offset>
回复,那么表示主服务器将与从服务器执行完整重同步操作:其中 runid 是这个主服务器的运行 ID,从服务器会将这个 ID 保存起来,在下一次发送 PSYNC 命令时使用;而 offset 则是主服务器当前的复制偏移量,从服务器会将这个值作为自己的初始化偏移量。 - 如果主服务器返回
+CONTINUE
回复,那么表示主服务器将与从服务器执行部分重同步操作,从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了。 - 如果主服务器返回
+ERR
回复,那么表示主服务器的版本低于 Redis 2.8,它识别不了 PSYNC 命令,从服务器将向主服务器发送 SYNC 命令,并与主服务器执行完整同步操作。
复制的实现
通过向从服务器发送 SLAVEOF 命令,我们可以让一个从服务器区复制一个主服务器:
SLAVEOF <master_ip> <master_port>
复制功能的详细细节步骤:
步骤 1:设置主服务器的地址和端口
当客户端向从服务器发送 SLAVEOF 命令。
从服务器首先要做的就是将客户端给定的主服务器 IP 地址以及端口保存到服务器状态的 masterhost 属性 和 masterport 属性里面:
struct redisServer{
// ...
/* Replication (slave) */
char *masterauth; /* AUTH with this password with master */
char *masterhost; /* Hostname of master */
int masterport; /* Port of master */
// ...
};
SLAVEOF 命令是一个异步命令,在完成 masterhost 属性 和 masterport 属性的设置工作以后,从服务器将向发送 SLAVEOF 命令的客户端返回 OK,表示复制命令已经被接收,而实际的复制工作将在 OK 返回之后才真正开始执行。
步骤 2:建立套接字连接
在 SLAVEOF 命令执行之后,从服务器将根据命令所设置的 IP 地址和端口,创建连向主服务器的套接字连接。
如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责执行后续的复制工作,比如接收 RDB 文件,以及接收主服务器传播来的写命令,等。
而主服务器在接受(accept)从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来对待,这是从服务器将同时具有服务器(server)和客户端(client)两个身份:从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回命令回复。
因为复制工作接下来的几个步骤都会以从服务器向主服务器发送命令请求的形式来进行,所以理解 “ 从服务器是主服务器的客户端 ” 这一点非常重要。
步骤 3:发送 PING 命令
从服务器称为主服务器的客户端之后,做的第一个事情就是向主服务器发送一个 PING 命令。
PING 命令有两个作用:
- 虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信,通过发送 PING 命令可以检查套接字的读写状态是否正常。
- 因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送 PING 命令可以检查主服务器能否正常处理命令请求。
从服务器在发送 PING 命令之后将遇到以下三种情况的其中一种:
- 如果主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定的时限(timeout)内读取出命令回复的内容,那么表示主从服务器之间的网络状态不佳,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
- 如果主服务器向从服务器返回了一个错误,那么表示主服务器暂时没有办法处理从服务器的命令请求,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
- 如果从服务器读取到 “ PONG ” 回复,那么表示主从服务器之间的网络连接状态正常,并且主服务器可以正常处理从服务器(客户端)发送的命令请求,在这种情况下,从服务器可以继续执行复制工作的下一个步骤。
步骤 4:身份验证
从服务器在收到主服务器返回的 “ PONG ” 回复之后,下一步要做的就是决定是否进行身份验证:
- 如果从服务器设置了 masterauth 选项,那么进行身份验证。
- 未设置,不验证。
masterauth <master-password>
在需要进行身份验证的情况下,从服务器将向主服务器发送一个条 AUTH 命令,命令的参数为从服务器 masterauth 选项的值。
从服务器在身份验证阶段可能遇到的情况有以下几种:
- 主服务器没有设置 requirepass 选项,从服务器没有设置 masterauth 选项,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。
- 从服务器通过 AUTH 命令发送的密码和主服务器 requirepass 选项所设置的密码相同,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。反之,不相同,主服务器将返回一个 invalid password 错误。
- 主服务器设置了 requirepass 选项,从服务器没有设置 masterauth 选项,那么主服务器将返回一个 NOAUTH 错误。另一方面,主服务器没有设置 requirepass 选项,从服务器设置了 masterauth 选项,那么主服务器将返回一个 no password is set 错误。
所有错误都会令从服务器中止目前的复制工作,并从创建套接字开始重新执行复制,直到身份验证通过,或者从服务器放弃进行复制为止。
步骤 5:发送端口信息
在身份验证步骤之后,从服务器将执行命令 REPLOCONF listening-port <port-number>
,向主服务器发送从服务器的监听端口号。
主服务器在接收到这个命令之后,会将端口记录在从服务器所对应的客户端状态的 slave_listening_port 属性中:
struct redisServer{
// ...
char replrunid[CONFIG_RUN_ID_SIZE+1]; /* Master run id if is a master. */
int slave_listening_port; /* As configured with: SLAVECONF listening-port. */
int slave_capa; /* Slave capabilities: SLAVE_CAPA_* bitwise OR. */
// ...
};
主服务器可以通过执行 INFO replication 命令来打印出从服务器的端口号。
127.0.0.1:6379> info replication
# Replication
role:master
connected_slaves:1
slave0:ip=192.168.125.4,port=6379,state=online,offset=1359,lag=1
master_repl_offset:1359
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:1358
步骤 6:同步
在这一步,从服务器将向主服务器发送 PSYNC 命令,执行同步操作,并将自己的数据库更新至主服务器当前所处的状态。
值得一提的是:
- 在同步操作执行之前,只有从服务器是主服务器的客户端
- 执行同步操作之后,主服务器也会成为从服务器的客户端
同步操作执行:
- 如果 PSYNC 命令执行的是完整重同步操作,那么主服务器需要成为从服务器的客户端,才能将保存在缓冲区里面的写命令发送给从服务器执行。
- 如果 PSYNC 命令执行的是部分重同步操作,那么主服务器需要成为从服务器的客户端,才能向从服务器发送保存在复制积压缓冲区里面的写命令。
因此,在同步操作执行之后,主从服务器双方都是对方的客户端,他们可以互相向对方发送命令请求,或者互相向对象返回命令回复。
正因为主服务器成为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,不仅同步操作需要用到这一点,这也是主服务器对从服务器执行命令传播操作的基础。
步骤 7:命令传播
当完成了同步之后,主从服务器就会进入命令传播阶段,这是主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从服务器一直保持一致了。
心跳检测
在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:
REPLCONF ACK <replication_offset>
其中 replication_offset 是从服务器当前的复制偏移量。
发送 REPLCONF ACK 命令对于主从服务器有三个作用:
- 检测主从服务器的网络连接状态。
- 辅助实现 min-slaves 选项。
- 检测命令丢失。
检测主从服务器的网络连接状态
主从服务器通过发送和接收 REPLCONF ACK 命令来检查两者之间的网络连接是否正常:
如果主服务器超过一秒钟没有收到从服务器发来的 REPLCONF ACK 命令,那么主服务器就知道主从服务器之间的连接出现问题了。
通过向主服务器发送 INFO replicaiton 命令,在列出的从服务器列表的 lag 一栏钟,就是距离从服务器最后一次向主服务器发送 REPLCONF ACK 命令过去多少秒。
127.0.0.1:6379> info replication # Replication role:master connected_slaves:1 slave0:ip=192.168.125.4,port=6379,state=online,offset=3473,lag=0 master_repl_offset:3487 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:2 repl_backlog_histlen:3486
一般 lag 在 1 和 0 之间跳动,超过了 1 就有问题了。
检查命令丢失
如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送 REPLCONF ACK 命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。
注意,主服务器向从服务器补发缺失数据这一操作的原理和部分重同步操作的原理非常相似,这两个操作的区别在于:
- 补发缺失数据操作在主从服务器没有断线的情况下执行,
- 而部分重同步操作则是在主从服务器断线并重连之后执行。
3.2 Sentinel(哨兵)
Sentinel(哨岗、哨兵)是 Redis 的高可用性(high availability)解决方案:
由一个或多个 Sentinel 实例(instance)组成的 Sentinel 系统(system),可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自己将下线服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
同时也会继续监视已经下线的主服务器,再次上线时,会成为新主服务器的从服务器。
启动并初始化 Sentinel
启动一个 Sentinel 可以使用命令:
$ redis-sentinel /path/to/your/sentinel.conf
或者命令
$ redis-server /path/to/your/sentinel.conf --sentinel
这两个命令的效果完全相同。
当一个 Sentinel 启动时,它需要执行以下步骤:
- 初始化服务器。
- 将普通 Redis 服务器使用的代码替换成 Sentinel 专用代码。
- 初始化 Sentinel 状态。
- 根据给定的配置文件,初始化 Sentinel 的监视主服务器列表。
- 创建连向主服务器的网络连接。
详细介绍如下:
初始化服务器
首先,因为 Sentinel 本质上只是一个运行的特殊模式下的 Redis 服务器,所以启动 Sentinel 的第一步,就是初始化一个普通的 Redis 服务器。
不过,因为 Sentinel 执行的工作和普通的 Redis 服务器执行的工作不同,所以 Sentinel 的初始化过程和普通 Redis 服务器的初始化过程并不完全相同。
Sentinel 模式下 Redis 服务器主要功能的使用情况:
功能 | 使用情况 |
---|---|
数据库和键值对方面的命令,比如 SET、DEL、FLUSHDB | 不使用 |
事务命令,比如 MULTI 和 WATCH | 不使用 |
脚本命令,比如 EVAL | 不使用 |
RDB 持久化命令,比如 SAVE 和 BGSAVE | 不使用 |
AOF 持久化命令,比如 BGREWRITEAOF | 不使用 |
复制命令,比如 SLAVEOF | Sentinel 内部可以使用,但是客户端不可以使用 |
发布与订阅命令,比如 PUBLISH 和 SUBSCRIBE | SUBSCRIBE、PSUBCRIBE、UNSUBSCRIBE、PUNSUBSCIRBE四个命令在 Sentinel 内部和客户端都可以使用,但是 PUBLISH 命令只能在 Sentinel 内部使用 |
文件事件处理器(负责发送命令请求、处理命令回复) | Sentinel 内部使用,但关联的文件事件处理器和普通 Redis 服务器不同 |
时间事件处理器(负责执行 serverCron 函数) | Sentinel 内部使用,时间事件的处理器仍然是 serverCron 函数,serverCron 函数会调用 sentinel.c/sentinelTimer 函数,后者包含了 Sentinel 要执行的所有操作 |
使用 Sentinel 专用代码
启动 Sentinel 的第二个步骤就是将一部分普通 Redis 服务器使用的代码替换成 Sentinel 专用代码。
使用 Sentinel.c/sentinelcmds 作为服务器的命令表,替换普通 Redis 服务器 server.c/redisCommandTable 的命令表。
这就解释了为什么在 Sentinel 模式下,Redis 服务器不能执行诸如 SET、DBSIZE、EVAl 等命令,因为就没有加载进去。
PING、SENTINEL、INFO、SUBSCRIBE、PSUBCRIBE、UNSUBSCRIBE、PUNSUBSCIRBE,这七个命令是客户端可以对 Sentinel 执行的全部命令。
初始化 Sentinel 状态
在应用了 Sentinel 的专用代码之后,接下来,服务器会初始化一个 sentinel.c/sentinelState 结构,这个结构保持了服务器中所有和 Sentinel 功能有关的状态:
/* Main state. */
struct sentinelState {
char myid[CONFIG_RUN_ID_SIZE+1]; /* This sentinel ID. */
// 当前纪元,用于实现故障转移
uint64_t current_epoch; /* Current epoch. */
// 保存了所有被这个sentinel监视的主服务器
// 字典的键是主服务器的名字
// 字典的值则是一个指向sentinelRedisInstance结构的指针
dict *masters; /* Dictionary of master sentinelRedisInstances.
Key is the instance name, value is the
sentinelRedisInstance structure pointer. */
// 是否进入了TILT模式?
int tilt; /* Are we in TILT mode? */
// 目前正在执行的脚本的数量
int running_scripts; /* Number of scripts in execution right now. */
// 进入TILT模式的事件
mstime_t tilt_start_time; /* When TITL started. */
// 最后一次执行时间处理器的时间
mstime_t previous_time; /* Last time we ran the time handler. */
// 一个FIFO队列,包含了所有需要执行的用户脚本
list *scripts_queue; /* Queue of user scripts to execute. */
char *announce_ip; /* IP addr that is gossiped to other sentinels if
not NULL. */
int announce_port; /* Port that is gossiped to other sentinels if
non zero. */
PORT_ULONG simfailure_flags; /* Failures simulation. */
} sentinel;
初始化 Sentinel 状态的 masters属性
Sentinel 状态中的 masters 字典记录了所有被 Sentinel 监视的主服务器的相关信息,其中:
- 字典的键是被监视主服务器的名字。
- 字典的值是被监视主服务器对应的 sentinel.c/sentinelRedisInstance 结构。
每个 sentinelRedisInstance 结构代表一个被 Sentinel 监视的 Redis 服务器实例(instance),这个实例可以是主服务器、从服务器,或者另一个 Sentinel。
实例结构包含的属性非常多,说明其中主要的:
typedef struct sentinelRedisInstance {
// 标识值,记录了实例的类型,以及该实例的当前状态
int flags; /* See SRI_... defines */
// 实例的名字
// 主服务器的名字由用户的配置文件中设置
// 从服务器以及 Sentinel 的名字由 Sentinel 自动设置
// 格式为 ip:port,例如 127.0.0.1:26379
char *name; /* Master name from the point of view of this sentinel. */
// 实例的运行 ID
char *runid; /* Run ID of this instance, or unique ID if is a Sentinel.*/
// 配置纪元,用于实现故障转移
uint64_t config_epoch; /* Configuration epoch. */
// 实例地址
sentinelAddr *addr; /* Master host. */
instanceLink *link; /* Link to the instance, may be shared for Sentinels. */
mstime_t last_pub_time; /* Last time we sent hello via Pub/Sub. */
mstime_t last_hello_time; /* Only used if SRI_SENTINEL is set. Last time
we received a hello from this Sentinel
via Pub/Sub. */
mstime_t last_master_down_reply_time; /* Time of last reply to
SENTINEL is-master-down command. */
mstime_t s_down_since_time; /* Subjectively down since time. */
mstime_t o_down_since_time; /* Objectively down since time. */
// SENTINEL down-after-milliseconds 选项设定的值
// 实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)
mstime_t down_after_period; /* Consider it down after that period. */
mstime_t info_refresh; /* Time at which we received INFO output from it. */
/* Role and the first time we observed it.
* This is useful in order to delay replacing what the instance reports
* with our own configuration. We need to always wait some time in order
* to give a chance to the leader to report the new configuration before
* we do silly things. */
int role_reported;
mstime_t role_reported_time;
mstime_t slave_conf_change_time; /* Last time slave master addr changed. */
/* Master specific. */
dict *sentinels; /* Other sentinels monitoring the same master. */
dict *slaves; /* Slaves for this master instance. */
// SENTINEL monitor <master-name> <IP> <port> <quorum> 选项中的 quorum 参数
// 判断这个实例时客观下线(objectively down)所需要的支持投票数量
unsigned int quorum;/* Number of sentinels that need to agree on failure. */
// SENTINEL parallel-syncs <master-name> <number> 选项的值
// 在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
int parallel_syncs; /* How many slaves to reconfigure at same time. */
char *auth_pass; /* Password to use for AUTH against master & slaves. */
/* Slave specific. */
mstime_t master_link_down_time; /* Slave replication link down time. */
int slave_priority; /* Slave priority according to its INFO output. */
mstime_t slave_reconf_sent_time; /* Time at which we sent SLAVE OF <new> */
struct sentinelRedisInstance *master; /* Master instance if it's slave. */
char *slave_master_host; /* Master host as reported by INFO */
int slave_master_port; /* Master port as reported by INFO */
int slave_master_link_status; /* Master link status as reported by INFO */
PORT_ULONGLONG slave_repl_offset; /* Slave replication offset. */
/* Failover */
char *leader; /* If this is a master instance, this is the runid of
the Sentinel that should perform the failover. If
this is a Sentinel, this is the runid of the Sentinel
that this Sentinel voted as leader. */
uint64_t leader_epoch; /* Epoch of the 'leader' field. */
uint64_t failover_epoch; /* Epoch of the currently started failover. */
int failover_state; /* See SENTINEL_FAILOVER_STATE_* defines. */
mstime_t failover_state_change_time;
mstime_t failover_start_time; /* Last failover attempt start time. */
// SENTINEL failover-timeout <master-name> <ms> 选项的值
// 刷新故障迁移状态的最大时限
mstime_t failover_timeout; /* Max time to refresh failover state. */
mstime_t failover_delay_logged; /* For what failover_start_time value we
logged the failover delay. */
struct sentinelRedisInstance *promoted_slave; /* Promoted slave instance. */
/* Scripts executed to notify admin or reconfigure clients: when they
* are set to NULL no script is executed. */
char *notification_script;
char *client_reconfig_script;
sds info; /* cached INFO output */
} sentinelRedisInstance;
对 Sentinel 状态的初始化将引发对 masters 字典的初始化,而 masters 字典的初始化是根据被载入的 Sentinel 配置文件来进行的。
创建连向主服务器的网络连接
初始化 Sentinel 的最后一步是创建连向被监视主服务器的网络连接,Sentinel 将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息。
对于每个被 Sentinel 监视的主服务器来说,Sentinel 会创建两个连向主服务器的异步网络连接:
- 一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。
- 一个是订阅连接,这个连接专门用于订阅主服务器的
__sentinel__:hello
频道。
接下来一节将介绍 Sentinel 是如何通过命令连接和订阅连接来与被监视主服务器进行通信的。
获取主服务器的信息
Sentinel 默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送 INFO 命令,并通过分析 INFO 命令的回复来获取主服务器的当前信息。
通过分析主服务器返回的 INFO 命令回复,Sentinel 可以获取以下两方面的信息:
- 一方面是关于主服务器本身的信息,包括 run_id 域记录的服务器运行 ID,以及 role 域记录的服务器角色;
- 一方面是关于主服务器属下所有从服务器的信息,每隔从服务器都由一个 “ slave ” 字符串开头的行记录,每行的 ip = 域记录了从服务器的 IP 地址,每行的 ip = 域则记录了从服务器的端口。根据这些 IP 地址和端口号,Sentinel 无须用户提供从服务器的地址信息,就可以自动发现从服务器。
至于主服务器返回的从服务器信息,则会被用于更新主服务器实例结构的 slaves 字典,这个字典记录了主服务器属下从服务器的名单:
- 字典的键是由 Sentinel 自动设置的从服务器名字,格式为 ip:port
- 字典的值则是从服务器对应的 sentinelRedisInstance 实例结构。
Sentinel 在分析 INFO 命令中包含的从服务器信息时,会检查从服务器对应的实例结构是否已经存在于 slaves 字典:
- 如果存在,就进行更新。
- 不存在,就新建一个。
获取从服务器信息
当 Sentinel 发现主服务器由新的从服务器出现时,Sentinel 除了会为这个新的从服务器创建相应的实例结构之外,Sentinel 还会创建连接到从服务器的命令连接和订阅连接。
在创建命令连接之后,Sentinel 在默认情况下,会以每十秒一次的频率通过命令连接向从服务器发送 INFO 命令。
根据 INFO 命令的回复,Sentinel 会提取处以下的信息:
- 从服务器的运行 ID run_id。
- 从服务器的角色 role。
- 主服务器的 IP 地址 master_host,以及主服务器的端口号 master_port。
- 主从服务器的连接状态 master_link_status。
- 从服务器的优先级 slave_priority。
- 从服务器的复制偏移量 slave_repl_offset。
根据这些信息,Sentinel 会对从服务器的实例结构进行更新。
向主服务器和从服务器发送信息
在默认情况下,Sentinel 会以每秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:
PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"
- 其中以 s_ 开头的参数记录的是 Sentinel 本身的信息。
- 而 m_ 开头的参数记录的则是主服务器的信息。监视主服务器,就是主服务器的信息,监视从服务器,就是从服务器正在复制的主服务器的信息。
接收来自主服务器和从服务器的频道信息
当 Sentinel 与一个主服务器或者从服务器建立起订阅连接之后,Sentinel 就会通过订阅连接,向服务器发送以下命令:
SUBSCRIBE __sentinel__:hello
Sentinel 对 __sentinel__:hello
频道的订阅会一直持续到 Sentinel 与服务器连接断开为止。
这也就是说,对于每个与 Sentinel 连接的服务器:
-
Sentinel 既通过命令连接向服务器的
__sentinel__:hello
频道发送信息, -
又通过订阅连接从服务器的
__sentinel__:hello
频道接收信息。
对于监视同一个服务器的多个 Sentinel 来说,一个 Sentinel 发送的信息会被其他 Sentinel 接收到,这些信息会被用于更新其他 Sentinel 对发送信息 Sentinel 的认知,也会被用于更新其他 Sentinel 对被监视服务器的认知。
当一个 Sentinel 从 __sentinel__:hello
频道收到一条信息时,Sentinel 会对这条信息进行分析,提取出信息中的 Sentinel IP 地址,Sentinel 端口号、Sentinel 运行 ID 等八个参数,并进行以下检查:
- 如果信息和自己的相同,就不处理。
- 相反,对相应的主服务器是实例结构进行更新。
更新 sentinels 字典
Sentinel 为主服务器创建的实例结构中的 sentinels 字典保存了除 Sentinel 本身之外,同样监视这个主服务器的其他 Sentinel 的资料:
- 键是一个 Sentinel 的名字,格式
ip:port
- 值是对应 Sentinel 的实例结构。
当一个 Sentinel 接收到其他 Sentinel 发来的信息时(我们称呼发送的信息的 Sentinel 为源 Sentinel,接收信息的 Sentinel 为目标 Sentinel),目标 Sentinel 会从信息中分析并提取处以下两方面参数:
- 与 Sentinel 有关的参数:源 Sentinel 的 IP 地址、端口号、运行 ID 和配置纪元。
- 与主服务器有关的参数:源 Sentinel 正在监视的主服务器的名字、IP 地址、端口号和配置纪元。
根据信息中提取出的主服务器参数,目标 Sentinel 会在自己的 Sentinel 状态的 masters 字典中查找相应的主服务器实例结构,然后根据提取出的 Sentinel 参数,检查主服务器实例结构的 sentinels 字典中,源 Sentinel 的实例结构是否存在:
- 存在,就更新。
- 不存就新建并添加。
因为一个 Sentinel 可以通过分析接收到的频道信息来获知其他 Sentinel 的存在,并通过发送频道信息来让其他 Sentinel 知道自己的存在,所以用户在使用 Sentinel 的时候并不需要提供各个 Sentinel 的地址信息,监视同一个主服务器的多个 Sentinel 可以自动发现对方。
创建连向其他 Sentinel 的命令连接
当 Sentinel 通过频道信息发现一个新的 Sentinel 时,它不仅会为新 Sentinel 在 sentinels 字典中创建相应的实例结构,
还会创建一个连向新 Sentinel 的命令连接,而新 Sentinel 也同样会创建连向这个 Sentinel 的命令连接,最终监视同一主服务器的多个 Sentinel 将会形成相互连接的网络。
使用命令连接相连的各个 Sentinel 可以通过向其他 Sentinel 发送命令请求来进行信息交换。
检测主观下线状态
在默认情况下,Sentinel 会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他 Sentinel 在内)发送 PING 命令,并通过实例返回的 PING 命令回复来判断实例是否在线。
有效回复:实例返回 +PONG、-LOADING、-MASTERDOWN 三种回复,其他都是无效回复。
Sentinel 配置文件中的 down-after-milliseconds
选项指定了 Sentinel 判断实例进入主观下线所需的时间长度:
如果一个实例在 down-after-milliseconds
毫秒内,连续向 Sentinel 返回无效回复,那么 Sentinel 会修改这个实例所对应的实例结构,在结构的 flags 属性中打开 SRI_S_DOWN 标识,以此表示这个实例已经进入主观下线状态。
不同 Sentinel 的 down-after-milliseconds
选项可能不用。当都判断主观下线后,实例才进入主观下线状态。
检测客观下线状态
当 Sentinel 将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主主服务器的其他 Sentinel 进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当 Sentinel 从其他 Sentinel 那里接收到足够数量的已下线判断之后,Sentinel 就会将主服务器判定为客观下线,并对主服务器执行故障转移操作。
发送 SENTINEL is-master-down-by-addr 命令
Sentinel使用:
SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>
命令询问其他 Sentinel 是否同意主服务器已下线,各个参数的意义如下:
参数 | 意义 |
---|---|
ip | 被 Sentinel 判断为主观下线的主服务器的 IP 地址 |
port | 被 Sentinel 判断为主观下线的主服务器的端口号 |
current_epoch | Sentinel 当前的配置纪元,用于选举领头 Sentinel |
runid | 可以是 * 符号或者 Sentinel 的运行 ID :* 符号:表示代表命令仅仅用于检测主服务器的客观下线状态;Sentinel 的运行 ID :用于选举领头 Sentinel |
接收 SENTINEL is-master-down-by-addr 命令
当一个 Sentinel(目标 Sentinel)接收到另一个 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 is-master-down-by-addr 命令的回复
根据其他 Sentinel 发回的 SENTINEL is-master-down-by-addr 命令回复,Sentinel 将统计其他 Sentinel 同意主服务器已下线的数量,当这一数量达到配置指定的判断客户下线所需的数量时,Sentinel 会将主服务器实例结构的 flags 属性的 SRI_O_DOWN 标识打开,标识主服务器已经进入客观下线。
// SENTINEL monitor <master-name> <IP> <port> <quorum> 选项中的 quorum 参数
// 判断这个实例时客观下线(objectively down)所需要的支持投票数量
unsigned int quorum;/* Number of sentinels that need to agree on failure. */
不同的 Sentinel 可能配置不同。
选举领头 Sentinel
当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个 Sentinel 会进行协商,选举出一个领头 Sentinel,并由领头 Sentinel 对下线主服务器执行故障转移操作。
以下是 Redis 选举领头 Sentinel 的规则和方法:
- 所有在线的 Sentinel 都有被选为领头 Sentinel 的资格,换句话说,监视同一个主服务器的多个在线 Sentinel 中的任意一个都有可能成为领头 Sentinel。
- 每次进行领头 Sentinel 选举之后,不论选举是否成功,所有 Sentinel 的配置纪元(configuration epoch)的值都会自增一次,配置纪元实际上就是一个计数器。
- 在一个配置纪元里面,所有 Sentinel 都有一次将某个 Sentinel 设置为局部领头 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 为止。
故障转移
在选举产生出领头 Sentinel 之后,领头 Sentinel 将对已下线的主服务器执行故障转移操作。
该操作包含以下三个步骤:
- 在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器。(SLAVEOF no one)
- 让已下线主服务器属下的所有从服务器改为复制新的主服务器。(SLAVEOF)
- 将已下线的主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主动服务的从服务器。(SLAVEOF)
挑选新的主服务器过程
领头 Sentinel 会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项地对列表进行过滤:
- 删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。
- 删除列表中所有最近五秒内没有回复过领头 Sentinel 的 INFO 命令的从服务器,这可以保证列表中剩余的从服务器都是最近成功进行过通信的。
- 删除所有与已下线主服务器连接断开超过 down-after-millisecond * 10 毫秒的从服务器,保证列表中剩余的从服务器都没有过早地与主服务器断开连接。
之后,领头 Sentinel 将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。
如果多个从服务器的优先级相同,则选出其中偏移量最大的从服务器(保存最新数据的从服务器)。
如果还相同,就按照运行 ID 排序,选出其中运行 ID 最小的从服务器。
3.3 集群
Redis 集群是 Redis 提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。
本节将对集群的节点、槽指派、命令执行、重新分片、转向、故障转移、消息等各个方面进行介绍。
设置 redis.conf 配置,使得 redis 以 cluster node 模式运行
cluster-enabled yes cluster-config-file nodes.conf cluster-node-timeout 5000 appendonly yes
节点
一个 Redis 集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。
连接各个节点的工作可以使用 CLUSTER MEET 命令来完成,该命令的格式如下:
CLUSTER MEET <ip> <port>
向一个节点 node 发送 CLUSTER MEET 命令,可以让 node 节点与 ip 和 port 所指定的节点进行握手(handshake),当握手成功时,node 节点就会将 ip 和 port 所指定的节点添加到 node 节点当前所在的集群中。
启动节点
一个节点就是一个运行在集群模式下的 Redis 服务器,Redis 服务器在启动时会根据 cluster-enabled 配置选项是否为 yes 来决定是否开启服务器的集群模式。
节点(运行在集群模式下的 Redis 服务器)会继续使用所有在单机模式下使用的服务器组件。比如:
- 节点会继续使用文件事件处理器来处理命令请求和返回命令回复。
- 节点会继续使用时间事件处理器来执行 serverCron 函数,会调用集群模式下特有的 clusterCron 函数。
- 节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象。
- 节点会继续使用 RDB 持久化模块和 AOF 持久化模块来执行持久化工作。
- 节点会继续使用发布与订阅模块来执行 PUBLISH、SUBSCRIBE 等命令。
- 节点会继续使用复制模块来进行节点的复制工作。
- 节点会继续使用 Lua 脚本环境来执行客户端输入的 Lua 脚本。
集群数据结构
集群节点会继续使用 redisServer 结构来保存服务器的状态,使用 redisClient 结构来保存客户端的状态。
集群模式下用到的数据,节点会将它们保存到
- cluster.h/clusterNode
- cluster.h/clusterLink
- cluster.h/clusterState
结构里面。
clusterNode :
clusterNode 结构保存了一个节点的当前状态。每个节点都会使用一个 clusterNode 结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的 clusterNode 结构,以此来记录其他节点状态:
typedef struct clusterNode {
// 创建节点的时间
mstime_t ctime; /* Node object creation time. */
// 节点的名字,由40个十六进制字符组成
// 例如:71d2bc39de865da096e5406110b6d366056ca9f6
char name[CLUSTER_NAMELEN]; /* Node name, hex string, sha1-size */
// 节点标识
// 使用各种不同的标识值记录节点的角色(比如主节点或者从节点)
// 以及节点目前所处的状态(比如在线或者下线)
int flags; /* CLUSTER_NODE_... */
// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch; /* Last configEpoch observed for this node */
unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
int numslots; /* Number of slots handled by this node */
int numslaves; /* Number of slave nodes, if this is a master */
struct clusterNode **slaves; /* pointers to slave nodes */
struct clusterNode *slaveof; /* pointer to the master node. Note that it
may be NULL even if the node is a slave
if we don't have the master node in our
tables. */
mstime_t ping_sent; /* Unix time we sent latest ping */
mstime_t pong_received; /* Unix time we received the pong */
mstime_t fail_time; /* Unix time when FAIL flag was set */
mstime_t voted_time; /* Last time we voted for a slave of this master */
mstime_t repl_offset_time; /* Unix time we received offset for this node */
mstime_t orphaned_time; /* Starting time of orphaned master condition */
PORT_LONGLONG repl_offset; /* Last known repl offset for this node. */
// 节点的 IP 地址
char ip[NET_IP_STR_LEN]; /* Latest known IP address of this node */
// 节点的端口号
int port; /* Latest known port of this node */
// 保存连接节点所需的有关信息
clusterLink *link; /* TCP/IP link with this node */
list *fail_reports; /* List of nodes signaling this as failing */
} clusterNode;
clusterNode 结构的 link 属性是一个 clusterLink 结构,该结构保存了连接节点所需的有关信息。
clusterLink:
/* clusterLink encapsulates everything needed to talk with a remote node. */
typedef struct clusterLink {
// 连接的创建时间
mstime_t ctime; /* Link creation time */
// TCP 套接字描述符
int fd; /* TCP socket file descriptor */
// 输出缓冲区,保存着等待发送给其他节点的消息(message)
sds sndbuf; /* Packet send buffer */
// 输入缓冲区,保存着从其他节点接收到的消息
sds rcvbuf; /* Packet reception buffer */
// 与这个连接相关联的节点,如果没有的话就是NULL
struct clusterNode *node; /* Node related to this link if any, or NULL */
} clusterLink;
最后,每个节点都保存着一个 clusterState 结构,这个结构记录了在当前节点的视角下,集群目前所处的状态。
clusterState:
typedef struct clusterState {
// 指向当前节点的指针
clusterNode *myself; /* This node */
// 集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;
// 集群当前的状态:是在线还是下线
int state; /* CLUSTER_OK, CLUSTER_FAIL, ... */
// 集群中至少处理者一个槽的节点的数量
int size; /* Num of master nodes with at least one slot */
// 集群节点名单(包括 myself 节点)
// 字典的键为节点的名字,字典的值为节点对应的 clusterNode 结构
dict *nodes; /* Hash table of name -> clusterNode structures */
dict *nodes_black_list; /* Nodes we don't re-add for a few seconds. */
clusterNode *migrating_slots_to[CLUSTER_SLOTS];
clusterNode *importing_slots_from[CLUSTER_SLOTS];
clusterNode *slots[CLUSTER_SLOTS];
zskiplist *slots_to_keys;
/* The following fields are used to take the slave state on elections. */
mstime_t failover_auth_time; /* Time of previous or next election. */
int failover_auth_count; /* Number of votes received so far. */
int failover_auth_sent; /* True if we already asked for votes. */
int failover_auth_rank; /* This slave rank for current auth request. */
uint64_t failover_auth_epoch; /* Epoch of the current election. */
int cant_failover_reason; /* Why a slave is currently not able to
failover. See the CANT_FAILOVER_* macros. */
/* Manual failover state in common. */
mstime_t mf_end; /* Manual failover time limit (ms unixtime).
It is zero if there is no MF in progress. */
/* Manual failover state of master. */
clusterNode *mf_slave; /* Slave performing the manual failover. */
/* Manual failover state of slave. */
PORT_LONGLONG mf_master_offset; /* Master offset the slave needs to start MF
or zero if stil not received. */
int mf_can_start; /* If non-zero signal that the manual failover
can start requesting masters vote. */
/* The followign fields are used by masters to take state on elections. */
uint64_t lastVoteEpoch; /* Epoch of the last vote granted. */
int todo_before_sleep; /* Things to do in clusterBeforeSleep(). */
PORT_LONGLONG stats_bus_messages_sent; /* Num of msg sent via cluster bus. */
PORT_LONGLONG stats_bus_messages_received; /* Num of msg rcvd via cluster bus.*/
} clusterState;
CLUSTER MEET 命令的实现
通过向节点 A 发送 CLUSTER MEET 命令,客户端可以让接收命令的节点 A 将另一个节点 B 添加到节点 A 当前所在的集群里面:
CLUSTER MEET <ip> <port>
收到命令的节点 A 将与节点 B 进行握手(handshake),以此来确认彼此的存在,并为将来的进一步通信打好基础:
- 节点 A 会为节点 B 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典里面。、
- 之后,节点 A 将根据 CLUSTER MEET 命令给定的 IP 地址和端口号,向节点 B 发送一条 MEET 消息(message)。
- 如果一切顺利,节点 B 将接收到节点 A 发送的 MEET 消息,节点 B 会为节点 A 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典里面。
- 之后,节点 B 将向节点 A 返回一条 PONG 消息。
- 如果一切顺利,节点 A 将接收到节点 B 返回的 PONG 消息,通过这条 PONG 消息节点 A 可以知道节点 B 已经成功地接收到了自己发送的 MEET 消息。
- 之后,节点 A 将向节点 B 返回一条 PING 消息。
- 如果一切顺利,节点 B 将接收到节点 A 返回的 PING 消息,通过这条 PING 消息节点 B 可以知道节点 A 已经成功地接受到了自己返回的 PONG 消息,握手成功。
之后,节点 A 将节点 B 的信息通过 Gossip 协议传播给集群中的其他节点,让其他节点也与节点 B 进行握手,最终,经过一点时间之后,节点 B 会被集群中的所有节点认识。
槽指派
Redis 集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为 16384(0x4000) 个槽(slot),数据库中的每个键都属于这 16384 个槽的其中一个,集群中的每个节点可以处理 0 个或最多 16384 个槽。
当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。
通过向节点发送 CLUSTER ADDSLOTS 命令,我们可以将一个或者多个槽指派(assign)给节点负责:
CLUSTER ADDSLOTS <slot> [slot ...]
记录节点的槽信息
clusterNode 结构中的 slots 属性和 numslot 属性记录了节点负责的那些槽:
unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
int numslots; /* Number of slots handled by this node */
slots 属性是一个二进制位数组(bit array),这个数组的长度为 16384/8 = 2048 个字节,共包含 16384 个二进制位。
Redis 以 0 为起始索引,16384 为终止索引,对 slots 数组中的 16384 个二进制位进行编号,并根据索引 i 上的二进制位的值来判断节点是否负责处理槽 i :
- 如果 slots 数组在索引 i 上的二进制位的值位 1,那么表示节点负责处理槽 i。
- 如果 slots 数组在索引 i 上的二进制位的值位 0,那么表示节点不负责处理槽 i。
因为取出和设置 slots 数组中的任意一个二进制位的值的复杂度仅为 O(1),所以对于一个给定节点的 slots 数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的负责度都是 O(1)。
至于 numslots 属性则记录节点负责处理的槽的数量,也即是 slots 数组中值为 1 的二进制的数量。
传播节点的槽指派信息
一个节点除了会将自己负责处理的槽,记录在 clusterNode 结构的 slots 属性和 numslots 属性之外,他还会将自己的 slots 数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理那些槽。
当节点 A 通过消息从节点 B 那里接收到节点 B 的 slots 数组时,节点 A 会在自己的 clusterState.nodes 字典中查找节点 B 对应的 clusterNode 结构,并对结构中的 slots 数组进行保存或更新。
因为集群中的每个节点都会将自己的 slots 数组通过信息发送给集群中的其他节点,并且每个接收到 slots 数组的节点都会将数组保存到相应节点的 clusterNode 结构里面,因此,集群中的每个节点都会知道数据库中的 16384 个槽分别被指派给了集群中的那些节点。
记录集群所有槽的指派信息
clusterState 结构中的 slots 数组记录了集群中所有 16384 个槽的指派信息:
clusterNode *slots[CLUSTER_SLOTS];
slots 数组包含了 16384 个项,每个数组项都是一个指向 clusterNode 结构的指针:
- 如果 slots[i] 指针指向 NULL,那么表示槽 i 尚未指派给任何节点。
- 如果 slots[i] 指针指向一个 clusterNode 结构,那么表示槽 i 已经指派给了clusterNode 结构代表的节点。
如果直将槽指派信息保存在每个节点的 clusterNode.slots 数组里,会出现一些无法高效地解决的问题,而 clusterState.slots 数组的存在解决了这些问题:
- 如果节点只使用 clusterNode.slots 数组来记录槽的指派信息,那么为了知道槽 i 是否已经指派,或者槽 i 被指派给了哪个节点,程序需要遍历 clusterState.nodes 字典中的所有 clusterNode 结构,检查这些结构的 slots 数组,直到找到负责处理槽 i 的节点为止,这个过程的复杂度为 O(N),其中 N 为 clusterState.nodes 字典保存的 clusterNode 结构的数量。
- 而通过将所有槽的指派信息保存在 clusterState.slots 数组里面,程序要检查槽 i 是否已经被指派,又或者取得负责处理槽 i 的节点,只需要访问 clusterState.slots[i] 的值即可,这个操作的复杂度仅为 O(1)。
虽然 clusterState.slots 数组记录了集群中所有槽的指派信息,但是使用 clusterNode 结构的 slots 数组来记录单个节点的槽指派信息仍然是有必要的:
- 因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的 clusterNode.slots 数组整个发出去就可以了。
- 另一方面,如果 Redis 不使用 clusterNode.slots 数组,而单独使用 clusterState.slots 数组的话,那么每次要将节点 A 的槽指派信息传播给其他节点时,程序必须遍历整个 clusterState.slots 数组,记录节点 A 负责处理哪些槽,然后才能发送节点 A 的指派信息,这比直接使用 clusterNode.slots 数组要麻烦和低效很多。
clusterState.slots 数组记录了集群中所有槽的指派信息,而 clusterNode.slots 数组只记录了 clusterNode 结构所代表的节点的指派信息,这是两个 slots 数组的关键区别所在。
CLUSTER ADDSLOTS 命令的实现
CLUSTER ADDSLOTS 命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责:
CLUSTER ADDSLOTS <slot> [slot ...]
CLUSTER ADDSLOTS 命令实现的伪代码:
def CLUSTER_ADDSLOTS(*all_input_slots):
# 遍历所有输入槽,检查它们是否都是未指派槽
for i in all_input_slots:
# 如果有,哪怕一个槽已经被指派给了某个节点
# 那么项客户端返回错误,并终止命令执行
if clusterState.slots[i] != NULL:
reply_error()
return
# 如果所有输入槽都是未指派槽
# 那么再次遍历所有输入槽,将这些槽指派给当前节点
for i in all_input_slots:
# 设置clusterState结构的slots数组
# 将slots[i]的指针指向代表当前节点的clusterNode结构
clusterState.slots[i] = clusterState.myself
# 访问代表当前节点的clusterNode结构的slots数组
# 将数组在索引 i 上的二进制位设置位 1
setSlotBit(clusterState.myself.slots, i)
在 CLUSTER ADDSLOTS 命令执行完毕之后,节点会通过发送消息告知集群中的其他节点,自己目前正在负责哪些槽。
在集群中执行命令
在对数据库中的 16384 个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。
当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:
- 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
- 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个 MOVED 错误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令。
计算键属于哪个槽
节点使用以下算法来计算给定键 key 属于哪个槽:
def slot_number(key):
# key & 11 1111 1111 1111
return CRC16(key) & 16383
其中 CRC16(key) 语句用于计算键 key 的 CRC-16 校验和,而 & 16383 语句则用于计算出一个介于 0 至 16383 之间的整数作为键 key 的曹号。
可以使用 CLUSTER KEYSLOT <key>
命令可以查看一个给定键属于哪个槽:
127.0.0.1:7001> cluster keyslot name
(integer) 5798
判断槽是否由当前节点负责处理
当节点计算出键所属的槽 i 之后,节点就会检查自己在 clusterState.slots 数组中的项 i,判断键所在槽是否由自己负责:
- 如果 clusterState.slots[i] 等于 clusterState.myself,那么说明槽 i 由当前节点负责,节点可以执行客户端发送的命令。
- 如果 clusterState.slots[i] 不等于 clusterState.myself,那么说明槽 i 并非由当前节点负责,节点会根据 clusterState.slots[i] 指向的 clusterNode 结构所记录的节点 IP 和端口号,向客户端返回 MOVED 错误,指引客户端转向至正在处理槽 i 的节点。
MOVED 错误
当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个 MOVED 错误,指引客户端转向至正在处理槽的节点。
MOVED 错误的格式为:
MOVED <slot> <ip>:<port>
当客户端开启集群模式:
redis-cli -c -p 7000
客户端接收到节点返回的 MOVED 错误时,客户端会根据 MOVED 错误中提供的 IP 地址和端口号,转向至负责处理槽 slot 的节点,并向该节点重新发送之前想要执行的命令。
127.0.0.1:7001> set mm 1111
-> Redirected to slot [125] located at 127.0.0.1:7000
OK
当客户端使用单机(stand alone)模式,客户端接收到 MOVED 错误时,只会打印出来。
127.0.0.1:7000> set name holl
(error) MOVED 5798 127.0.0.1:7001
一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是换一个套接字来发送命令。
如果客户端尚未与想要转向的节点创建套接字连接,那么客户端会先根据 MOVED 错误提供的 IP 地址和端口号来连接节点,然后再进行转向。
节点数据库的实现
集群节点保存键值对以及键值对过期时间的方式,与单机 Redis 服务器保存的方式完全相同。
节点和单机服务器再数据库方面的一个区别是,节点只能使用 0 号数据库,而单机 Redis 服务器则没有这一限制。
另外,除了将键值对保存再数据库里面之外,节点还会用 clusterState 结构中的 slots_to_keys 跳跃表来保存槽和键之间的关系:
typedef struct clusterState{
// ...
zskiplist *slots_to_keys;
// ...
}
slots_to_keys 跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员(number)都是一个数据库键:
- 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到 slots_to_keys 跳跃表。
- 当节点删除数据库中的某个键值对时,节点就会在 slots_to_keys 跳跃表解除被删除键和槽号的关联。
通过在 slots_to_keys 跳跃表中记录各个数据库所属的槽,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作。
例如命令 CLUSTER GETKEYSSINSLOT <slot> <count>
可以返回最多 count 个属于槽 slot 的数据库键,而这个命令就是通过遍历 slots_to_keys 跳跃表来实现的。
重新分片
Redis 集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。
重新分片的实现原理
Redis 集群的重新分片操作是由 Redis 的集群管理软件 redis-trib 负责执行的,Redis 提供了进行重新分片所需的所有命令,而 redis-trib 则通过向源节点和目标节点发送命令来进行重新分片操作。
redis-trib 对集群的单个槽 slot 进行重新分片的步骤如下:
- redis-trib 对目标节点发送
CLUSTER SETSLOT <slot> IMPORTING <source_id>
命令,让目标节点准备好从源节点导入(import)属于槽 slot 的键值对。 - redis-trib 对源节点发送
CLUSTER SETSLOT <slot> MIGATING <target_id>
命令,让源节点准备好将属于槽 slot 的键值对迁移(migrate)至目标节点。 - redis-trib 对源节点发送
CLUSTER GETKEYSINSLOT <slot> <count>
命令,获得最多 count 个属于槽 slot 的键值对的键名(key name)。 - 对于步骤 3 获取的每个键名,redis-trip 都向源节点发送一个
MIGATE <target_ip> <target_port> <key_name> 0 <timeout>
m命令,将被选中的键原子地从源节点迁移至目标节点。 - 重复执行步骤 3 和步骤 4,直到源节点保存的所有属于槽 slot 的键值对都被迁移至目标节点为止。
- redis-trib 向集群中的任意一个节点发送
CLUSTER SETSLOT <slot> NODE <target_id>
命令,将槽 slot 指派给目标节点,这一指派信息会通过信息发送至整个集群,最终集群中的所有节点都会直到槽 slot 已经指派给了目标节点。
ASK 错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现一种情况:
属于被迁移槽的一部分键值对保存在源节点里面,而另一个部分键值对则保存在目标节点中。
当客户端向源节点发送与数据库键值有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:
- 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
- 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么整个键可能已经被迁移到了目标节点,源节点将向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。
CLUSTER SETSLOT IMPORTING 命令的实现
clusterState 结构的 importing_slots_from 数组记录了当前节点正在从其他节点导入的槽:
clusterNode *importing_slots_from[CLUSTER_SLOTS];
如果 importing_slots_from[i] 的值不为 NULL,而是指向一个 clusterNode 结构,那么表示当前节点正在从 clusterNode 所代表的节点导入槽 i。
在对集群进行重新分片的时候,向目标节点发送命令:
CLUSTER SETSLOT <i> IMPORTING <source_id>
可以将目标节点 clusterState.importing_slots_from[i] 的值设置为 source_id 所代表节点的 clusterNode 结构。
CLUSTER SETSLOT MIGRATING 命令的实现
clusterState 结构的 migrating_slots_to 数组记录了当前节点正在迁移至 其他节点的槽:
clusterNode *migrating_slots_to[CLUSTER_SLOTS];
如果 migrating_slots_to[i] 的值不为 NULL,而是指向一个 clusterNode 结构,那么表示当前节点正在将槽 i 迁移至 clusterNode 所代表的节点。
在对集群进行重新分片的时候,向源节点发送命令:
CLUSTER SETSLOT <i> MIGRATING <target_id>
可以将源节点 clusterState.migrating_slots_to[i] 的值设置为 target_id所代表节点的 clusterNode 结构。
ASK 错误
如果节点收到一个关于键 key 的命令请求,并且键 key 所属的槽 i 正好就指派给了这个节点,那么节点会尝试在自己的数据库离查找键 key,如果找到了的话,节点就直接执行客户端发送的命令。
于此相反,如果节点没有在自己的数据库里找到键 key,那么节点会检查自己的 clusterState.migrating_slots_to[i],看键 key 所属的槽 i 是否正在进行迁移,如果槽 i 的确在进行迁移的话,那么节点向客户端发送一个 ASK 错误,引导客户端到正在导入槽 i 的节点去查找键 key。
接到 ASK 错误的客户端会根据错误提供的 IP 地址和端口号,转向至正在导入槽的目标节点,然后首先向目标节点发送一个 ASKING 命令,之后再重新发送原本像要执行的命令。
ASKING 命令
ASKING 命令唯一要做的就是打开发送该命令的客户端的 REDIS_ASKING 标识。
#define CLIENT_ASKING (1<<9) /* Client issued the ASKING command */
在一般情况下,如果客户端向节点发送一个关于槽 i 的命令,而槽 i 又没有指派给这个节点的话,那么节点将向客户端返回一个 MOVED 错误;但是,如果节点的 clusterState.importing_slots_from[i] 显示节点正在导入槽 i,并且发送命令的客户端带有 REDIS_ASKING 标识,那么节点将破例执行这个关于槽 i 的命令一次。
当客户端接收到 ASK 错误并转向至正在导入槽的节点时,客户端会先向节点发送一个 ASKING 命令,然后才重新发送想要执行的命令,这是因为如果客户端不发送 ASKING 命令,而直接发送想要执行的命令的话,那么客户端发送的命令将被节点拒绝执行,并且返回 MOVED 错误。
另外要主要的是,客户端的 REDIS_ASKING 标识是一个一次性标识,当节点执行了一个带有 REDIS_ASKING 标识的客户端发送的命令之后,客户端的 REDIS_ASKING 标识就会被移除。
ASK 错误和 MOVED 错误的区别
ASK 错误和 MOVED 错误都会导致客户端转向,它们的区别在于:
- MOVED 错误代表槽的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽 i 的 MOVED 错误之后,客户端每次遇到关于槽 i 的命令时,都可以直接将命令请求发送至 MOVED 错误所指向的节点,因为该节点就是目前负责槽 i 的节点。
- 于此相反,ASK 错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽 i 的 ASK 错误之后,客户端只会在接下来的一次命令请求中将关于槽 i 的命令请求发送至 ASK 错误所指示的节点,但这种转向不会对客户端今后发送关于槽 i 的命令请求产生任何影响,客户端仍然会将关于槽 i 的命令请求发送至目前负责处理槽 i 的节点,除非 ASK 错误再次出现。
复制与故障转移
Redis 集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
设置从节点
向一个节点发送命令:
CLUSTER REPLICATE <node_id>
可以让接收命令的节点成为 node_id 所指定节点的从节点,并开始对主节点开始复制:
- 接收到该命令的节点首先会在自己的 clusterState.nodes 字典中找到 node_id 所对应的节点的 clusterNode 结构,并将自己的 clusterState.myself.slaveof 指针指向这个结构,一次来记录这个节点正在复制的节点:
// 如果这是一个从节点,那么指向主节点
struct clusterNode *slaveof; /* pointer to the master node. Note that it
may be NULL even if the node is a slave
if we don't have the master node in our
tables. */
- 然后,节点会修改自己在 clusterState.myself.flags 中的属性,关闭原本的 REDIS_NODE_MASTER 标识,打开 REDIS_NODE_SLAVE 标识,标识这个节点已经由原来的主节点变成了从节点。
- 最后,节点会调用复制代码,并根据 clusterState.myself.slaveof 指向的 clusterNode 结构所保存的 IP 地址和端口号,对主节点进行复制。
因为节点的复制功能和单机 Redis 服务器的复制功能使用了相同的代码,所以让从节点复制主节点相当于向从节点发送命令 SLAVEOF <master_ip> <master_port>
。
一个节点成为从节点,并开始复制某个主节点这个消息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点。
集群中的所有节点都会在代表主节点的 clusterNode 结构的 slaves 属性和 numslaves 属性中记录正在复制这个主节点的从节点名单:
// 正在复制这个主节点的从节点数量
int numslaves; /* Number of slave nodes, if this is a master */
// 一个数组
// 每个数组项指向一个正在复制这个主节点的从节点的 clusterNode 结构
struct clusterNode **slaves; /* pointers to slave nodes */
故障检测
集群中的每个节点都会定期的向集群中的其他节点发送 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 链表里面:
// 一个链表,记录了所有其他节点对该节点的下线报告
list *fail_reports; /* List of nodes signaling this as failing */
每个下线报告由一个 clusterNodeFailReport 结构表示:
/* This structure represent elements of node->fail_reports. */
typedef struct clusterNodeFailReport {
// 报告目标节点已经下线
struct clusterNode *node; /* Node reporting the failure condition. */
// 最后一次从node节点收到下线报告的时间
// 程序使用这个时间戳来检查下线报告是否过期
// (与当前时间相差太大的下线报告会被删除)
mstime_t time; /* Time of the last report from this node. */
} clusterNodeFailReport;
如果在一个集群里面,半数以上复制处理槽的主节点都将某个主节点 x 报告为疑似下线,那么这个主节点 x 将被标记为已下线(FAIL),将主节点 x 标记为已下线的节点会向集群广播一条关于主节点 x 的 FAIL 消息,所有收到这条 FAIL 消息的节点都会立即将主节点 x 标记为已下线。
故障转移
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:
- 复制下线主节点的所有从节点里面,会由一个从节点被选中。
- 被选中的从节点会执行 SLAVEOF no one 命令,成为新的主节点。
- 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
- 新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线系欸但负责处理的槽。
- 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
选举新的主节点
新的主节点是通过选举产生的。
以下是集群选举新的主节点的方法:
- 集群的配置纪元是一个自增计数器,它的初始值为 0。
- 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被赠一。
- 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
- 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。
- 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_ANTH_ACK 消息,表示这个主节点支持从节点成为新的主节点。
- 每个参与选举的从节点都会接收 CLUSTERMSG_TYPE_FAILOVER_ANTH_ACK 消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。
- 如果集群里有 N 个具有投票权的主节点,那么当一个从节点收集到大于等于 N/2+1 张支持票时,这个从节点就会当选为新的主节点。
- 因为在每个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有 N 个主节点进行投票,那么具有大于等于 N/2+1 张支持票的从节点只有一个,这确保了新的主节点只会有一个。
- 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。
这个选举新主节点的方法和选举领头 Sentinel 的方法非常相似,因为两者都是基于 Raft 算法的领头选举(leader election)方法来实现的。
消息
集群中的各个节点通过发送和接收消息(message)来进行通信,我们称发送消息的节点为发送者(sender),接收消息的节点为接收者(receiver)。
节点发送的消息主要有以下五种:
- MEET 消息:当发送者接到客户端发送的 CLUSTER MEET 命令时,发送者会向接收者发送 MEET 消息,请求接收者加入到发送者当前所处的集群里面。
- PING 消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过 PING 消息的节点发送 PING 消息,以此来检查被选中的节点是否在线。除此之外,如果节点 A 最后一次收到节点 B 发送的 PONG 消息的时间,距离当前时间已经超过了节点 A 的 cluster-node-timeout 选项设置时长的一半,那么节点 A 也会向节点 B 发送 PING 消息,这可以防止节点 A 因为长时间没有随机选中节点 B 作为 PING 消息的发送对象而导致节点 B 的消息更新滞后。
- PONG 消息:当接收者收到发送者发来的 MEET 消息或者 PING 消息时,为了向发送者确认这条 MEET 消息或者 PING 消息已到达,接收者会向发送者返回一条 PONG 消息。另外,一个节点也可以通过向集群广播自己的 PONG 消息来让集群中的其他节点立即刷新关于这个节点的认识。
- FAIL 消息:当一个主节点 A 判断另一个主节点 B 已经进入 FAIL 状态时,节点 A 会向集群广播一条关于节点 B 的 FAIL 消息,所有收到这条消息的节点都会立即将节点 B 标记为已下线。
- PUBLISH 消息:当节点接收到一个 PUBLISH 命令时,节点执行这个命令,并向集群广播一条 PUBLISH 消息,所有接收到这条 PUBLISH 消息的节点都会执行相同的 PUBLISH 命令。
/* Note that the PING, PONG and MEET messages are actually the same exact
* kind of packet. PONG is the reply to ping, in the exact format as a PING,
* while MEET is a special PING that forces the receiver to add the sender
* as a node (if it is not already in the list). */
#define CLUSTERMSG_TYPE_PING 0 /* Ping */
#define CLUSTERMSG_TYPE_PONG 1 /* Pong (reply to Ping) */
#define CLUSTERMSG_TYPE_MEET 2 /* Meet "let's join" message */
#define CLUSTERMSG_TYPE_FAIL 3 /* Mark node xxx as failing */
#define CLUSTERMSG_TYPE_PUBLISH 4 /* Pub/Sub Publish propagation */
#define CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 5 /* May I failover? */
#define CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 6 /* Yes, you have my vote */
#define CLUSTERMSG_TYPE_UPDATE 7 /* Another node slots configuration */
#define CLUSTERMSG_TYPE_MFSTART 8 /* Pause clients for manual failover */
一条消息由消息头(header)和消息正文(data)组成。
消息头
节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送自身的一些消息,因为这些消息也会被消息接收者用到,所以严格来讲,我们可以认为消息头本身也是消息的一部分。
每个消息头都由一个 cluster.h/clusterMsg 结构表示:
typedef struct {
char sig[4]; /* Siganture "RCmb" (Redis Cluster message bus). */
// 消息的长度(包括这个消息头的长度和消息正文的长度)
uint32_t totlen; /* Total length of this message */
uint16_t ver; /* Protocol version, currently set to 0. */
uint16_t notused0; /* 2 bytes not used. */
// 消息的类型
uint16_t type; /* Message type */
// 消息正文包含的节点信息数量
// 只在发送 MEET、PING、PONG 这三种 Gossip 协议消息时使用
uint16_t count; /* Only used for some kind of messages. */
// 发送者所处的配置纪元
uint64_t currentEpoch; /* The epoch accordingly to the sending node. */
// 如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元
uint64_t configEpoch; /* The config epoch if it's a master, or the last
epoch advertised by its master if it is a
slave. */
uint64_t offset; /* Master replication offset if node is a master or
processed replication offset if node is a slave. */
// 发送者的名字(ID)
char sender[CLUSTER_NAMELEN]; /* Name of the sender node */
// 发送者目前的槽指派信息
unsigned char myslots[CLUSTER_SLOTS/8];
// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
// 如果发送者是一个主节点,那么这里记录的是 REDIS_NODE_NULL_NAME
// (一个 40 字节长,值全为 0 的字节数组)
char slaveof[CLUSTER_NAMELEN];
char notused1[32]; /* 32 bytes reserved for future usage. */
// 发送者的端口号
uint16_t port; /* Sender TCP base port */
// 发送者的标识值
uint16_t flags; /* Sender node flags */
// 发送者所处集群的状态
unsigned char state; /* Cluster state from the POV of the sender */
unsigned char mflags[3]; /* Message flags: CLUSTERMSG_FLAG[012]_... */
// 消息的正文(或者说,内容)
union clusterMsgData data;
} clusterMsg;
clusterMsg.data 属性指向联合体 cluster.h/clusterMsgData,这个联合体就是消息的正文:
union clusterMsgData {
/* PING, MEET and PONG 消息的正文*/
struct {
/* Array of N clusterMsgDataGossip structures */
// 每条 PING, MEET and PONG消息都包含两个
// clusterMsgDataGossip 结构
clusterMsgDataGossip gossip[1];
} ping;
/* FAIL 消息的正文*/
struct {
clusterMsgDataFail about;
} fail;
/* PUBLISH 消息的正文*/
struct {
clusterMsgDataPublish msg;
} publish;
/* UPDATE */
struct {
clusterMsgDataUpdate nodecfg;
} update;
};
clusterMsg 结构记录了发送者自身的节点信息,接收者会根据这些信息,在自己的 clusterState.nodes 字典里找到发送者对应的 clusterNode 结构,并对结构进行更新。
MEET、PING、PONG 消息的实现
Redis 集群中的各个节点通过 Gossip 协议来交换各自关于不同节点的状态信息,其中 Gossip 协议由 MEET、PING、PONG 三种消息实现,这三种消息的正文都由两个 cluster.h/clusterMsgDataGossip 结构组成:
union clusterMsgData {
/* PING, MEET and PONG 消息的正文*/
struct {
/* Array of N clusterMsgDataGossip structures */
// 每条 PING, MEET and PONG消息都包含两个
// clusterMsgDataGossip 结构
clusterMsgDataGossip gossip[1];
} ping;
// ...
};
因为 MEET、PING、PONG 三种消息都使用相同的消息正文,所以节点通过消息头的 type 属性来判断一个消息是 MEET 消息、PING 消息还是 PONG 消息。
每次发送 MEET、PING、PONG 消息时,发送者都从自己的已知节点列表中随机选出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个 clusterMsgDataGossip 结构里面。
clusterMsgDataGossip 结构:
/* Initially we don't know our "name", but we'll find it once we connect
* to the first node, using the getsockname() function. Then we'll use this
* address for all the next messages. */
typedef struct {
// 节点的名字
char nodename[CLUSTER_NAMELEN];
// 最后一次向该节点发送 PING 消息的时间戳
uint32_t ping_sent;
// 最后一次从该节点接收到 PONG 消息的时间戳
uint32_t pong_received;
// 节点的 IP 地址
char ip[NET_IP_STR_LEN]; /* IP address last time it was seen */
// 节点的端口号
uint16_t port; /* port last time it was seen */
// 节点的标识值
uint16_t flags; /* node->flags copy */
uint16_t notused1; /* Some room for future improvements. */
uint32_t notused2;
} clusterMsgDataGossip;
当接收者收到 MEET、PING、PONG 消息时,接收者会访问消息正文中的两个 clusterMsgDataGossip 结构,并根据自己是否已知 clusterMsgDataGossip 结构中记录的被选中节点来选择进行哪种操作:
- 如果不存在,与节点进行握手。
- 如果存在,根据对应的 clusterNode 结构。
FAIL 消息的实现
当集群里的主节点 A 将主节点 B 标记为已下线(FAIL)时,主节点 A 将向集群广播一条关于主节点 B 的 FAIL 消息,所有接收到这条 FAIL 消息的节点都会将主节点 B 标记为已下线。
在集群的节点数量比较大的情况下,单纯使用 Gossip 协议来传播节点的已下线消息会给节点的消息更新带来一定延迟,因为 Gossip 协议消息通常需要一段时间才能传播至整个集群,而发送 FAIL 消息可以让集群里的所有节点立即知道某个节点已下线,从而尽快判断是否需要将集群标记为下线,又或者对下线主节点进行故障转移。
FAIL 消息的正文由 cluster.h/clusterMsgDataFail 结构表示,这个结构只包含一个 nodename 属性,该属性记录了已下线节点的名字:
typedef struct {
char nodename[CLUSTER_NAMELEN];
} clusterMsgDataFail;
因为集群里面的所有节点都是一个独一无二的名字,所以 FAIL 消息里面只需要保存下来节点的名字,接收到消息的节点就可以根据这个名字来判断是哪个节点下线了。
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;
/* We can't reclare bulk_data as bulk_data[] since this structure is
* nested. The 8 bytes are removed from the count during the message
* length computation. */
// 定义为 8 字节只是为了对齐其他消息结构
// 实际的长度由保存的内容决定
unsigned char bulk_data[8];
} clusterMsgDataPublish;
clusterMsgDataPublish 结构的 bulk_data 属性是一个字节数组,这个字节数组保存了客户端通过 PUBLISH 命令发送给节点的 channel 参数和 message 参数,而结构的 channel_len 和 message_len 则分别保存了 channel 参数的长度和 message 参数的长度:
- 其中 bulk_data 的 0 字节至 channel_len-1 字节保存的是 channel 参数。
- 而 bulk_data 的 channel_len 字节至 channel_len+message_len-1字节保存的则是 message 参数。