redis主从、sentinel、集群原理

young 544 2022-01-04

整理于《Redis设计与实现》一书

主从复制

在redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)另一个服务器,我们称被复制的服务器为主服务器(master),对主服务器进行复制的服务器为从服务器(slave)。

假设现在有两个redis服务器,地址为别为127.0.0.1:6379和127.0.0.1:12345,向服务器127.0.0.1:12345发送命令127.0.0.1:123456> SLAVEOF 127.0.0.1 6379,那么服务器127.0.0.1:12345将成为127.0.0.1:6379的从服务器。

进行复制中的主从服务器双方的数据库将保存相同的状态,概念上将这种现象称为“数据库状态一致“,或者简称为一致。

旧版复制功能的实现

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

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

同步

当客户端向服务器发送SLAVEOF命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作

从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成。

SYNC命令的执行步骤:

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

命令传播

在同步操作执行完毕之后,主从服务器两个的数据库状态将达到一致,但是这种一致并不是一成不变的,每当主服务器执行客户端发送的写命令时,主服务器的数据库就可能被修改,从而导致主从服务数据库状态不一致。

为了让主从服务器再次回到一致状态,主服务器需要对从服务器执行命令传播操作:主服务器会将自己执行的写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态

旧版复制功能的缺陷

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

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

对于初次复制来说,旧版的复制功能可以很好的完成任务,但是对于断线重连后的复制来说,旧版的功能就显得效率十分低下了。

SYNC命令是一个非常耗费资源的操作

每次执行SYNC命令,主从服务器需要执行一下操作:

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

新版复制功能的实现

为了解决旧版复制功能在处理断线重连情况下复制低效的问题,redis从2.8版本开始,使用PSYNC命令代替SYNC命令来执行复制时的同步操作。

PSYNC命令具有完整重同步(full resyncchronization)和部分重同步(partial resyncchronization)两种模式

  • 完整重同步用于处理初次复制的情况,完整重同步的步骤和SYNC命令的执行步骤基本一样
  • 部分重同步用于处于断线重连的情况,当从服务器在断线重连之后,如果条件允许,主服务器可以将从服务器连接断开期间执行的写操作发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处状态
    psync.png

部分重同步的实现

部分重同步功能由以下三个部分构成:

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

复制偏移量

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

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

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

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

假设A此时重新连接上主服务,并向主服务器发送PSYNC命令,报告从服务器A当前的复制偏移量是10086,此时应该执行完整重同步还是部分重同步?执行部分重同步的话,主服务器又应该如何不常从服务器在断线期间丢失的数据呢?这些问题的答案都和复制积压缓冲区有关。

复制积压缓冲区

复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小是1MB

固定长度的先进先出队列

固定长度先进先出队列的入队和出队规则和普通的先进先出队列一样,新元素从一边进入队列, 旧元素从另一边弹出队列。

和普通先进先出队列随着元素的增加和减少而动态调整长度不同,固定长度先进先出队列的长度是固定的,当入队元素的数量大于队列长度时,最先入队的元素会被弹出,而新元素会被放入队列。

例如将'h'、'e'、'l'、'l'、'o'这五个字符放进一个长度为3的固定长度队列中,那么h、e、l这三个字符先被放入队列['h','e','l'],当后一个'l'字符要进入队列时,队首的'h'字符将被弹出,队列变化['e','l','l'],接着'o'的入队会引起'e'的出队,队列变成['l','l','o']。

当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里面。
replicationbacklog.png

因此,主服务器的复制积压缓冲区里面会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量。
replicationbacklogbuf.png

当从服务器重新连上主服务器时,从服务器会通过PSYNC命令将自己的复制偏移量offset发送给主服务器,主服务器会根据这个复制偏移量来决定对服务器执行何种同步操作:

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

继续前面的例子

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

根据需要调整复制积压缓冲区的大小

Redis为复制积压缓冲区设置的默认大小为1MB,如果主服务器需要执行大量写命令,又或者主从服务器断线后重连所需的时间比较长,那么这个大小也许并不合适。如果复制积压缓冲区的大小设置得不恰当,那么PSYNC命令的复制重同步就不能正常发挥作用,因此,正确估算和设置复制积压缓冲区的大小非常重要。

复制积压缓冲区的最小大小可以根据公式second*write_size_per_second来计算

  • second为从服务器断线后重新连接上主服务器所需要的平均时间(以秒计算)
  • write_size_per_second则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)

为了安全起见,可以将复制积压缓冲区的大小设为2*second*write_size_per_second,这样可以保证绝大部分断线情况都能用部分重同步来处理。

至于复制积压缓冲区大小的修改方法,可以参考配置文件中的关于repl_backlog-size选项的配置。

服务器运行ID

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

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

每当服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器则会将这个运行ID保存起来。

当从服务器断线并重连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID:

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

PSYNC命令的实现

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

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

根据情况,接收到PSYNC命令的主服务器会向从服务器返回一下三种情况之一:

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

复制的实现

步骤1:设置主服务器的地址和端口

当客户端向从服务器发送127.0.0.1:12345> SLAVEOF 127.0.0.1 6379命令时,从服务器首先要做的是将客户端给定的主服务器IP地址127.0.0.1以及端口6379保存到服务器状态的masterhost属性里

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

masterhost.png

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

步骤2:建立套接字连接

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

如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将成为这个套接字关联一个专门用于处理复制工作的文件处理器,这个福利期将负责执行后续的复制工作,比如接收RDB文件,以及接收主服务器传播来的命令等。

主服务器在接受(accpet)从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,并将从服务器看做是一个连接到主服务器的客户端来对待,这时从服务器将同时具有服务器(server)和客户端(client)两个身份,从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回命令回复。

因为复制工作接下来的几个步骤都会以从服务器向主服务器发送命令请求的形式来进行,所以理解“从服务器是主服务器的客户端”这一点非常重要。
masterslavesocket.png

步骤3:发送PING命令

从服务器成为主服务器的客户端之后,做的第一件事就是向主服务器发送一个PING命令。

PING命令有两个左右

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

从服务器发送PING命令之后会遇到以下三种情况之一:

  • 如果主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定时限(timeout)内读取出命令回复内容,那么表示主从服务器之间的网络连接不佳,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
  • 如果主服务器向从服务器返回一个错误,那么表示主服务器暂时没办法处理从服务器的命令请求,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。例如,主服务器正在处理一个超时运行的脚本,那么当从服务器向主服务器发送PING命令时,从服务器将接受到主服务器返回的BUSY Redis is busy running a script.You can only call SCRIPT KILL or SHUTDOWN NOSAVE错误。
  • 如果从服务器读取到"PONG"回复,那么表示主从服务器之间的网络连接状态正常,并且主服务器可以正常处理从服务器(客户端)发送的命令请求,在这种情况下,从服务可以继续执行复制工作的下个步骤。
    masterslaveping.png

步骤4:身份验证

从服务器在接收到主服务器返回的PONG回复之后,下一步要做的就是决定是否进行身份验证

  • 如果从服务器设置的masterauth选项,那么进行身份验证
  • 如果从服务器没有设置masterauth选项,那么不进行身份验证

需要进行身份验证的情况下,从服务器将向主服务发送一条AUTH命令,铭刻的参数为从服务器masterauth选项的值

从服务器器验证身份阶段可能遇到的情况:

  • 如果主服务器没有设置requirepass选项,并且从服务器也没有设置masterauth选项,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行
  • 如果从服务器通过AUTH命令发送的密码与主服务器requirepass设置的密码相同,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。否则,主服务器将返回一个invalid password错误
  • 如果主服务器设置了requirepass选项,但从服务器没有设置masterauth选项,那么主服务器将返回一个NOAUTH错误。如果主服务器没有设置requirepass选项,但是从服务器设置了masteauth选项,那么主服务器会返回一个no password is set错误

所有错误情况都会令从服务器中止当前的复制工作,并从创建套接字开始重新执行复制,直到身份验证通过或者从服务器放弃执行复制为止。

步骤5:发送端口消息

在身份验证之后,从服务器将执行命令REPLCONF listening-port <port-number> ,向主服务器发送从服务器的监听端口号

主服务器在接收到这个命令后,会将端口号记录在从服务器对应的客户状态的slave_listening_port属性中

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

slave_listening_port属性目前唯一的作用就是在主服务器执行INFO replicaiton命令时打印出从服务器的端口号

步骤6:同步

在这一步,从服务器将向主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态。

值得一提的是,在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会成为从服务器的客户端:

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

因此,在同步操作执行之后,主从服务器双方都是对方的客户端,他们可以互相向对方发送命令请求,或者互相向对象返回命令回复

正因为主服务器成为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,不仅同步操作需要用到这一点,这也是主服务对从从服务器执行命令传播操作的基础

步骤7:命令传播

完成同步之后,主从服务器就会进去命令传播阶段,这时,主服务器只要一直将自己执行的写命令发送给从服务器,从服务器只要一直接收并执行主服务器发来的命令,就可以保证主从服务器一直保持一致

心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令REPLCONF ACK <replicaiton_offset>,其中replication_offset是从服务器当前的复制偏移量。

发送REPLCONF ACK命令对于主服务器有三个作用:

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

检测主从服务器的网络连接状态

主从服务器可以通过发送和接收REPLCONF ACK命令来检查两者之间的网络是否连接正常,如果主服务器超过一秒钟没有收到从服务器发来的REPLCONF ACK命令,那么主服务器就知道主从服务器之间的连接出现问题了。

通过向主服务器发送 INFO replication 命令,在列出的从服务器列表的lag一栏中,可以看到相应的服务器最后一次向主服务器发送REPLCONF ACK命令距离现在过了多少秒

一般情况下,lag的值应该在0秒或者1秒之间跳动,如果超过1秒的话,说明主从服务器之间的连接出现了故障

辅助实现min-slaves配置选项

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

例如

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

那么在从服务器的数量少于3个,或者3个从服务器的延迟(lag)值都大于或等于10秒时,主服务器将拒绝执行写命令,这里的延迟值就是上面INFO replication命令中的lag值

检测命令丢失

如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压化重启里面找到从服务器缺少的数据,并将这些数据重新发送给服务器。

例如,现在有两个处于一致状态的主从服务器,它们的复制偏移量都是200.

如果这时主服务器执行了一个SET key value,将自己的复制偏移量更新到了233,并尝试向从服务器传播命令SET key value,但这条命令却因为网络问题在传播途中丢失了,那么主从服务器之间的复制偏移量就出现了不一致,主服务器的复制偏移量为233,而从服务器的复制偏移量依旧是200。

在这之后,当从服务器发送REPLCONF ACK命令时(REPLCONF ACK 200),主服务器会觉察从服务器的复制偏移量依然为200,而自己的复制偏移量为233,说明复制积压缓冲区里面的复制偏移量201至233的数据在传播过程中丢失了,于是主服务器会再次向从服务器传播命令SET key value,从服务器通过接收兵执行这个命令可以将自己更新至主服务器当前所处状态。

主服务器向从服务器补发缺失数据这个操作的原理和部分重同步操作的原理非常相似,这两个操作的区别在于,补发缺失数据操作在主从服务器没有断线的情况下执行,部分重同步操作则在主从服务器断线并重连之后执行

2.8版本之前的Redis没有REPLCONF ACK命令,命令传播丢失,主从服务器都不会发现,无法确保主从服务器一致。

Sentinel哨兵

Sentinel是redis的高可用解决方案,由一个或者多个Sentinel实例组成的Sentinel系统,可以监视任意多个主服务器,以及这些主服务器下属的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器下属的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
sentinel.png

  • 图中双环表示的是当前的主服务器server1
  • 图中单环表示主服务器的三个从服务器server2、server3、server4
  • server2、server3、server4三个从服务器正在复制主服务器server1,sentinel系统则在监视所有四个服务器

假设此时,主服务器server1进入下线状态,那么从服务器server2、server3、server4对主服务器的复制操作将被中止,并且sentinel系统会察觉到server1已下线
sentinel1.png

当server1的下线时长超过用户设定的下线时长上限时,sentinel系统就会对server1执行故障转移操作:

  1. sentinel系统会挑选出server1属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器
  2. sentinel系统会向server1下属的所有从服务器发送新的复制指令,让他们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作执行完毕
  3. sentinel还会继续监控已下线的server1,并在他重新上线时,将它设置为新的主服务器的从服务器
    sentinel2.png

启动并初始化sentinel

启动sentinel可以使用命令

redis-sentinel ${redis-conf-home}/sentinel.conf

或者

redis-server ${redis-conf-home}/sentinel.conf --sentinel

这两个命令的效果完全一样。

当一个sentinel启动时,他需要执行一下步骤:

  1. 初始化服务器
  2. 将普通redis服务器使用的代码替换成sentinel专用代码
  3. 初始化sentinel状态
  4. 根据给定的配置文件,初始化sentinel的监视主服务器列表
  5. 创建连向主服务器的网络连接

初始化服务器

sentinel本质上只是一个运行在特殊模式下的redis服务器,所以启动sentinel的第一步就是初始化一个普通的redis服务器。

因为sentinel执行的工作和普通的redis服务器执行的工作不同,所以sentinel的初始化过程和普通redis服务器的初始化过程并不完全相同。

例如,普通服务器在初始化时会通过载入RDB文件或者AOF文件来还原数据库状态,但是sentinel并不需要使用数据库,所以sentinel初始化的时候不会载入RDB文件或者AOF文件。

使用sentinel专用代码

启动sentinel的第二个步骤就是将一部分普通redis服务器的代码替换成sentinel专用代码,比如,普通redis服务器使用redis.h/REDIS_SERVERPORT常量值作为服务器端口

#define REDIS_SERVERPORT 6379

而sentinel则使用sentinel.c/REDIS_SENTINEL_PORT常量值作为服务器端口

#define REDIS_SENTINEL_PORT 26379

此外,普通redis服务器使用redis.c/redisCommandTable作为服务器的命令表

struct redisCommand redisCommandTable[] = {    
    {"get",getCommand,2,"r",0,NULL,1,1,1,0,0},
    {"set",setCommand,-3,"wm",0,noPreloadGetKeys,1,1,1,0,0},
    {"setnx",setnxCommand,3,"wm",0,noPreloadGetKeys,1,1,1,0,0},
    // ...
    {"script",scriptCommand,-2,"ras",0,NULL,0,0,0,0,0},
    {"time",timeCommand,1,"rR",0,NULL,0,0,0,0,0},
    {"bitop",bitopCommand,-4,"wm",0,NULL,2,-1,1,0,0},
    {"bitcount",bitcountCommand,-2,"r",0,NULL,1,1,1,0,0}
}

而sentinel则使用sentinel.c、sentinelcmds作为服务器的指令表,并且其中的INFO命令会使用sentinel模式下的专用实现sentinel.c/sentinelInfoCommand函数,而不是普通redis服务器使用的实现redis.c/infoCommand函数

struct redisCommand sentinelcmds[] = {
    {"ping",pingCommand,1,"",0,NULL,0,0,0,0,0},
    {"sentinel",sentinelCommand,-2,"",0,NULL,0,0,0,0,0},
    {"subscribe",subscribeCommand,-2,"",0,NULL,0,0,0,0,0},
    {"unsubscribe",unsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
    {"psubscribe",psubscribeCommand,-2,"",0,NULL,0,0,0,0,0},
    {"punsubscribe",punsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
    {"info",sentinelInfoCommand,-1,"",0,NULL,0,0,0,0,0}
};

sentinelcmds命令表也解释为了为什么在sentinel模式下,redis服务器不能执行如SET、DBSIZE、EVAL等命令,因为服务器分布没有在命令表中载入这些命令。

PING、SENTINEL、INFO、SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE和PUNSUBSCRIBE这7个命令就是客户端可以对sentinel执行的全部命令了。

初始化sentinel状态

在应用了sentinel的专用代码之后,服务器会初始化一个sentinel.c/sentinelState结构(简称sentinel状态),这个结构保存了服务器中所有和sentinel功能相关的状态(服务器的一般状态仍然由redis.h/redisServer结构保存)

struct snetinelState{
    // 当前纪元,用于实现故障转移
    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 *script_queue;
} sentinel;

初始化sentinel状态的master属性

sentinel状态中的masters字典,记录了所有被sentinel监控的主服务器的相关信息,其中

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

每个sentinelRedisInstance结构(简称实例结构)代表一个被sentinel监视的redis服务器实例,这个实例可以是主服务器、从服务器,或者另一个sentinel。

实例结构包含的属性非常多

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

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

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

对sentinel状态的初始化将引发对masters字典的初始化,而masters字典的初始化是根据被载入的sentinel配置来进行的。

例如启动包含一下配置内容的sentinel

# master1 configure
sentinel monitor master1 127.0.0.1 6379 2
sentinel down-after-milliseconds master1 30000
sentinel parallel-syncs master1 1
sentinel faliover-timeout master1 900000

# master2 configure
sentinel monitor master2 127.0.0.1 12345 5
sentinel down-after-milliseconds master2 50000
sentinel parallel-syncs master2 5
sentinel faliover-timeout master2 40000

sentinelStatemasters.png

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

初始化sentinel的最后一步是将创建连向被监视主服务器的网络连接,sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关信息。

对于每个被sentinel监视的主服务器来说,sentinel会创建两个连向主服务器的异步网络连接:

  • 一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复
  • 另一个连接时订阅连接,这个连接专门用于订阅服务器的_sentinel_:hello频道

为什么有两个连接

在redis目前的发布与订阅功能中,被发送的信息都不会保存在redis服务器里面,如果在发送消息时,想要接受的客户端不在线或者断线,那么这个客户端就会丢失这条消息。因此,为了不丢失_sentinel_:hello频道的任何消息,sentinel必须专门用一个订阅连接来接受该频道的信息。

除了订阅频道之外,sentinel还必须向主服务器发送命令,以此来与主服务器进行通信,所以sentinel还必须向主服务器创建命令连接。

因为sentinel需要与多个实例创建多个网络连接,所以sentinel使用的是异步连接。

获取主服务器信息

sentinel默认会以每10秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过解析INFO命令的回复来获取主服务器当前的信息。

# Server
...
run_id:7611c59dc3a29aa6fa0609f841bb6a1019008a9c
...
# Replication
role:master
...
slave0:ip=127.0.0.1,port=11111,state=online,offset=43,lag=0
slave1:ip=127.0.0.1,port=22222,state=online,offset=43,lag=0
slave2:ip=127.0.0.1,port=33333,state=online,offset=43,lag=0
...
# Other sections
...

通过分析主服务器返回的INFO命令回复,sentinel可以获取以下两方面的信息:

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

根据run_id和role域记录的信息,sentinel将对主服务器的实例结构进行更新,例如主服务器重启之后,他的运行ID就会和实例结构之前保存的运行ID不同,sentinel检测到之后就会对实例结构的运行ID进行更新。

主服务器返回的从服务器信息,则会被用于更新主服务器实例结构的slaves字典,这个字典记录了主服务器属下从服务器的名单

  • 字典的键是有sentinel自动设置的从服务器名称,格式为ip:port
  • 字典的值则是从服务器对应的实例结构,比如,键时127.0.0.1:11111,那么这个键的值就是ip店址为127.0.0.1,端口号为11111的从服务器的实例结构

sentinel在分析INFO命令中包含的从服务器信息时,会检查从服务器对应的实例结构是否已经存在于slaves字典

  • 如果从服务器的对应的实例结构已经存在,那么sentinel对从服务器的实例结构进行更新
  • 如果从服务器对应的实例结构不存在,那么说明这个从服务器是新发现的从服务器,sentinel会在slaves字典中为这个从服务器新创建一个实例结构

主服务器实例结构和从服务器实例结构的区别

  • 主服务器实例结构的flags属性值为SRI_MASTER,从服务器的为SRL_SLAVE
  • 主服务器实例结构的name属性的值为用户使用sentinel配置文件设置的,从服务器的为sentinel根据服务器的IP地址和端口号自动设置的

获取从服务器信息

当sentinel发现主服务器有新的从服务器出现时,sentinel除了会为这个新的从服务器创建相应的实例结构之外,sentinel还会创建连接到从服务器的命令连接和订阅连接。

创建命令连接之后,sentinel在默认情况下会以每10秒一次的频率通过命令连接向从服务器发送INFO命令

# Server
...
run_id:32be0699dd27b410f7c90dada3a6fab17f97899f
...
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
slave_repl_offset:11887
slave_priority:100
# Other sections
...

根据INFO命令的回复,sentinel会提取出以下信息

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

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

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

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

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

这条命令向服务器的_sentinel_:hello频道发送了一条信息,信息内容由多个参数组成

  • 以s_开头的参数记录的是sentinel自身的信息
  • 以m_开头的参数记录的是主服务的信息

如果正在监视的是主服务器,那么这些参数记录的就是主服务器的信息,如果sentinel正在监视的是从服务器,那么这些参数记录的就是从服务器正在复制的主服务器信息

参数意义
s_ipsentinel的ip
s_portsentinel的端口号
s_runidsentinel的运行ID
s_epochsentinel当前的配置纪元(Configuration epoch)
m_name主服务器名字
m_ip主服务器ip
m_port主服务器端口号
m_epoch主服务器当前的配置纪元

接收来自主服务器和从服务器的频道信息

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

SUBSCRIBE _sentinel_:hello

sentinel对_sentinel_:hello频道的订阅会一直持续到sentinel与服务器断开连接为止。

也就是说,对于每个sentinel连接的服务器,sentinel既通过命令连接向服务器的_sentinel_:hello频道发送消息,又通过订阅连接从服务器的_sentinel_:hello频道接收消息。

对于监视同一个服务器的多个sentinel来说,一个sentinel发送的信息被其他sentinel接收到,这些信息就会被用于更新其他sentinel对发送信息的sentinel的认真,也会被用于更新其他sentinel对监视服务器的认知。

假设现在有sentinel1、sentinel2、sentinel3三个sentinel在监视同一个服务器,那么当sentinel1向服务器的_sentinel_:hello频道发送一条信息时,所有订阅了_sentinel_:hello频道的sentinel(包括sentinel1自己在内)都会受到这条信息。

当一个sentinel从_sentinel_:hello频道收到一条信息时,sentinel会对这条信息进行分析,提取出信息中的sentinel IP地址、sentinel端口号,sentinel运行ID等8个参数,并进行以下检查

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

更新sentinels字典

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

  • sentinels字典的键时其中一个sentinel的名字,格式为ip:port
  • sentinel字典的值则是键所对应的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创建一个新的实例结构,并将这个结构添加到sentinels字典里,flags为SRI_SENTINEL

因为一个sentinel可以通过分析接收到的频道信息来获知其他sentinel的存在,并通过发送频道信息来让其他sentinel知道自己的存在,所用户在使用sentinel的时候并不需要听歌各个sentinel的地址新,监视同一个主服务器的多个sentinel可以自动发现对方。

创建连向其他sentinel的命令连接

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

使用命令连接相连的各个sentinel可以通过向其他sentinel发送命令请求来进行信息交换,sentinel实现主观下线检测和客观下线检测都会使用sentinel之间的命令连接来进行通信。

sentinel之间不会创建订阅连接

sentinel在连接主服务器或者从服务器时,会同时创建命令连接和订阅连接,但是在连接其他sentinel的时候,只会创建命令连接,而不创建订阅连接。因为sentinel需要通过接收主服务器或者从服务器发来的频道信息来发现未知的新sentinel,所以才需要建立订阅连接,而相互已知的sentinel只需使用命令连接来进行通信就足够了。

检测主观下线状态

默认情况下,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表示,以此来表示这个实例已经进入主观下线状态
sentinelsrlsdown.png

主观下线时长选项的作用范围

用户设置的down-after-milliseconds选项的值,不仅会被sentinel用来判断主服务器的主观下线状态,还会被用于判断主服务器属下的所有从服务器,以及所有同样监视这个主服务器的其他sentinel主观下线状态。

例如用户设置了一下配置

sentinel monitor master 127.0.0.1 6379 2
sentinel down-after-milliseconds master 50000

那么50000毫秒不仅会成为sentinel判断master进入主观下线的标准,还会成为sentinel判断master属下所有从服务器,以及所有同样监视master的其他sentinel进入主观下线的标准。

多个sentinel设置的主观下线时长可能不同

对于监视同一个主服务器的多个sentinel来说,这些sentinel所设置额定down-after-milliseconds选项的值可能不同,因此当一个sentinel将服务判断为主观下线时,其他sentinel可能仍然会任务主服务出去在新状态。

检查客观下线状态

当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_epochsentinel当前的配置纪元,用于选举领头sentinel
runid可以使*符合或者sentinel的运行id,*符号代表命令仅仅用于检测主服务器的客观下线状态,而sentinel的运行ID则用于选举领头sentinel

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

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

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

例如返回

1) 1
2) *
3) 0

那么说明目标sentinel也同意主服务器已下线

接收SENTINEL is-master-down-by-addr命令的回复

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

客观下线状态的判断条件

当认为主服务器已经进入下线状态的sentinel的数量,超过sentinel配置中设置的quorum参数的值,那么该sentinel就会认为主服务已进入乐观下线状态。

比如sentinel启动时载入以下配置

sentinel monitor master 127.0.0.1 6379 2

那么包括当前sentinel在内,只要总共有2个sentinel任务主服务器已进入下线状态,那么当前sentinel就将主服务判断为客观下线

不同sentinel判断客观下线的条件可能不同

对于监视同一个主服务器的多个sentinel来说,他们讲主服务器判断为客观下线的条件可能也不同,当一个sentinel将主服务器判断为客观下线时,其他说呢提可能并不这么认为。

例如,对于监视同一个主服务器的5个sentinel来说,sentinel1载入以下配置

senrinel monitor master 127.0.0.1 6379 2

当5个sentinel中有两个sentinel认为主服务器已下线,sentinel1机会将主服务器判断为客观下线。

对于载入了以下配置的sentinel2来说

sentinel monitor maste 127.0.0.1 6379 5		

仅有两个sentinel认为主服务器已下线,并不会令sentinel2将主服务判断为客观下线。

选举领头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,比如在一个由10个sentinel组成的sentinel系统里,只要有大于等于10/2+1=6个sentinel将某个sentinel设置为领头sentinel,那么被设置的那个sentinel就会成为领头sentinel。
  • 因为领头sentinel的产生需要半数以上的sentinel的支持,并且每个sentinel在每个配置纪元里面只能设置一次局部领头sentinel,所以在一个配置纪元里面,只会出现一个领头sentinel
  • 如果在给定的时限内,没有一个sentinel被选举为领头sentinel,那么各个sentinel将在一段时间之后再次进行选举,直到选出领头sentinel为止

假设现在有三个sentinel正在监视同一个主服务器,并且这三个sentinel之前已经通过SENTINEL is-master-down-by-addr命令确认主服务器进入了客观下线状态
sentinelleader1.png

为了选出领头sentinel,三个sentinel将再次向其他sentinel发送SENTINEL is-master-down-by-addr命令
sentinelleader2.png

和检测客观下线状态时发送的SENTINEL is-master-down-by-addr命令不同,sentinel这次发送的命令会带有sentinel自己的运行ID,如

SENTINEL is-master-down-by-addr 127.0.0.1 6379 0 a123155fbde12132e121f12123d

如果接受到这个命令的sentinel还没有设置局部领头sentinel的话,他就会将这个运行ID为a123155fbde12132e121f12123d的sentinel设置为自己的局部领头sentinel,并返回类似以下的命令回复

1) 1
2) a123155fbde12132e121f12123d
3) 0

然后接收到命令回复的sentinel就可以根据这一回复,统计出有多少个sentinel将自己设置成了局部领头sentinel

根据命令请求发送的先后顺序不同,可能会有某个sentinel的 SENTINEL is-master-down-by-addr命令比起其他sentinel发送的相同命令都更快到达,并最终胜出领头sentinel的选举,然后这个领头sentinel就可以开始对主服务器执行故障转移操作了。

领头sentinel选举基于raft算法实现

故障转移

在选举产生出领头sentinel之后,领头sentinel将对已下线的主服务器

执行故障转移操作,该操作包含以下三个步骤:

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

选出新的主服务器

故障转移操作的第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好,数据完整的从服务器,然后向这个从服务器发送SLAVEOF no one命令,将这个从服务器转换为主服务器

新的主服务器是怎么挑选出来的

领头sentinel会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项的对列表进行过滤

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

之后,领头sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器

如果有多个具有相同最高优先级的从服务器,那么领头sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有服务器进行排序,并选出其中偏移量最大的服务器(复制偏移量最大的服务器就是保存着最新数据的从服务器)。

最后,如果有多个优先级最高,复制偏移量最大的从服务器,那么领头sentinel将按照运行ID对这些从服务器进行排序,并选出其中运行ID最小的服务器。

领头的sentinel会一直想选出的从服务器发送INFO命令,当从服务器返回的命令回复从

# Replication
role:salve
...
# Other sections
...

变为

# Replication
role:master
...
# Other sections
...

sentinelsalveuptomaster.png

修改从服务器的复制目标

当心的主服务出现之后,领头sentinel下一步要做的就是,让已下线主服务器属下的所有从服务器去复制新的主服务器,这一动作通过向从服务器发送SLAVEOF 命令来实现
sentinelnewmasterslaveof.png

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

故障转移操作最后要做的是,将已下线的主服务器设置为新的主服务器的从服务器。

因为旧的主服务器已下线,所以这种设置是保存在server1对应的实例结构里面的,当server1重新上线时,sentinel就会向它发送SLAVEOF命令,让它成为server2的从服务器。
sentineloldmasterslaveof.png

集群

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

节点

一个redsi集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,他们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,必须将各个独立的节点连接起来,构成一个包含多个节点的集群。

连接各个节点的工作可以使用CLUSTER MEET命令来完成

CLUSTER MEET <ip> <port>

向一个节点node发送CLUSTER MEET命令,可以让node节点与ip和port所指定的节点进行握手(handshake),当握手成功时,node节点就会讲ip和port所指定的节点添加到node节点当前所在的集群中。

例如现在有三个独立的节点127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002,(下文省略IP地址,直接使用端口号进行区分),首先使用客户端连接上节点7000,通过发送CLUSTER NODE命令可以看到,集群目前只包含7000自己一个节点

redis-cli -c -p 7000
127.0.0.1:7000> CLUSTER NODES
ad1afc1afa1af5bae21f5a61f61d1a5691dabc :0 myself,master -0 0 0 connected

继续向节点7000发送以下命令,可以将节点7001添加到节点7000所在的集群里

127.0.0.1:7000> CLISTER MEET 127.0.0.1 7001
OK
127.0.0.1:7000> CLUSTER NODES
19419651e13213498156c132132dfa12316168e 127.0.0.1:7001 master -0 1388204746210 0 connected
ad1afc1afa1af5bae21f5a61f61d1a5691dabc :0 myself,master -0 0 0 connected

继续向节点7002发送以下命令,可以将节点7002添加到节点7000所在的集群里
cluster1.png

cluster2.png

cluster3.png

cluster4.png

cluster5.png

启动节点

一个节点就是一个运行在集群模式下的redis服务器,redis服务器在启动时会根据cluster-enable配置选项是否为yes来决定是否开启服务器的集群模式

节点(运行在集群模式下的redis服务器)会继续使用所有在单机模式中使用的服务器组件,比如

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

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

集群数据结构

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;
    // 输出缓冲区,保存着等待发送给其他节点的消息(message)
    sds sndbuf;
    // 输入缓冲区,保存着从其他节点接收到的消息
    sds rcvbuf;
    // 与这个连接相关联的节点,如果没有的话就为NULL
    struct clusterNode *node;
} clusterLink;

redisClient结构和clusterLink结构的相同和不同之处

redisClient结构和cluster结构都有自己的套接字描述符和输入、输出缓冲区,这两个结构的区别在于,redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区是用于连接节点的。

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

typedef struct clusterState{
    // 指向当前节点的指针
    clusterNode *myself;
    // 集群当前的配置纪元,用于实现故障转移
    uint64_t currentEpoch;
    // 集群当前的状态,在线还是下线
    int state;
    // 集群节点名单(包含myself节点)
    // 字典的键位节点的名字,字典的值为节点对应的clusterNode结构
    dict *nodes;
	// ...        
} clusterState;

以之前的7000,7001,7002节点为例,下图展示了节点7000创建的clusterState结构,这个结构从节点7000的角度记录了集群以及集群包含的三个节点的当前状态(图中省略了部分属性):

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

节点7001和节点7002也会创建类似的clusterState结构
clusterNode.png

CLUSTER MEET 命令的实现

通过向节点A发送CLUSTER MEET命令,客户端可以让接受命令的节点A将另一个节点B添加到节点A所在的集群里面

CLUSTER MEET <ip> <port>

收到命令的节点A将与节点B进行握手,以此来确认彼此的存在,并为将来的进一步通信打好基础:

  1. 节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面
  2. 之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息(message)
  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消息,握手完成
    clusterhandshake.png

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

槽指派

redis集群通过分片的方式来保存数据库中的键值对,集群的真个数据库被分为16384个槽(slot),数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以操作0个或则最多16384个槽。

当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok),相反的,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。

之前通过CLUSTER MEET命令将7000,7001,7002三个节点连接到了同一个集群里,不过这个集群目前仍处于下线状态,因为集群中的三个节点都没有在处理任何槽

127.0.0.1:7000> CLUSTER INFO
cluster_state:fail
cluster_slots_assigned:0
cluster_slots_ok:0
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_kown_nodes:3
cluster_size:0
cluster_current_epoch:0
cluster_stats_messages_sent:110
cluster_stats_messages_received:28

通过向节点发送CLUSTER_ADDSLOTS命令,可以将一个或者多个槽指派(assign)给节点负责

CLUSTER ADDSLOTS <slot> [slot...]

执行一下命令可以将槽0到槽5000指派给节点7000负责

127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
OK
127.0.0.1:7000> CLUSTER NODES
9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26 127.0.0.1:7002 master - 0 1388316664849 0 connected
68eef66df23420a5862208ef5b1a7005b806f2ff 127.0.0.1:7001 master - 0 1388316665850 0 connected
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected 0-5000

为了让7000,7001,7002是三个节点所在集群进入上线状态,继续执行命令,将槽5001至10000指派给节点7001,槽10001至槽16383指派给7002

当个以上三个CLUSTER ADDSLOTS命令都执行完毕之后,数据库中的16384个槽都已经指派给了相应的节点,集群进入上线状态

127.0.0.1:7000> CLUSTER INFO
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:3
cluster_size:3
cluster_current_epoch:0
cluster_stats_messages_sent:2699
cluster_stats_messages_received:2617
127.0.0.1:7000> CLUSTER NODES
9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26
127.0.0.1:7002 master - 0 1388317426165 0 connected 10001-16383
68eef66df23420a5862208ef5b1a7005b806f2ff 127.0.0.1:7001 master - 0 1388317427167 0 connected 5001-10000
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected 0-5000

记录节点的槽指派信息

clusterNode结构的slots属性和numslot属性记录了节点负责处理哪里槽

struct clusterNode {
    // ...
    unsigned char slots[16384/8];
    int numslots;
    // ...
};

slot属性是一个二进制位数组(bit array),这个数组的长度为16384/8=2048个字节,总共包含16384个二进制。

redis以0为起始索引,16383为终止索引,对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个槽的指派信息

typedef struct clusterState{
    // ...
    clusterNode *slots[16384];
    //...
} clusterState;

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)。

虽然clusteState.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 <solt> [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):
	return CRC16(key) & 16383

其中CRC16(key)语句用于计算出键key的CRC-16检验和,而&16383语句则用于计算出一个介于0至16383之间的整数,作为key的槽。

使用CLUSTER KEYSLOT <key> 命令可以查看一个给定键属于哪个槽。

CLUSTER KEYSLOT命令就是通过调用上面给出的槽分配算法来实现的,以下是该命令的伪代码

def CLUSTER_KEYSOLT(key):
	# 计算槽号
	slot = slot_number(key)
	# 将槽号返回给客户端
	reply_client(slot)

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

当节点计算出键所属的槽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的节点

例如,客户端向接单7000发送命令SET data "2013-12-31"的时候,节点首先计算出键date属于槽2022,然后检查得出clusterState.slots[2002]等于clusterState.myself,这说明槽2022正是有节点7000负责,于是节点7000直接执行这个SET命令,并将结果返回给发送命令的客户端。

当客户端向节点7000发送命令 SET msg "happy new year!"的时候,节点首先计算出键msg属于槽6257,然后检查clusterState.slots[6257]是否等于clusterState.myself,结果发现两者并不相等,这说明槽6257并非由节点7000负责处理,于是节点7000访问clusterState.slots[6257]所指向的clusterNode结构,并根据结构记录的IP地址127.0.0.1和端口号7001,向客户端返回错误MOVED 6257 127.0.0.1:7001,指引节点转向正在负责处理槽6257的节点7001

MOVED错误

当节点发现键所在的槽并非由自己负责处理的时候,节点机会向客户点返回一个MOVED错误,指引客户端转向至正在负责槽的节点

MOVED错误的格式为

MOVED <slot> <ip>:<port>

其中slot为键所在的槽,而ip和port贼是负责处理槽slot的节点的ip地址和端口号

当客户端接收到节点返回的MOVED错误时,客户端会根据MOVED错误中提供的IP地址和端口号,转向至负责处理槽slot的节点,并向该节点重新发送之前想要的执行的命令。

一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是换一个套接字来发送命令。

如果客户端尚未与想要转向的节点创建套接字连接,那么客户端会先根据MOVED错误提供的IP地址和端口号来连接节点,然后再进行转向

被隐藏的MOVED错误

集群模式的redis-cli客户端在接收到MOVED错误时,并不会打印出MOVED错误,而是根据MOVED错误自动进行节点转向,并打印出转向信息,所以我们是看不见节点返回错误的

redis-cli -c -p 7000 # 集群模式
127.0.0.1:7000> SET msg "happy new year!"
-> Redirected to slot [6257] located at 127.0.0.1
OK

但是如果使用单例模式的redis-cli客户端,再次向节点7000发送相同的命令,那么MOVED错误就会被客户端打印出来

redis-cli -p 7000 # 单机模式
127.0.0.1:7000> SET msg "happy new year!"
(error) MOVED 6257 127.0.0.1:7001

因为单机模式的redis-cli客户端不清楚MOVED错误的作用,所以会直接打印出来,不会进行自动转向

节点数据库的实现

集群节点保存键值对以及键值对过期时间的方式,与单机redis服务器保存键值对已经键值对过期时间的方式完全相同。

节点和单机服务器在数据库方面的一个区别是,节点只能使用0号数据库,而单机redis服务器则没有这个限制。

除了将键值对保存在数据库里面之外,节点还会用clusterState结构中的slot_to_keys跳跃表来保存槽和键之间的关系

typedef struct clusterState{
    // ...
    zskiplist *slot_to_keys
	// ...
} clusterState;

slots_to_keys跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员(member)都是一个数据库键

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

通过在slots_to_keys跳跃表中记录各个数据库键所属的槽,节点可以很方便的对属于某个或某些槽的所有数据库键进行批量操作,例如CLISTER GETKEYSINSLOT <slot> <count> 命令可以返回多count个属性槽slot的数据库键,而这个命令就是通过遍历slot_to_keys跳跃表来实现的。

重新分片

redis集群的重新分片操作,可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。

重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点可以继续处理命令请求。

例如,对于之前提到的包含7000,7001,7002三个节点的集群来说,可以想这个集群添加一个IP为127.0.0.1,端口为7003的节点(简称节点7003)

redis-cli -c -p 7000
127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7003
OK

此时该节点是没有可处理槽的

127.0.0.1:7000> cluster nodes
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected 0-5000
68eef66df23420a5862208ef5b1a7005b806f2ff 127.0.0.1:7001 master - 0 1388635782831 0 connected 5001-10000
9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26 127.0.0.1:7002 master - 0 1388635782831 0 connected 10001-16383
04579925484ce537d3410d7ce97bd2e260c459a2 127.0.0.1:7003 master - 0 1388635782330 0 connected

通过重新分片操作,将原本指派给节点7002的槽15001只16383改为指派给节点7003

127.0.0.1:7000> cluster nodes
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected 0-5000
68eef66df23420a5862208ef5b1a7005b806f2ff 127.0.0.1:7001 master - 0 1388635782831 0 connected 5001-10000
9dfb4c4e016e627d9769e4c9bb0d4fa208e65c26 127.0.0.1:7002 master - 0 1388635782831 0 connected 10001-15000
04579925484ce537d3410d7ce97bd2e260c459a2 127.0.0.1:7003 master - 0 1388635782330 0 connected 15001-16383

重新分片的实现原理

redis集群的重新分片操作是由redis的集群管理软件redis-trib负责执行的,redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。

redis-trib对集群的单个槽slot进行重新分片的步骤如下

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

如果重新分片涉及多个槽,那么redis-trib将对每个给定的槽分别执行上述的步骤

ASK错误

在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况,属于被迁移槽的一部分键值对保存在源节点中,而另一部分键值对被保存在目标节点里。

当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于被迁移的槽时

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

CLUSTER SETSLOT IMPORTING命令的实现

clusterState结构的importing_slots_from数组记录了当前节点正在从其他节点导入的槽

typedef struct clusterState{
    // ...
    clusterNode *importing_slots_from[16384];
    // ...
} clusterState;

如果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数组记录了当前节点正在迁移至其他节点的槽

typedef struct clusterState {
    // ...
    clusterNode *migrating_slots_to[16384];
    // ...
} clusterState;

如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在将槽i迁移至clusterNode所代表的节点。

在对集群进行重新分片的时候,向源节点发送命令

CLUSTER SETSLOT <i> MIGRATING <targer_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。
askerror.png

ASKING命令

ASKING命令唯一要做的就是打开该命令的客户端REDIS_ASKING标识

伪代码:

def ASKING():
	# 打开标识
	client.flags |= REDIS_ASKING
	# 向客户端返回OK回复
	reply("OK")

在一般情况下,如果客户端向节点发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,那么节点将向客户端返回一个MOVED错误;但是如果节点的clusterState.importing_slots_from[i]显示节点正在导入槽i,并且发送命令的客户端带有REDIS_ASKING标识,那么节点将破例执行这个关于槽i的命令一次。
clusterslotasking.png

当客户端接收到ASK错误并转向正在导入槽的节点时,客户端会先向节点发送一个ASKING命令,然后才重新发送想要执行的命令,这是因为如果客户端不发送ASKING命令,直接发送要执行的命令,那么客户端的命令将被节点拒绝,并返回MOVED错误。

客户端的REDIS_ASKING标识是一个一次性标识,当节点执行了REDSI_ASKING标识的客户端发送的命令之后,该标识就会被移除。

redis-cli -p 7003
127.0.0.1:7003> GET "love"
(error) MOVED 16198 127.0.0.1:7002

127.0.0.1:7003> ASKING
OK
127.0.0.1:7003> GET "love"
"you get th key 'love'"

127.0.0.1:7003> GET "love"
(error) MOVED 16198 127.0.0.1:7002

ASK错误和MOVED错误的区别

ASK错误和MOVED错误都会导致客户端转向

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

复制与故障转移

redis集群汇总的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在复制的主节点下线时,代替下线主节点继续处理命令请求。

例如现在有一个集群,包含7000,7001,7002,7003四个主节点和7004,7005两个复制7000的从节点
clusterFailover1.png

节点角色状态工作
7000主节点在线处理0-5000槽
7001主节点在线处理5001-10000槽
7002主节点在线处理10001-15000槽
7003主节点在线处理15001-16383槽
7004从节点在线复制节点7000
7005从节点在线复制节点7000

如果此时节点7000进入下线状态,那么集群中仍在正常运行的接个主节点将在节点7000的两个从节点7004和7005中选出一个节点作为新的主节点,这个新的主节点将接管原来节点7000负责处理的槽,并继续处理客户端发送的请求命令。

例如节点7004被选为新的主机诶单,那么7004将处理0-5000槽,节点7005从原来的复制节点7000改为复制节点7004
clusterFailover2.png

节点角色状态工作
7000主节点下线处理0-5000槽(因为故障转移已完成,所以此工作无效)
7001主节点在线处理5001-10000槽
7002主节点在线处理10001-15000槽
7003主节点在线处理15001-16383槽
7004主节点在线处理0-5000槽
7005从节点在线复制节点7004

如果故障转移完成之后,节点7000重新上线,那么它将成为7004的从节点。
clusterFailover3.png

节点角色状态工作
7000从节点在线复制7004
7001主节点在线处理5001-10000槽
7002主节点在线处理10001-15000槽
7003主节点在线处理15001-16383槽
7004主节点在线处理0-5000槽
7005从节点在线复制节点7004

设置从节点

向一个节点发送命令

CLUSTER REPLICATE <node_id>

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

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

一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个节点正在复制某个主节点

集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves属性中记录正在复制这个主节点的从节点名单

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

clusterFailover4.png

上图为节点7004和7005成为节点7000的从节点之后的,集群中各个节点为节点7000创建的clusterNode结构

  • 节点7000的clusterNode结构的numslaves属性值为2,说明有两个从节点在复制节点7000、
  • 节点7000的clusterNode结构的slaves数组的两个项分布指向代表节点7004和代表节点7005的clusterNode结构,说明节点7000的两个从节点分布是7004和7005

故障检测

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

例如节点7001向节点7000发送了一条PING消息,但是节点7000没有在规定的时间内,向节点7001返回一条PONG消息,那么节点7001就会在自己的clusterState.nodes字典中找到节点7000对应的clusterNode,并在它的flags属性中打开REDIS_NODE_PFAIL标识,以此表示节点7000进入疑似下线状态。
clusterFailover5.png

集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如某个节点的是处于现在状态、疑似下线状态(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;

例如,当主节点7001在收到主节点7002、主节点7003发送的消息后,等值主节点7002和主节点7003都认为主节点7000进入了疑似下线状态,那么主节点7001将为主节点7000创建下线报告
clusterFailover6.png

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

例如,主节点7002和7003都认为主节点7000进入下线状态,并且主节点7001也认为主节点7000进入疑似下线状态,综合起来,集群中四个负责处理槽的主节点里面。有三个都将主节点7000标记为下线,数量超过了半数,所以主节点7001会将主节点7000标记为已下线,并向集群广播一条关于主节点7000的FAIL消息。
clusterFailover7.png

故障转移

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,步骤如下

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

选取新的主节点

新的主机诶单是通过选举产生的,步骤如下

  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中选举领头sentinel的方法相似,都是基于rafa算法的领头选举