0%

Redis 多机数据库的实现

先介绍了 Redis 中主从服务器复制的流程,后介绍到实现 Sentinel 机制来保证故障的发现和转移,进一步介绍集群的数据流转原理。而这些机制合而为一,共同构成了 Redis 的多机数据库的实现原理,即:主从复制、数据 slot 化管理、故障的监察与转移等。

复制

Redis中,用户通过执行 slaveof 命令或者设置 slaveof 选项,让一个服务器去复制另外一个服务器。

127.0.0.1:12345> SLAVEOF 127.0.0.1 6379
OK

那么服务器 127.0.0.1:12345 将成为 127.0.0.1:6379 的从服务器,而服务器 127.0.0.1:6379 则成为主服务器。进行复制中的主从服务器双方的数据库将保存相同的数据,概念上将这种现象称作 “数据库状态一致” 或者简称”一致”。

2.8版本之前复制功能

旧版复制功能的流程

旧版本复制功能分为:同步(sync)和命令传播(command propagate)

  • 同步:将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
  • 命令传播:主服务器数据库状态被修改,记录相关修改命令并传播给从服务器执行

下图是主从服务器在执行SYNC命令期间的通信过程示意图

下图是主从服务器同步过程

旧版复制功能的缺陷

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

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

对于初次复制来说,旧版复制功能能够很好地完成任务,但对于断线后重复制来说,旧版复制功能虽然也能让主从服务器重新回到一致状态,但效率却非常低。对于断线重连来说,重连时间越短,接到的命令也就越少,而为了补足少量的数据而执行一次 SYNC 命令无疑是非常低效的。

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

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

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

因为 SYNC 命令是非常耗费资源的操作,所以 Redis 有必要保证在真正有需要时才执行 SYNC 命令

2.8版本之后复制功能

新版复制功能的流程

使用 PSYNC 命令代替 SYNC 命令来执行复制时的同步操作。PSYNC 命令具有完整重同步(full resynchronization)和部分重同步(partial resynchi ronization)两种模式:

  • 完整重同步:类似 SYNC 的初始同步
  • 部分重同步:处理断线后复制情况,将主从服务器断开期间执行的写命令发送给从服务器。通过复制偏移量、复制积压缓冲区、服务器运行 ID

下图是主从服务器执行部分重同步的过程示意图

下图是使用 PSYNC 命令来进行断线后重复制过程

部分重同步的实现

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

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

复制偏移量

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

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

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

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

当服务器 A 断线导致与主服务器数据不一致

从服务器 A 在断线之后就立即重新连接主服务器,并且成功,那么接下来,从服务器将向主服务器发送 PSYNC 命令,报告从服务器 A 当前的复制偏移量为 10086,那么这时,主服务器应该对从服务器执行完整重同步还是部分重同步呢?如果执行部分重同步的话,主服务器又如何补偿从服务器A在断线期间丢失的那部分数据呢?以上问题的答案都和复制积压缓冲区有关

复制积压缓冲区

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

固定长度先进先出队列的入队和出队规则:

新元素从一边进入队列,而旧元素从另一边弹出队列。当入队元素的数量大于队列长度时,最先入队的元素会被弹出,而新元素会被放入队列。

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

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

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

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

主服务器对 A 服务器执行部分重同步操作

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

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

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

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

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

服务器运行ID

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

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

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

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

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

部分重同步实现流程

复制的实现

通过向从服务器发送 SLAVEOF 命令,让从服务器去复制主服务器

SLAVEOF <master_ip> <master_port>

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

将给定的 master_ip master_port 设置进从服务器的 masterhost masterport 字段,设置成功返回 OK

步骤二:建立Socket连接

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

如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责执行后续的复制工作。

而主服务器在接受(accept)从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来对待,这时从服务器将同时具有服务器(server)和客户端(client)两个身份:从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回命令回复。因为复制工作接下来的几个步骤都会以从服务器向主服务器发送命令请求的形式来进行,所以理解“从服务器是主服务器的客户端”这一点非常重要。

步骤三:发送PING命令

从服务器成为主服务器的客户端之后,首先向主服务器发送一个 PING 命令,检查 Socket I/O 读写状态是否正常。

下图为从服务器在发送PING命令时可能遇上的情况

步骤四:身份验证

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

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

在需要进行身份验证的情况下,从服务器将向主服务器发送一条 AUTH 命令,命令的参数为从服务器 masterauth 选项的值,如果主服务器设置的 requirepass 选项与命令参数传来的从服务器 masterauth 选项的值相同,则身份验证通过。

下图为从服务器在身份验证阶段可能遇上的情况

步骤五:发送端口信息

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

主服务器在接收到这个命令之后,会将端口号记录在从服务器所对应的客户端状态的slave_listening_port属性中。但是该属性目前唯一的作用是在主服务器执行 INFO replication 命令时打印出从服务器的端口号。

步骤六:同步

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

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

因此,在同步操作执行之后,主从服务器双方都是对方的客户端,它们可以互相向对方发送命令请求,或者互相向对方返回命令回复。也正因为主服务器成为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,不仅同步操作需要用到这一点,这也是主服务器对从服务器执行命令传播操作的基础。

步骤7:命令传播

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

心跳检测

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

REPLCONF ACK <replication_offset>

其中 replication_offset 是从服务器当前的复制偏移量。

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

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

    从服务器发送 INFO replication,若主服务器超过一秒钟没有接收到 REPLCONF ACK 命令,说明连接有问题。

  • 辅助实现 min-slaves 选项

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

    例如:在主服务器配置min-slaves-to-write=3min-slaves-max-lag=10则在从服务器的数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10秒时,主服务器将拒绝执行写命令,这里的延迟值就是上面提到的 INFO replication 命令的 lag 值。

  • 检测命令丢失

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

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

    Redis 2.8 版本以前的命令丢失

    REPLCONF ACK 命令和复制积压缓冲区都是 Redis 2.8 版本新增的,在 Redis 2.8 版本以前,即使命令在传播过程中丢失,主服务器和从服务器都不会注意到,主服务器更不会向从服务器补发丢失的数据,所以为了保证复制时主从服务器的数据一致性,最好使用 2.8 或以上版本的 Redis

Sentinel

Sentinel 是 Redis 高可用的解决方案,由一个或者多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些服务器属下的所有从服务器,并在被监视的主服务器下线的状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器。

下图为 Sentinel 监视 Redis 服务器

下图为 Sentinel 察觉 Redis 服务器下线

下图为 Sentinel 将某个从服务器升级为主服务器

下图为如果离线 Redis 服务器上线后,Sentinel 将其降级为从服务器

启动并初始化 Sentinel

//启动命令
redis−sentinel /path/to/your/sentinel.conf
或
redis-server /path/to/your/sentinel.conf

1. 初始化服务器

本质上是 Redis 服务器,但是在功能使用方面与服务器又有所不同。

2. 将Redis服务器默认配置替换为Sentinel专用配置

比如普通的 Redis 服务器使用 redis.h/redis_serverport 作为端口,而 Sentinel 使用 redis_sentinel_port 常量的值作为服务器端口。

除此之外,普通 Redis 服务器使用 redis.c/redisCommandTable 作为服务器的命令表,而 Sentinel 则使用 sentinel.c/sentinelcmds 作为服务器的命令表。因此可执行的命令两者也有所区分。

3. 初始化Sentinel状态

在应用了 Sentinel 的专用代码之后,服务器会初始化一个 sentinel.c/sentinelState 结构(后面简称 “Sentinel状态”),这个结构保存了服务器中所有和 Sentinel 功能有关的状态(服务器的一般状态仍然由 redis.h/redisServer 结构保存)并且根据给定的配置文件,初始化 Sentinel 的监视主服务器列表(也就是 master 字段)。

下图为 Sentinel 初始化监视主服务器列表

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

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

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

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

为什么有两个连接?

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

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

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

下图是 Sentinel 向主服务器创建命令连接和订阅连接

获取主服务器信息

Sentinel 会以默认十秒一次的频率,发送命令连接向被监视的主服务器发送 INFO 命令,通过分析 INFO 命令的回复来获取主服务器的当前状态。

下图是 Sentinel 向带有三个从服务器的主服务器发送 INFO 命令

下图是 Sentinal 接收到的回复

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

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

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

下图为 Sentinel 维护从服务器网络连接映射

获取从服务器信息

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

在创建命令连接之后,Sentinel 在默认情况下,会以每十秒一次的频率通过命令连接向从服务器发送 INFO 命令并获得相应回复。

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

在默认情况下,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 通过频道信息发现一个新的 Sentinel 时,它不仅会为新 Sentinel 在 sentinels 字典中创建相应的实例结构,还会创建一个连向新 Sentinel 的命令连接,而新 Sentinel 也同样会创建连向这个 Sentinel 的命令连接,最终监视同一主服务器的多个 Sentinel 将形成相互连接的网络:Sentinel A 有连向 Sentinel B 的命令连接,而 Sentinel B 也有连向Sentinel A 的命令连接。

使用命令连接相连的各个 Sentinel 可以通过向其他 Sentinel 发送命令请求来进行信息交换。

Sentinel 之间不会创建订阅连接

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

检测主观下线状态

在默认情况下,Sentinel 会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他 Sentinel 在内)发送 PING 命令,并通过实例返回的 PING 命令回复来判断实例是否在线。如果一个实例在 down-after-milliseconds 毫秒内,连续向 Sentinel 返回无效回复,那么 Sentinel 会修改这个实例所对应的实例结构,在结构的 flags 属性中打开 SRI_S_DOWN 标识,以此来表示这个实例已经进入主观下线状态。

下面介绍一下有效和无效回复的定义:

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

当然,用户设置的 down-after-milliseconds 选项的值,不仅会被 Sentinel 用来判断主服务器的主观下线状态,还会被用于判断主服务器属下的所有从服务器,以及所有同样监视这个主服务器的其他 Sentinel 的主观下线状态。当然,多个 Sentinel 设置的主观下线时长可能不同,因此对 master 主观下线的判定也不同时。

检查客观下线状态

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

选举以及故障转移

与 Raft 算法选举过程基本类似,最终将当选的 Sentinel 节点升级为主服务器。

集群

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

节点

一个 Redis 集群通常有多个节点(node)组成,开始时每个节点相互独立,都处于一个只包含自己的集群当中,要组建一个真正工作的集群,需要将各个独立的节点连接起来,构成一个包含多节点的集群。

CLUSTER MEET <ip> <port> //连接节点

下图是握手过程

启动节点

Redis 服务器在启动时会根据 cluster-enabled 配置选项是否为 yes 来决定是否开启服务器的集群模式。节点会继续使用 redisServer 结构来保存服务器的状态,使用 redisClient 结构来保存客户端的状态,至于那些只有在集群模式下才会用到的数据,节点将它们保存到了 cluster.h/ClusterNode 结构、cluster.h/clusterLink 结构,以及 cluster.h/clusterState 结构里面,以此记录其他节点的状态。

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

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

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

redisClient 和 clusterLink 结构异同

两者都有 socket 描述符和输入输出缓冲区。区别在于,redisClient 结构中的套接字和缓冲区是用于连接客户端的,而 clusterLink 的上述字段是用于连接节点的。

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

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

下图为(7000、7001、7002)集群在 7000 节点中 clusterState 状态

将节点加入集群

槽指派

Redis 集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的每个键都属于这 16384 个槽的其中一个,集群中的每个节点可以处理 0 个或最多 16384 个槽。当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。如果没有进行槽分配,集群处于下线状态。

记录节点的槽指派信息

struct clusterNode {
    //....
    
    //包含16384个二进制位,标识节点处理哪些槽
    unsigned char slots[16384/8];
    //负责处理槽的数量
    int numslots;
    
    //....
}

传播并记录节点的槽指派信息

一个节点除了会将自己负责处理的槽记录在 clusterNode 结构的 slots 属性和 numslots 属性之外,还会将自己的 slots 数组通过消息发送给集群其他节点。节点接收到其他节点的 slots 数组时,会在自己的 clusterState.nodes 字典中查找对应的 clusterNode 结构,并对结构中的 slots 数组进行保存或者更新。

struct clusterState {
    //...
    
    //指针指向NULL则该槽未分配
    //指针非NULL则代表该槽分配给当前节点
    //方便 槽->节点 的查找方式
    clusterNode *slots[16384];
    
    //方便 节点->槽 的查找方式
    //nodes -> node -> char slots[]
    
    //...
}

在集群中执行命令

下图为客户端向节点发送数据库键命令的判断流程

计算键属于哪个槽

利用校验和来判断键的位置

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

节点数据库的实现

集群节点保存键值对以及键值对过期时间的方式与单机 Redis 服务器保存键值对以及键值对过期时间的方式完全相同。节点和单机服务器在数据库方面的一个区别是,节点只能使用 0 号数据库,而单机 Redis 服务器则没有这一限制。

服务器会在 0 号数据库 expire 字段记录下键值对的键及过期时间。

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

重新分片

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

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

  1. redis-trib 对目标节点发送CLUSTER SETSLOT <s1ot> IMPORTING <source_id>命令,让目标节点准备好从源节点导入(import)属于槽 slot 的键值对。

  2. redis-trib 对源节点发送CLUSTER SETSLOT <s1ot> MIGRATING <target_id>命令,让源节点准备好将属于槽 slot 的键值对迁移(migrate)至目标节点。

  3. redis-trib 向源节点发送CLUSTER GETKEYSINSLOT <s1ot> <count>命令,获得最多 count 个属于槽 slot 的键值对的键名。

  4. 对于步骤 3 获得的每个键名,redis-trib 都向源节点发送一个MIGRATE <target_ip> <target_port> <key _name> 0 <timeout>命令,将被选中的键原子地从源节点迁移至目标节点。

  5. 重复执行步骤 3 和步骤 4,直到源节点保存的所有属于槽 slot 的键值对都被迁移至目标节点为止。每次迁移键的过程如下图所示。

  6. redis-trib 向集群中的任意一个节点发送CLUSTER SETSLOT <slot> MODE <target_id>命令,将槽 slot 指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会知道槽 slot 已经指派给了目标节点。

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

ASK错误

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

IMPORTING和MIGRATING命令实现

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

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

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

在对集群进行重新分片的时候,向目标节点发送命令:CLUSTER SETSLOT <i> IMPORTING <source_id>,可以将目标节点 clusterState.importing_slots_from[i] 的值设置为 source_id 所代表节点的 clusterNode 结构。同时向源节点发送命令:CLUSTER SETSLOT <i> MIGRATING <target_id>,可以将源节点 migrating_slots_to[i] 的值设置为 target_id 所代表节点的 clusterNode 结构。

维护这两个数组的主要目的是作为判断源节点是否在迁移的条件。

ASKING命令

ASKING 命令唯一要做的就是打开发送该命令的客户端的 REDIS_ASKING 标识,该标识是一次性标识。

下图是 ASKING 命令区分 ASK 和 MOVED 的判断逻辑

ASK错误和MOVED错误的区别

ASK 错误和 MOVED 错误都会导致客户端转向,它们的区别在于

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

节点故障与故障转移

故障检测

集群中的每个节点都会定期地向集群中的其他节点发送 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 链表里面。

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

故障转移

Redis 集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:

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

消息

集群中的各个节点通过发送和接收消息(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 消息来让集群中的其他节点立即刷新关于这个节点的认识。主节点会向集群广播一条 PONG 消息,以此来让集群中的其他节点立即知道这个节点已经变成了主节点,并且接管了已下线节点负责的槽。
  • FAIL消息:当一个主节点 A 判断另一个主节点 B 已经进入 FAIL 状态时,节点 A 会向集群广播一条关于节点 B 的 FAIL 消息,所有收到这条消息的节点都会立即将节点 B 标记为已下线。
  • PUBLISH消息:当节点接收到一个 PUBLISH 命令时,节点会执行这个命令,并向集群广播一条 PUBLISH 消息,所有接收到这条 PUBLISH 消息的节点都会执行相同的 PUBLISH 命令。

消息头与请求体

每个消息头都由一个 cluster.h/clusterMsg 结构表示,主要记录发送者自身的信息,接收者根据这些信息对自身 clusterState.nodes 中结构进行更新。

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

MEET、PING、PONG消息实现

Redis 集群中的各个节点通过 Gossip 协议来交换各自关于不同节点的状态信息,其中 Gossip 协议由 MEET、PING、PONG 三种消息实现,这三种消息的正文都由两个 cluster.h/clusterMsgDataGossip 结构组成。因为MEET、PING、PONG三种消息都使用相同的消息正文,所以节点通过消息头的 type 属性来判断一条消息是 MEET 消息、PING 消息还是 PONG 消息。

每次发送 MEET、PING、PONG 消息时,发送者都从自己的已知节点列表中随机选出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个 clusterMsgDataGossip 结构里面。

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

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

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

FAIL消息实现

当集群里的主节点 A 将主节点 B 标记为已下线(FAIL)时,主节点 A 将向集群广播条关于主节点 B 的 FAIL 消息,所有接收到这条 FAIL 消息的节点都会将主节点 B 标记为已下线。

在集群的节点数量比较大的情况下,单纯使用 Gossip 协议来传播节点的已下线信息会给节点的信息更新带来一定延迟,因为 Gossip 协议消息通常需要一段时间才能传播至整个集群,而发送 FAIL 消息可以让集群里的所有节点立即知道某个主节点已下线,从而尽快判断是否需要将集群标记为下线,又或者对下线主节点进行故障转移。

FAIL消息的正文由 cluster.h/clusterMsgDataFail 结构表示,这个结构只包含一个 nodename 属性,该属性记录了已下线节点的名字:

typedef struct {
    char nodename[REDIS_CLUSTER_NAMELEN]; 
} clusterMsgDataFail;

PUBLISH消息的实现

当客户端向集群中的某个节点发送命令PUBLISH <channel> <message>的时候,接收到 PUBLISH 命令的节点不仅会向 channel 频道发送消息 message,它还会向集群广播一条 PUBLISH 消息,所有接收到这条 PUBLISH 消息的节点都会向 channel 频道发送 message 消息。

PUBLISH 消息的正文由 cluster.h/clusterMsgDataPublish 结构表示

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

为什么不直接向节点广播 PUBLISH 命令

实际上,要让集群的所有节点都执行相同的 PUBLISH 命令,最简单的方法就是向所有节点广播相同的 PUBLISH 命令,这也是 Redis 在复制 PUBLISH 命令时所使用的方法,不过因为这种做法并不符合 Redis 集群的“各个节点通过发送和接收消息来进行通信”这一规则,所以节点没有采取广播 PUBLISH 命令的做法。