在前面的章节中,我们已经详细介绍了 Redis 主从复制的核心原理,以及从库视角下主从复制的核心实现。从本节开始,我将和小伙伴们一起,分析一下主库视角下的主从复制,这样整个主从复制的实现就完整了。

在从库发起建连操作时,主库是无法立刻识别出该建连请求是来自从库的,会将其作为一个普通客户端进行处理,为其创建对应的 client 实例。这里注意 client 中的 replstate 字段,它记录了该从库状态的变更。在下图中,展示了主库与从库进行握手的核心流程,最右侧就是从库对应的 client->replstate 状态的变化流程:

image.png

在从库与主库建立连接之后,从库会向主库发送 PING 和 AUTH 命令进行探活和鉴权,此时从库在主库眼中与普通客户端无异,主库会正常地进行鉴权和响应。

接下来,从库会连续发送三条 REPLCONF 命令,将从库的 ip、port 以及从库支持的能力告知主库,在主库侧会根据 REPLCONF 命令的参数更新不同的字段,例如:

  • 主库会将从库端口号记录到 client->slave_listening_port 字段中;
  • 主库会将从库的 ip 记录到 client->slave_addr 字段中;
  • 主库会将从库支持的能力记录到 client->slave_capa 字段中,其中每一位标记一种能力,最低位标记从库结束符方式传输 RDB 数据,次低位表示从库是否支持 PSYNC2 协议。

从库在收到三条 REPLCONF 命令的响应之后,才会发送 PSYNC 命令,主库处理 PSYNC 命令的逻辑位于 syncCommand() 函数中,其中会先检查当前是否处在 Coordinated failover 场景中,例如,当前 PSYNC 命令是否携带了 failover 参数、主库是否处于 FAILOVER_IN_PROGRESS 状态。另外,在 Sub-Slave 复制场景中,从库相对于 Sub-Slave 来说,就是主库,这里会检查从库是否与真正的主库断开了主从复制连接。

主从复制的 Coordinated Failover 是在 6.2.0 版本中引入的功能,与后面将要介绍的 Cluster Failover 以及 Sentinel Failover 功能类似,我们会将其作为一个单独的专题进行介绍,这里先按下不表。

通过上述检查之后,主库会执行 masterTryPartialResynchronization() 函数,尝试进行部分同步。

主从复制中缓冲区的设计演化

在开始介绍部分同步之前,我们先来介绍一下部分同步以及后续同步过程中涉及到的主从复制缓冲区的设计。

在前文中,我们一直在用一个比较笼统的词“主从复制缓冲区”去描述主库缓存命令的缓冲区。这里我们展开详细说明一下这块的设计。

在 Redis 2.8 之前,主库执行完命令之后,会直接把命令写到从库 client 中的返回缓冲区,然后发送到从库,这个结构如下图所示,其中从库 client 中 buf 和 reply 组成的这个缓冲区,就是很多文章中说的 replication buffer

image.png

介绍 Redis 时间事件的时候提到,clientsCron() 中会检查每个 client 占用空间,一旦超过指定的阈值,就会断开连接并销毁 client 实例,这主要是防止对端长时间不读取数据,导致 Redis 打满的情况。默认配置如下,正常客户端的 client 内存占用是没限制的,因为正常客户端会是 Request-Response 模式交互方式,不太可能出现响应堆积的情况。

1
2
3
4
5
client-output-buffer-limit normal 0 0 0

client-output-buffer-limit replica 256mb 64mb 60

client-output-buffer-limit pubsub 32mb 8mb 60

但是,主从模式下,replication buffer 需要存储较长一段时间内,主库执行的全部修改命令,比如在进行全量同步的时候,replication buffer 需要存储主库生成 RDB + 传输 RDB + 从库加载 RDB这三个时间段内产生的全部日志,如果 replication buffer 配置小了,就会导致主从连接断开,重连,无限循环下去。

Redis 2.8 中,为了支持 PSYNC 特性,又引入了一个 replication backlog 的缓冲区,也就是很多文章中说的复制积压缓冲区,主从复制的架构演进成下图所示的结构。当主库执行完写操作之后,不仅会将相应的更新命令发送到从库 client 的 replication buffer 中,还会写入到 replication backlog 这个缓冲区中。backlog 不会一直缓存所有命令,而是会定期丢掉历史命令。在从库发送 PSYNC 命令的时候,会携带 Replication Offset,主库会检查 Replication Offset 对应的命令是否还在 backlog 中,如果存在,就进行部分同步;如果不存在,就进行全量同步。

image.png

Redis 7 版本之后,backlog 缓冲区使用一个 char* 数组(redisServer.repl_backlog 字段)来表示,但是这一实现存在一个性能问题,多个从库在同步同一个主库的场景中,主库需要把同一条命令复制多份,然后写入到不同从库的 replication buffer,还要写一份到 backlog 缓冲区中,这样显然比较浪费内存。

在 7.0 版本中,Redis 对上述问题进行了相关优化,采用了共享缓冲区的设计。使用redisServer.repl_buffer_blocks 字段维护了一个全局的、公共的 replBufBlock 列表,replBufBlock 是真正存储主从复制命令的地方。在主库执行完命令之后,只会将修改命令写入到 repl_buffer_blocks 队尾的 replBufBlock 实例,每个 replBufBlock 实例中最多可以存储 16KB 的数据,当队尾的 replBufBlock 满了之后,会创建新的 replBufBlock 并入队。

下图展示了 Redis 7.0 使用共享缓冲区优化后,主从复制的结构图:

image.png

replBufBlock 结构体的定义如下,其中 buf 数组是真正存储主从复制数据的地方;repl_offset 字段记录了 buf 中第一个字节对应的 Replication Offset;refcount 字段记录了当前这个 replBufBlock 被引用的次数,当它降为 0 的时候,就是它被回收的时候。

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct replBufBlock {

int refcount; // 当前有多少个client在使用这个replBufBlock实例

long long id; // replBufBlock实例的唯一标识

long long repl_offset; // 当前replBufBlock存储的起始Replication Offset

size_t size, used; // 记录了下面buf数组的长度和已使用字节数

char buf[]; // buf是真正存储主从复制数据的地方

} replBufBlock;

在主库向从库发送命令的时候,直接从 repl_buffer_blocks 队列中定位到目标的 replBufBlock 实例,然后让相应从库的 client->ref_repl_buf_node 指针(listNode* 类型),指向这个 replBufBlock 实例即可。在创建从库对应的 client 实例的时候,主库会在其 flags 中添加 CLIENT_SLAVE 标记位,用来标识它是与从库交互的 client 实例,在后续 IO 线程中,就会通过 CLIENT_SLAVE 标记位识别从库 client,并发送其 ref_repl_buf_node 字段中存储的数据,client 中的 ref_block_pos 字段记录了这个 replBufBlock 中数据的发送情况,发送完当前的 replBufBlock 之后,ref_repl_buf_node 就会指向下一个 replBufBlock 继续发送。这样,就不用为每个从库复制一份数据了,节省了大量的内存开支。

另外,replication backlog 缓冲区也复用了 repl_buffer_blocks 列表中的数据。在 Redis 7.0 中, redisServer 中的 repl_backlog 字段不再一个 char 指针,而是变成了一个 replBacklog 指针,指向了一个 replBacklog 实例,replBacklog 结构体的定义如下。有的时候,repl_buffer_blocks 会缓存非常多的修改命令,如果我们只按照列表方式维护 replBufBlock 实例,在 backlog 场景下,是需要根据从库发来的 Replication Offset 查找 replBufBlock 的,这就会比较耗时。为了解决这个问题,replBacklog 在 blocks_index 字段中维护了一个 rax 树,它的 Key 是 replBufBlock 的起始 Replication Offset,Value 是相应的 replBufBlock 实例,这样形成了一个索引,但是 blocks_index 并不是对每个 replBufBlock 实例都进行索引,而是每隔 64 个 replBufBlock 实例才会创建一个索引,也就是 blocks_index 是个稀疏索引

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct replBacklog {

listNode *ref_repl_buf_node; // 指向当repl_buffer_blocks列表的第一个节点

size_t unindexed_count; // 当前已经累计了多少个未进行索引的replBufBlock

rax *blocks_index; // 稀疏索引

long long histlen; // 整个replBacklog实际存储的字节数

long long offset; // 整个replBacklog存储的第一个字符对应的Replication Offset

} replBacklog;

这里的 unindexed_count 字段用来记录当前已经累计了多少个未进行索引的 replBufBlock,一旦累计到 64,就会将最新的 replBufBlock 添加到稀疏索引中,并清零。histlen 字段记录了整个 replBacklog 实际存储了多少数据(单位是字节),offset 字段记录了整个 replBacklog 中的第一个字符对应的 Replication Offset。下图展示了 replBacklog 的核心结构:

image.png

replBufBlock 这个优化的相关 PR 链接是 https://github.com/redis/redis/pull/9166 ,感兴趣的小伙伴可以深入了解一下。

部分同步

介绍完主从复制中缓冲区设计的演化之后,我们回到 masterTryPartialResynchronization() 函数,正式开始介绍主库是如何进行部分同步。

检查 Replication ID 和 Replication Offset

首先,masterTryPartialResynchronization() 中会检查 PSYNC 命令携带的 Replication ID 是否正确,这里会将这个值分别与当前主库记录的 Main ID(也就是 redisServer.replid 字段)以及 Secondary ID(也就是 redisServer.replid2 字段)进行比较。与前者匹配成功,表示从库已经开始与主库进行过同步,中间可能出现过网络闪断重连等问题,才导致了此次重新握手;与后者匹配成功,表示出现了从库之前一直与上一任主库进行同步,这是第一次与当前主库进行同步。

在 PSYCN 命令中的 Replication ID 与 Secondary ID 匹配时,还要额外比较 PSYNC 命令携带的 Replication Offset 与当前主库的 redisServer.second_replid_offset。在当前 Redis 节点由从库提升为主库时,不仅会将上一任主库的 Replication ID 记录到 replid2 字段中,还会将自身与上一任主库同步的 Replication Offset 记录到 second_replid_offset 字段中。

有的小伙伴可能会问,为什么要比较 Secondary Replication Offset 呢?答案在下图展示的这种特殊场景中,在主库切换的这个时刻,从库 B 的复制速度已经超过了从库 C,但是从库 C 被升级为了主库,此时新一任主库 C 自然也就无法继续给从库更多的数据来进行部分同步,需要触发一次全量同步。

image.png

接下来,检查主库的 backlog 缓冲区中是否包含从库 Replication Offset 对应的数据,如下图所示,replBacklog 指向了 repl_buffer_blocks 列表的第一个节点,所以整个 repl_buffer_blocks 就构成了逻辑上的 backlog 缓冲区。只要 PSYNC 携带的 Replication Offset 落到 repl_buffer_blocks 队列中即可。

image.png

更新从库 Client 状态

完成 Replication ID 和 Replication Offset 的检查之后,主库会更新该从库对应 client 的状态相关字段,比较关键的是下面三个字段。

  • 在 flags 字段中设置 CLIENT_SLAVE 标记,表示该 client 用于与从库进行交互。前面共享缓冲区的设计中也提到,主库可以通过 CLIENT_SLAVE 标记感知到从库 client,才会发送 client-> ref_repl_buf_node 这个 replBufBlock 块中存储的修改命令。

  • 更新 replstate 状态为 SLAVE_STATE_ONLINE,表示对应从库正常上线。

  • 更新 repl_ack_time 字段为当前时间戳,记录最后一次与从库进行交互的时间戳。

同时,主库还会将从库 client 实例添加到 redisServer.slaves 列表中,这个列表中记录了全部从库对应的 client 实例。

发送数据

最后,主库会向从库返回 +CONTINUE 响应,这里会根据从库是否支持 PSYCN2 协议决定 +CONTINUE 响应是否会携带当前主库的 Replication ID。注意,这里的 +CONTINUE 响应是调用 connWrite() 函数立刻写回给从库的,而不是先写入缓冲区中等待 IO 线程写回。

完成 +CONTINUE 响应的发送完之后,主库就会开始计算 backlog 缓冲区中,哪些数据是要返回给这个从库的,这部分实现在 addReplyReplicationBacklog() 函数中,下图展示了该函数查找目标 replBufBlock 块的逻辑。addReplyReplicationBacklog() 函数首先根据 replBacklog 中的稀疏索引,定位到包含 PSYNC Replication Offset 的 replBufBlock 节点(也就是下图的 replBufBlock5),并将该节点记录到从库 client 的 ref_repl_buf_node 字段中,然后通过 PSYNC Replication Offset 减去 replBufBlock5 起始的 Replication Offset,就得到了从库 client-> ref_block_pos 的值。此时,要发送给从库的数据就是下图中红色的部分。

image.png

到此为止,主库侧部分同步的处理逻辑就介绍完了。后续主库向从库发送数据的逻辑,与正常情况下的主从复制逻辑一样,会在下一节展开介绍。

总结

在这一节中,我们先是从主库的视角,介绍了主从复制中主库的状态变化;然后介绍了主从复制中缓冲区的演化过程,着重分析了 Redis 7 中引入的共享缓冲区设计;最后,详细分析了主从部分同步过程中,主库需要完成的几个关键操作,其中包括:校验 Replication ID 和 Replication Offset、从库 Client 状态变更以及最后发送数据的逻辑。

下一节,我们将继续站在 Redis 主库的角度,介绍主从全量同步的过程。