Redis 的复制、哨兵和集群

在 Redis 中,复制功能的主要作用是实现读写分离和数据备份,哨兵的作用是实现故障切换(failover),集群的主要作用是实现数据分片(sharding),解决单机的资源和性能瓶颈问题。

Redis 复制

Redis 基础的复制功能允许一个 Redis 服务去复制另一个 Redis 服务,被复制的服务称为主服务(master),对主服务进行复制的称为从服务(slave)。从服务作为主服务的精确备份,即使因为网络问题等原因导致主从连接断开,从服务也会不断尝试重连,并在重新建立连接后继续进行复制操作。

假设现在有两个 Redis 服务,分别为 127.0.0.1:6379127.0.0.1:12345,如果向服务 127.0.0.1:12345 发送以下命令:

1
127.0.0.1:12345> SLAVEOF 127.0.0.1 6379

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

需要注意的是这种方式设置的主从是临时的,重启服务后失效。在配置文件中设置才会永久生效。

复制的实现

Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作。同步操作用于将从服务的状态更新至主服务当前的状态。命令传播操作则用于当主服务状态被修改后,主从服务出现状态不一致时,让从服务重新回到一致的状态。

当客户端向从服务发送 SLAVEOF 命令,要求从服务复制主服务时,从服务首先要做的就是将客户端给定的主服务的 IP 地址和端口号保存。SLAVEOF 命令是一个异步命令,在完成保存后从服务会向客户端返回 OK,表示复制命令已被接收,而实际的复制工作将在返回 OK 后才真正开始执行。

接着从服务将根据设置的主服务的 IP 地址和端口号,创建一个连向主服务的套接字连接。如果连接成功,那么从服务将会为这个套接字关联一个专门用于处理复制工作的文件事件处理器,比如接收 RDB 文件以及主服务传播过来的写命令等。而主服务将会为该套接字创建相应的客户端状态,将从服务看作是一个连接主服务的客户端,这时从服务将同时具有服务端(server)和客户端(client)两个身份:从服务可以向主服务发送命令请求,而主服务可以向从服务返回命令回复。

从服务成为主服务的客户端之后会向主服务发送一个 PING 命令,一个目的是检查套接字的读写状态是否正常,另一个目的是检查主服务能否正常处理命令请求。如果网络不佳或者主服务返回错误,则从服务会断开并重新创建连向主服务的套接字;如果从服务收到 PONG 回复,则表示主服务当前可以正常处理从服务的命令请求。

接下来就是身份验证阶段,如果主服务设置了 requirepass 选项,那么从服务必须设置 masterauth 选项(也就是密码),没有提供密码或者密码错误都会返回错误;如果主服务没有设置 requirepass 选项,从服务设置 masterauth 同样会返回错误。所有的错误都会使从服务终止当前的复制工作,直到身份验证通过。

身份验证通过后,从服务将执行 REPLCONF listening-port <port-number> 命令,向主服务发送从服务的监听端口号,主服务会将该端口号记录在从服务所对应的客户端状态的 slave_listening_port 属性中。

接下来就是真正进行同步的阶段,从服务会向主服务发送 PSYNC 命令进行同步,如果是初次同步,则使用完整重同步模式进行复制。值得一提的是,在同步操作执行之前,只有从服务是主服务的客户端,但是在执行同步操作之后,主服务也会成为从服务的客户端。正是因为这样,主服务才可以发送写命令来改变从服务的状态,这也是主服务对从服务进行命令传播的基础。

PSYNC 命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式。完整重同步用于处理初次复制的情况,收到该命令的主服务会执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从现在开始执行的所有写命令,当主服务的 BGSAVE 命令执行完毕后,主服务会将生成的 RDB 文件发送给从服务,从服务接收并载入这个 RDB 文件,从而将数据库的状态更新至主服务执行 BGSAVE 命令时的状态。接着主服务会将记录在缓冲区的所有写命令发送给从服务,从服务执行这些写命令,将数据库更新至主服务当前的状态。部分重同步则用于处理在命令传播阶段,主从发生断连后重复制的情况,当从服务在断线后重新连接主服务时,如果条件允许,主服务可以将断开期间执行的写命令发送给从服务,而不必重新进行完整重同步。

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

部分重同步的实现

部分重同步的重点包括复制偏移量(replication offset)、复制积压缓冲区(replication backlog)和运行 ID(run ID)。

心跳检测

在命令传播阶段,心跳检测用于检测主从之间的网络连接状态、辅助实现 min-slaves 选项以及检测命令丢失。

关于复制的几个重要事实

  • 从服务能够接受其他从服务的连接。除了可以将多个从服务连接到主服务外,还可以将从服务连接到其他从服务,所有的从服务都将从主服务接收完全相同的复制流。
  • 复制在主服务中没有阻塞。当一个或多个从服务与主服务进行初次同步或部分重同步时,主服务可以继续处理其他请求。
  • 复制在从服务中基本也没有阻塞。在开始执行初始同步时,从服务可以继续提供旧版本的数据。在接收到主服务发送的 RDB 文件之后,从服务必须删除旧的数据集,加载新的数据集,此时从服务会被阻塞。从 Redis 4.0 开始,可以配置使旧数据集的删除发生在其他线程,但是新数据集的加载仍然要在主线程中进行,此时从服务还是会阻塞。
  • 默认情况下,从服务是以只读模式启动的,该模式可以通过 slave-read-only 选项控制。只读模式下从服务只能接收读命令,所有的写命令都会返回错误。
  • 可以使用无盘复制(生成的 RDB 文件不存盘直接发送给从节点)来降低主服务的磁盘开销,这适用于主服务所在的机器磁盘性能较差但网络良好的场景。

哨兵(Sentinel)

哨兵是 Redis 高可用的解决方案,该方案是由一个或多个 Sentinel 实例组成 Sentinel 系统,这个系统可以监视多个主服务以及这些主服务下的所有从服务,在被监视的主服务进入下线状态时,自动将下线主服务下属的某个从服务提升为新的主服务,然后由新的主服务继续处理命令请求。

哨兵的运行过程

使用 redis-sentinel /path/sentinel.conf 可以启动一个 Sentinel,由于 Sentinel 本质上就是一个运行在特殊模式下的 Redis 服务,所以也可以使用 redis-server /path/sentinel.conf --sentinel 命令来启动。

初始化服务

启动 Sentinel 的第一步就是初始化一个普通的 Redis 服务,但是该服务不会载入 RDB 或 AOF 文件。

使用专用代码

接下来第二步就是将普通 Redis 服务使用的代码替换成 Sentinel 专用的代码。比如说,普通 Redis 服务使用 server.c/redisCommandTable 作为服务的命令表,而 Sentinel 则使用 sentinel.c/sentinelcmds 作为服务的命令表,从该命令表可以看出 Sentinel 只能使用 PING、SENTINEL、INFO、SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE 和 PUNSUBSCRIBE 这些命令。

初始化状态

接着就是初始化 Sentinel 的状态,即通过读取配置文件来初始化 sentinel.c/sentinelState 结构,其中 masters 字典属性记录了所有被 Sentinel 监视的主服务信息。这里有一个配置文件的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#####################
# master1 configure #
#####################
sentinel monitor master1 127.0.0.1 6379 2
sentinel down-after-milliseconds master1 30000
sentinel parallel-syncs master1 1
sentinel failover-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 failover-timeout master2 450000

创建连向主服务的连接

初始化 Sentinel 的最后一步就是创建连向监视主服务的网络连接,Sentinel 将成为主服务的客户端,它可以向主服务发送命令,并从命令回复中获取相关的信息。对于每个被 Sentinel 监视的主服务来说,Sentinel 会创建两个连向主服务的异步网络连接,一个是命令连接,用于发送命令和接收回复;另一个是订阅连接,专门用于订阅主服务的 __sentinel__:hello 频道。

在 Redis 目前的发布和订阅功能中,被发送的信息都不会保存在 Redis 服务中,所以如果在信息发送时,接收的客户端不在线或者断线,则信息就会丢失。因此为了不丢失 __sentinel__:hello 频道的任何信息,Sentinel 必须专门用一个订阅连接来接收该频道的信息。

获取主从服务的信息

Sentinel 默认会以每 10 秒一次的频率通过命令连接向被监视的主服务发送 INFO 命令来获取主服务的当前信息,INFO 命令的回复类似于以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 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 将对主服务的实例结构 sentinelRedisInstance 进行更新,比如主服务重启后,它的运行 ID 就会和之前保存的不同,Sentinel 会检测到该情况并进行更新。同时主服务返回的从服务信息将会被保存到主服务实例结构中的 slaves 字典属性里,从服务的实例结构同样使用 sentinelRedisInstance

主从服务信息的存储结构

Sentinel 在分析 INFO 命令返回的从服务信息时,会检查对应的从服务实例结构是否已经存在,如果存在就进行更新;如果不存在,就会在 slaves 字典中创建一个新的实例结构,同时会创建连接到从服务的命令连接和订阅连接,并同样会以每 10 秒一次的频率通过命令连接向从服务发送 INFO 命令,并根据返回信息更新从服务实例的结构。

向所有服务发送信息

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

1
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 正在监视的是主服务,则这些就是主服务的信息;如果 Sentinel 正在监视的是从服务,则这些就是从服务正在复制的主服务的信息。

订阅所有服务的信息

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

1
SUBSCRIBE __sentinel__:hello

该订阅会一直维持到 Sentinel 与服务的连接断开为止。这也就是说,对于每个与 Sentinel 连接的服务,Sentinel 既会通过命令连接向服务的 __sentinel__:hello 频道发送命令信息,又通过订阅连接从服务的 __sentinel__:hello 频道接收消息。对于监视同一个服务的多个 Sentinel 来说,一个 Sentinel 发送的信息会被其他 Sentinel 接收到(因为其他 Sentinel 也订阅了该服务的该频道)。

更新 sentinels 字典并创建命令连接

Sentinel 为主服务创建的实例结构中的 sentinels 字典保存了所有监视该服务的 Sentinel 的信息,Sentinel 的实例结构同样使用 sentinelRedisInstance。当一个 Sentinel 接收到其他 Sentinel 发来的信息时,会从信息中分析并提取源 Sentinel 的信息,然后检查自己的 sentinels 字典,如果已经存在该 Sentinel 实例,则进行更新;否则会在 sentinels 字典中创建一个新的 Sentinel 实例结构,同时还会创建一个连向新 Sentinel 的命令连接,而新 Sentinel 同样也会创建连向这个 Sentinel 的命令连接,最终监视同一主服务的多个 Sentinel 将会形成互相连接的网络。

检测主观下线状态

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

用户设置的 down-after-milliseconds 不仅用来判断主服务的主观下线状态,还被用于判断主服务下属的所有从服务,以及所有同样监听该主服务的其他 Sentinel 的主观下线状态。不同的 Sentinel 设置的主观下线时长可能不同,当一个 Sentinel 将主服务判断为主观下线时,其他 Sentinel 可能仍然认为主服务处于在线状态,只有当主服务的断线时长超过其他 Sentinel 设置的时长后,所有的 Sentinel 才会认为该主服务处于主观下线状态。

检查客观下线状态

当 Sentinel 将一个主服务判断为主观下线之后,为了确认这个主服务是否真的下线,它会向同样监视该主服务的其他 Sentinel 进行询问,看它们是否也认为该主服务处于下线状态。当它收集到足够数量(Sentinel 配置中设置的 quorum 参数的值)的下线判断后,就会将主服务判定为客观下线,并对主服务执行故障转移操作。

命令 sentinel monitor master 127.0.0.1 6379 2 中最后的那个值就是 quorum 参数的值,每个 Sentinel 配置的 quorum 参数的值可能不同,因此判断客观下线的条件也就不同。

选举领头 Sentinel

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

故障转移

故障转移包含三个步骤,首先会在已经下线的主服务下属的所有从服务中挑选一个从服务,向它发送 SLAVEOF no one 命令,将其转换为主服务。接着会向已经下线的主服务下属的所有从服务发送 SLAVEOF 命令,让它们去复制新的主服务。最后要将已经下线的主服务设置为新的主服务的从服务,并在该从服务恢复上线后,向它发送 SLAVEOF 命令,让它真正成为新的主服务的从服务。

总结

  • Sentinel 只是一个运行在特殊模式下的 Redis 服务,它使用了和普通模式不同的命令表,因此能够使用的命令也和普通 Redis 服务不同。
  • Sentinel 会读取用户指定的配置文件,为每个要被监视的主服务创建相应的实例结构,同时创建连向主服务的命令连接和订阅连接。
  • Sentinel 通过向主服务发送 INFO 命令来获取主服务和主服务下属的所有从服务的信息,并为这些从服务创建相应的实例结构,以及连向这些从服务的命令连接和订阅连接。
  • Sentinel 默认会以每十秒一次的频率向被监视的主服务和从服务发送 INFO 命令,当主服务处于下线状态或者 Sentinel 正在对主服务进行故障转移时,Sentinel 向从服务发送 INFO 命令的频率会改为每秒一次。
  • 对于监视同一主服务和从服务的多个 Sentinel 来说,它们会以每两秒一次的频率,通过向被监视服务的 __sentinel__:hello 频道发送消息来向其他 Sentinel 宣告自己的存在。
  • 每个 Sentienl 也会从 __sentinel__:hello 频道中接收其他 Sentinel 发来的消息,并根据这些信息为其他 Sentinel 创建相应的实例结构,以及命令连接。
  • Sentinel 以每秒一次的频率向实例(包括主服务、从服务和其他 Sentinel)发送 PING 命令,并根据回复来判断实例是否在线。当一个实例在指定的时长中连续向 Sentinel 发送无效回复时,Sentinel 会将这个实例判断为主观下线。
  • 当 Sentinel 将一个主服务判断为主观下线时,它会向其他同样监视该主服务的 Sentinel 询问,看它们是否同意该主服务已经主观下线,当收集到足够数量的主观下线投票后,它会认为该主服务处于客观下线状态,会发起一次针对主服务的故障转移操作。
  • 故障转移操作之前需要所有监视下线主服务的 Sentinel 发起 Leader 选举,并由 Leader 对下线主服务执行故障转移操作。该选举方法是对 Raft 算法的实现。

集群

集群是 Redis 提供的分布式数据库方案,集群通过分片来进行数据共享,同时提供复制和故障转移的功能。集群的重要概念包括节点、槽指派、命令执行、重新分片、转向、故障转移和消息等。

节点

节点就是运行在集群模式下的 Redis 服务,集群模式需要在配置文件中指定,对应 cluster-enabled 选项。在节点运行时,除了会继续使用所有在单机模式下的服务组件,使用 redisServer 和 redisClient 结构保存服务端和客户端的状态外,还会使用新的结构来保存在集群模式下需要用到的数据。其中,使用 clusterNode 结构保存节点状态,使用 clusterLink 结构保存连接节点所需的信息,使用 clusterState 保存当前节点视角下的集群状态。

集群节点结构

一个 Redis 集群通常由多个节点组成,在刚开始时,每个节点都是互相独立的,只有将它们连接起来才能构成一个包含多个节点的集群。连接节点需要使用 CLUSTER MEET <ip> <port> 命令,该命令可以让该节点与指定节点进行握手(handshake),握手成功的两个节点会将彼此添加到 clusterState.nodes 字典中,同时该节点会将指定节点的信息通过 Gossip 协议传播给集群中的其他节点,让其他节点也与指定节点进行握手。

槽指派

Redis 集群通过分片的方式来保存数据库中的键值对,集群的整个数据库被分为 16384 个槽(slot),数据库中的每个键都通过 CRC16(key) & 16383 的方式计算它所在的哈希槽。集群的每个节点可以处理 0 个或最多 16384 个槽,当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果有任何一个槽没有得到处理,集群则处于下线状态(fail)。

通过向节点发送 CLUSTER ADDSLOTS <slot> [slot ...] 命令,可以将一个或多个槽指派给该节点。比如,使用以下命令可以将槽 0 到槽 5000 指派给 127.0.0.1:6379 节点:

1
127.0.0.1:6379> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000

Redis 集群使用 clusterNode 结构中的 slots 属性记录节点负责处理哪些槽,使用 numslots 属性记录节点处理槽的数量。其中 slots 属性是一个二进制位数组(bit array),数组的长度为 16384/8 = 2048 个字节,共包含 16384 个二进制位。Redis 以 0 为起始索引,为这 16384 个二进制位进行编号,并根据索引 i 对应的二进制位的值来判断节点是否负责处理槽 i。比如下图中,该节点就负责处理 0 到 7 号这 8 个槽。

slots属性

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

每个节点的 clusterState 结构中都有一个 slots 数组,记录了集群中所有 16384 个槽的指派信息,每个元素都是一个指向 clusterNode 结构的指针。所以说,clusterState.slots 数组记录了集群中所有槽的指派信息,而 clusterNode.slots 数组则只记录了该节点被指派的槽信息。

执行命令

集群上线后,客户端就可以向集群中的节点发送命令请求了。当客户端发送的命令与数据库键有关时,接收命令的节点会计算出该键属于哪个槽,并检查这个槽是否指派给了自己。如果是,则节点直接执行该命令;如果不是,则节点会向客户端返回一个 MOVED 错误,指引客户端转向(redirect)至正确的节点,并再次发送之前的命令。

节点数据库的实现

集群节点保存键值对、以及对键的过期时间的处理方式与单机 Redis 服务完全相同,唯一的区别就是,节点只能使用 0 号数据库,而单机服务则没有这种限制。另外,除了将键值对保存到数据库中,节点还会使用 clusterState 结构中的 slots_to_keys 跳跃表来保存槽与键之间的关系。通过在 slots_to_keys 跳跃表中记录各个键所属的槽,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作。例如 CLUSTER GETKEYSINSLOT <slot> <count> 命令可以返回最多 count 个属于 slot 槽的数据键,而这个命令就是通过遍历 slots_to_keys 跳跃表来实现的。

重新分片

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

Redis 集群的重新分片操作由集群管理软件 redis-trib 负责执行,在重新分片完成后,新的指派会通过消息发送至整个集群。

复制

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

向一个节点发送 CLUSTER REPLICATE <node_id> 命令可以使该节点成为指定节点的从节点,并开始对主节点进行复制。接收到该命令的节点首先会在自己的 clusterState.nodes 字典中找到 node_id 对应的 clusterNode 结构,并将自己的 clusterState.myself.slaveof 指针指向这个结构,用来记录它所复制的主节点。然后节点会修改自己的 clusterState.myself.flags 属性,关闭 REDIS_NODE_MASTER 标识,打开 REDIS_NODE_SLAVE 标识。最后会向从节点发送 SLAVEOF <master_ip> <master_port> 命令进行复制操作。

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

故障检测

集群中的每个节点都会以每秒一次的频率向其他节点发送 PING 消息,如果接收 PING 消息的节点没有在规定时间内发送 PONG 消息回复,则发送 PING 消息的节点就会将接收 PING 消息的节点标记为疑似下线(probable fail,PFAIL)。如果在一个集群中,半数以上的主节点都将某个主节点报告为疑似下线,那么这个主节点将被标记为已下线(FAIL),将该主节点标记为已下线的节点会向集群广播一条信息,所有收到该信息的节点都会立即将该主节点标记为已下线。

故障转移

当一个从节点发现自己正在复制的主节点进入了已下线状态时,会对下线节点进行故障转移操作,具体来说就是:首先会从复制下线主节点的从节点中选举一个从节点执行 SLAVEOF no one,使其成为新的主节点。然后新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。接着新的主节点会向集群广播一条 PONG 消息,告知集群中的其他节点自己已经由从节点变成了主节点,并已经接管了原本已下线主节点负责的槽。最后新的主节点开始接收和处理自己负责的命令请求,故障转移完成。

参考

《Redis 设计与实现》