无题
在上一节中,我们已经详细介绍了复制缓冲区的设计演进过程以及主库视角下部分同步的核心。在这一节,我们继续来分析一下主库视角下全量同步实现。
全量同步
通过上一节的分析我们知道,如果主从之间能进行部分同步,需要检查 Replication ID、Replication Offset 等一系列条件是否成立,如果部分同步的条件不成立,就会进入全量同步的逻辑。在进行全量同步的时候,主库首先执行与部分同步类似的状态更新操作:
- 将 client->replstate 状态为 WAIT_BGSAVE_START,表示主库要为全量同步执行一次 RDB 持久化。
- 向 client->flags 中设置 CLIENT_SLAVE 标记,标识这是一个从库 client 实例。
- 将 client 添加到 server.slaves 列表中。
如果当前主库之前的 backlog 缓冲区一直为空,那从库必然只能进行全量同步,此时会初始化 replBacklog、在 redisServer.replid 字段中填充新生成的 Replication ID 、清空 Secondary ID 和 Secondary Replication Offset(分别对应 redisServer 中的 replid2 字段和 second_replid_offset 字段)。
RDB 生成的场景分类
完成上述状态更新之后,主库会根据 RDB 子进程执行情况、主从传输 RDB 的方式以及其他优化配置进入不同的分支,如下图所示:
首先来看分支 4 ,这是最简单的一个分支。当有一个从库已经触发了主库生成 RDB 文件的时候,当前从库只需要等待这个 RDB 文件生成完成,到时候主库会将该 RDB 文件的数据同时分发到这两个从库,实现复用的效果。
再来看分支 1,这里涉及到无磁盘同步,我们知道从库是可以实现无磁盘加载 RDB 数据的功能,相应的主库也可以通过 repl-diskless-sync 配置开启无磁盘同步 RDB 的能力,这减少了主从两次落盘操作,在磁盘 IO 较慢的场景中可起到一定的优化作用。
但是,无磁盘同步的场景中,无法像 RDB 文件那样复用一份 RDB 文件,在不同时刻发送给多个从库,你可以考虑一种场景,主库正在与一个从库进行无磁盘同步的时候,又有一个从库来进行全量同步,这就会导致主库又要进行一次 RDB 持久化,这显然很消耗资源。
为了解决这个问题,Redis 为主库添加了 repl-diskless-sync-delay 配置(默认值为 5 秒)用来指定无磁盘 RDB 持久化的延迟时间,只要所有从库在延迟时间范围内发起全量同步,就只会触发一次 RDB 持久化,否则可能触发多次 RDB 持久化。如下图所示:
了解了这些知识之后,剩余分支分析起来就比较简单了。例如,分支 2 就是没有配置 repl-diskless-sync-delay 的场景,此时,主库会退化到为每个从库的全量同步请求,触发一次 RDB 持久化,然后再传给从库;分支 3 就是分支 4 中提到的触发基于磁盘同步的流程;分支 5 是前一次 RDB 持久化是无磁盘同步触发的,现在要在磁盘上生成的 RDB 文件,所以无法复用,只能排队等待。
无磁盘同步
明确上述五个分支的逻辑之后,我们挑出其中比较核心的逻辑进行分析。
首先来看无磁盘同步,它的核心实现 rdbSaveToSlavesSockets() 函数。在rdbSaveToSlavesSockets() 函数中会创建两组管道用于主子进程传输 RDB 以及状态信息,如下图所示:
从上图中可以看到,rdbSaveToSlavesSockets() 函数首先会创建两个管道:一个是 rdb 管道,用来实现子进程将 RDB 数据发送给主进程;二是 exit 管道,用于主进程通知子进程正常退出。
然后,主库会向等待全量同步的从库返回 +FULLRESYNC 响应,这些从库对应的 client->replstate 状态此时还是 WAIT_BGSAVE_START,接下来就会立刻将其修改为 WAIT_BGSAVE_END,表示等待 RDB 持久化的阶段结束了,下面将正式开始 RDB 的传输。整个这部分逻辑封装在 replicationSetupSlaveForFullResync() 函数。注意,如果存在多个从库并发进行全量同步,这里会向这些从库同时返回 +FULLRESYNC 响应。
接下来,创建子进程执行 RDB 持久化,RDB 数据的生成过程和具体格式在前面已经详细介绍过了,这里不再展开了。这里子进程产生的 RDB 数据不再写入到磁盘文件中,而是写入到上面创建的 rdb 管道中。主进程会监听 rdb 管道的可读事件,相应的回调函数是 rdbPipeReadHandler() 函数,其中会将读取到的 RDB 数据,先缓存到 redisServer.rdb_pipe_buff 缓冲区(默认长度 16KB)中,然后分别向从库发送。如下图所示:
每个从库的处理能力以及各个主从网络连接的状态各不相同,所以主库在发送 rdb_pipe_buff 缓冲区数据时,有可能会出现向一个从库发送成功,向另一个从库发送失败的情况。如上图所示,在向从库 B 发送数据的时候,connection B 连接已经阻塞,无法写入更多数据了,而 connection A 连接则可以将 rdb_pipe_buff 缓冲区中全部数据写入。
为了避免覆盖 rdb_pipe_buff 缓冲区中未发送的数据,主进程不再监听 rdb 管道上的可读事件,也就是不再读取 rdb 管道中的数据,转而开始监听 connection B 连接上的可写事件。当 connection B 可写的时候会继续将 rdb_pipe_buff 缓冲区中未发送完的数据,发送给这个较慢的从库 B 。等到 rdb_pipe_buff 缓冲区中数据,发送到全部从库之后,主进程才会继续开始监听 rdb 管道可读事件。
在无磁盘同步过程中,主库会使用从库 client 中的 repldboff 字段,记录 rdb_pipe_buff 缓冲区往指定从库发送数据的进度,如上图所示,通过该字段与 rdb_pipe_buff 缓冲区中存储的数据长度的比较,就可确认 rdb_pipe_buff 中的数据是否已经发送完了。
介绍完主线程与 rdb 管道以及从库的交互之后,我们来看子进程的逻辑。子进程在将全部 RDB 数据发送给主进程之后会阻塞等待,主进程在读取完 RDB 管道中的全部数据之后,会通过 exit 管道通知子进程安全退出。通过前面的学习我们知道,RDB 子进程退出的回调函数中会调用到 backgroundSaveDoneHandler() 函数,针对无磁盘同步的场景,该函数会先释放前面使用到的 rdb_pipe_buff 缓冲区、管道等资源,然后将所有 WAIT_BGSAVE_END 状态的从库 client 修改为 ONLINE 状态。
至此,无磁盘同步才算真正完成。
在前面介绍从库侧主从同步逻辑时提到,在从库完成无磁盘同步之后,会发送一条 REPLCONF ACK {Replication Offset} 命令,主库收到该命令时,会将从库 client 添加到 IO 线程的处理队列中,后续 IO 线程会根据从库的 Replication Offset,将 backlog 中积攒的命令发送给从库。这样,从库才算正式上线。
这里补充前面简单带过的一个知识点,为了一次性 RDB 持久化能够传输给多个从库的全量同步,主库不会在收到第一个全量同步请求时立刻启动无磁盘同步,而是延迟等待 repl-diskless-sync-delay 配置指定的时长(默认等待 5 秒),在这个窗口时间内,可能就会有其他从库来进行全量同步。在 replicationCron() 函数中会定时检查延迟全量同步的条件是否满足,如果满足会立刻触发上述无磁盘同步逻辑,相关的调用栈如下图所示:
基于磁盘的同步
下面我们来看磁盘同步的相关内容。
在磁盘同步模式下,子进程会将 RDB 数据写入到 dbfilename 配置项指定的文件中,这部分逻辑与前文介绍的 BGSAVE 命令触发的 RDB 持久化逻辑一模一样,这里就不再展开介绍了。
在 RDB 文件生成完成之后,主进程会将从库对应的 client-> replstate 切换成 WAIT_BGSAVE_END 状态,并向从库返回 +FULLRESYNC 响应。
生成 RDB 文件是一个耗时比较长的操作,这段时间内可能有其他从库来执行全量同步,这就形成了并发,此时只有第一个从库能够触发 RDB 子进程生成 RDB 文件,其他并发从库的 replstate 状态会直接变更为 WAIT_BGSAVE_END,表示从库正在等待 RDB 文件生成结束,同时返回给从库 +FULLRESYNC 响应。等到主库生成完 RDB 文件之后,会复用同一份 RDB 文件完成全量同步。
接下来,主进程在检查到 RDB 子进程结束之后,也是会调用 backgroundSaveDoneHandler() 函数来更新相关状态,这些都是前面 RDB 持久化小节中介绍过的逻辑,不再重复。除此之外,这里针对磁盘同步的场景,会额外调用 updateSlavesWaitingBgsave() 函数,触发 RDB 文件的发送。updateSlavesWaitingBgsave() 函数会过滤出所有处于 WAIT_BGSAVE_END 状态的从库,它们都是可以复用这份 RDB 文件进行全量同步的从库。然后,主库就可以监听将这些从库 client 的可写事件,相应的回调函数设置为 sendBulkToSlave()。这里还会将 client->replstate 切换为 SEND_BULK 状态,表示主从正在进行 RDB 的传输。
当从库 client 上发生可写事件时,就会触发上面注册的 sendBulkToSlave() 函数,其中会先通过 lseek() 函数,将文件读取偏移量定位到 client->repldboff 字段的位置,注意这里与无磁盘同步的区别,这里从库的 client->repldboff 字段表示的是已经向该从库发送了多少字节的数据,而不再是缓冲区的使用情况。然后,sendBulkToSlave() 函数会开始读取 RDB 文件的数据到一个临时缓冲区中,然后调用 connWrite() 函数将缓冲区中的数据发送给从库,发送操作完成之后,会递增 client->repldboff 值,直至整个 RDB 文件的数据全部发送给了从库。
磁盘同步完成 RDB 文件发送之后的逻辑,与无磁盘同步的逻辑基本一致:先将从库对应的 client->replstate 状态切换为 ONLINE ,表示从库上线;然后将从库 client 添加到 IO 线程的处理队列中,后续 IO 线程会根据从库的 Replication Offset,将 backlog 中积攒的命令发送给从库。这样,从库才算正式上线。
总结
在这一节中,我们重点介绍了 Redis 主从全量同步过程中,主库需要做的事情。
首先,我们详细分析了全量同步触发之后,主库子进程在不同场景中,会以不同的方式生成 RDB 数据;之后,又深度分析了无磁盘同步这一优化点的核心逻辑,以及基于磁盘的全量同步逻辑。
在下一节中,我们将继续从主库视角,介绍 Redis 主从复制过程中,命令是如何从主库发送到从库的。
