10、认识redis的主从复制

认识集群模式

在使用 Redis 主从复制模式的时候,一般会搭建多个从库(Slave),从库只支持读请求的处理,用来分摊主库(Master)的读压力,主库(Master)只有一个,只专注于处理写请求,或者同时支持读写,这样就可以实现读写分离,降低主库的读压力,也实现了读请求在各个 Redis 节点之间的负载均衡。这样,我们就得到了 Redis 主从模式的核心架构,如下图所示:
在这里插入图片描述

Redis 主从模式还解决了单点的问题。Redis 主库在进行修改操作的时候,会把相应的写入命令近乎实时地同步给从库,从库回放这些命令,就可以保证自己的数据与主库保持一致。那么,当主库发生宕机的时候,我们就可以将一个从库升级为主库继续提供服务;当一个从库宕机的时候,主库依旧可以处理写请求,其他从库依旧可以支持读请求,所以并不会影响整个 Redis 服务的读写。所以说,Redis 主从模式解决了 Redis 单点的问题

主从复制原理概述

Redis 中的主从复制协议有多次升级,所以这里我们从 2.8 版本开始一步步介绍 Redis 主从复制协议的升级和优化过程。

在 Redis 2.8 版本之前,主从复制的核心流程如下:
在这里插入图片描述

这里简单描述一下上图的流程。

  • 首先,在从库启动之后,会根据配置主动请求主库,建立网络连接。

  • 连接完成之后,从库会向主库发送 SYNC 命令,发起数据同步的请求。

  • 主库这一侧,在接收到 SYNC 命令之后,会执行 BGSAVE 命令进行 RDB 持久化,在 RDB 持久化执行的这段时间内,主库执行的所有写入命令,都会暂存到主从复制的缓冲区中。

  • 等到 RDB 文件生成好之后,主库会将整个 RDB 文件通过前面建立好的连接,发送给从库。

  • 从库这一侧在完整接收到 RDB文件之后,会加载这个 RDB 文件,这样,从库就有了主库进行 RDB 持久化时的全量数据了。

  • 主库在发送完 RDB 文件之后,还会将主从复制缓存区中的全部写入命令发送给从库,从库在收到这些命令之后,会将其应用到自己的内存数据中。这样,从库与主库的数据就一致了

  • 之后主库再收到客户端发送来的写入命令,除了应用到自身的内存数据之外,还会异步发送给从库,从库也会应用这条写入命令,这样就会保证主从的数据始终一致。

这个功能看起来没有什么问题,但是主库进行 RDB 持久化操作是一个全量扫描 Redis 数据的操作,该操作比较消耗资源和时间。如果在主从库之间网络状况不佳的情况下,主从连接经常出现闪开之后,马上恢复,这样每次都触发主库 RDB 持久化以及 RDB 文件的传输,显然不是我们希望的效果。

部分同步与 PSYNC 命令

为了解决上述问题(主从闪断之后每次都触发主库RDB以及RDB 文件的传输),Redis 2.8 引入了 PSYNC 命令,来支持“部分同步”的能力

这里引入了两个概念,一个是 Replication ID,一个是 Replication Offset。

  • Replication ID 用于唯一标识一个数据集,主从复制的场景中,从库始终复制主库的复制,可以认为它们是一份数据集,所以两者的 Replication ID 一致
  • Replication Offset 表示的一个数据集的状态或者版本,说白了,就是有多少条命令应用到了这个数据集上。在主从复制的场景中,在从库的数据集上执行的命令是始终落后于主库的,所以主库的 Replication Offset 一直大于从库,也就是说从库数据集的版本落后于主库;但是主库上执行的命令,在未来的某个时间点,也会在从库的数据集上执行,也就是说,从库数据集的版本不断在追赶主库,随着从库上执行的命令越来越多,Replication Offset 会不断增加,它的值在未来某个时间点,会变成现在主库的 Replication Offset 值。

这个定义可能有些抽象,我们可以下图这个例子,来说明一下,如下图 T1 时刻所示,主从之间的关系刚刚建立,主库会生成一个 Replication ID,并且同步给从库,从库也会复制这个 Replication ID,后面主从交互的时候,就靠这个 Replication ID 进行互认。另外,T1 时刻主从节点的内存中都没有任何数据,它们的 Replication Offset 也是 0。

到了 T2 时刻,主库收到了一条 SET K1 V1 命令,此时主库的数据集就变成了 K1 → V1,Replication Offset = 1,而此时这条命令还未复制到从库上,从库还处于 Replication Offset = 0 的状态。到了 T3 时刻,SET K1 V1 这条命令复制到了从库,从库就追上了主库,变成了 Replication Offset =1 的状态。
在这里插入图片描述

通过这个例子,我们可以很明显地看出,通过 Replication ID 和 Replication Offset 的组合,就可以确定一个数据集的版本。

弄清了 Replication ID 和 Replication Offset 这两个概念的本质之后,我们就可以开始来看 Redis 2.8 引入的部分同步功能了,它的核心流程如下。

  1. 从库与主库网络连接建立成功之后,从库不会再发送 SYNC 命令,而是发送 PSYNC 命令来发起部分同步请求,在这个请求中,会包含一个数据集的唯一标识,也就是 Replication ID 以及从库数据集的状态,也是 Replication Offset。
  2. 主库在收到 PSYNC 命令之后,会先检查请求中的 Replication ID 是否与当前主库一致。如果 Replication ID 一致,会继续检查请求中的 Replication Offset 是否还在主从复制缓冲区中。主从复制缓冲区是主库专门为主从复制开辟的一块命令缓冲区,用来缓存主库已经执行、但未发送到从库的命令。

如果这个偏移量对应的命令还在主从复制缓冲区中,说明从库落后的数据并不多,从库通过执行下图蓝色区域的命令就可以追上主库。这个时候,从库只需要从自己的 Replication Offset 位置,也就是下图中的箭头位置,开始同步命令即可,这种同步方式也称为“部分同步”:

在这里插入图片描述

如果这个偏移量对应的命令已经不在主从复制缓冲区中了,说明从库落后的数据非常多了,需要进行一次全量同步,也就回落到之前的方案:先进行 RDB 持久化,然后同步主从复制缓冲区中的命令。

如果 PSYNC 请求中携带的从库 Replication ID 与当前主库的 Replication ID 不一致,需要触发一次全量同步。主从 Replication ID 不一致有两种可能:

  • 一种可能是主库可能已经发生了更换,比如,原来的主库宕机了,一个从库提升为新主库,这个时候,新主库会生成一个新的 Replication ID;
  • 还有一种可能是从库是重启的 Redis 节点,这个时候,会重新生成 Replication ID,也就是数据集和当前主库的不一致。

此次全量同步结束之后,主库会将自己的 Replication ID 同步给从库,主从的 Replication ID 也就一致了,之后如果从库再次触发 PSYNC 命令,带的就是此次返回的 Replication ID。

PSYNC2 优化

虽然 PSYNC 命令可以解决主从库之间闪断后恢复的问题,但是在下面两个场景中,依旧会触发

  1. 全量同步:一个是从库出现重启之后,即使主从库的数据依旧保持一致,但由于从库存储的 Master Replication ID 丢失了,按照上述 PSYNC 的机制,还是会触发一次全量;

  2. 二是主从切换、从库提升为主库的场景,因为新主库会重新生成一个 Replication ID,与上一任主库的 Replication ID 不一致,所以会导致剩余全部从库进行一次全量同步。

这两个场景下,即使主从库的数据集完全一致,也要进行全量同步,显然是一种浪费资源的行为。在 Redis 4.0 版本中,将 PSYNC 升级到了 PSYNC 2,主要作用体现在两个方面。

  1. 在 Redis 服务关闭的过程中,会进行一次 RDB 持久化,此时会将内存中维护的 Replication ID 和 Replication Offset 写入到 AUX(rdb格式) 部分中。在 Redis 重启过程中,会加载这个 RDB 文件,也就会从 AUX 部分恢复原来的 Replication ID 和 Replication Offset ,如果恢复的这两个值依旧符合前面介绍的部分同步的条件,就无需进行全量同步了,只需要进行部分同步即可
  2. 为了避免主从切换带来的、不必要的全量同步,在 Redis 4.0 中,从库会存储上次同步的主库的 Replication ID(也被称为 Secondary ID)以及 Replication Offset ,同时,从库也会像主库那样,开辟一个主从复制缓冲区,里面存的数据就是近期从主库复制过来的命令

当主库发生宕机的时候,会有一个从库升级为主库,新升级上来的主库就会重新生成一个 Replication ID(也被称为 Main ID),同时保存 Secondary ID。这样,其他从库发送 PSYNC2 命令时,新主库会拿 PSYNC2 请求中的 Replication ID 同时与 Main ID 和 Secondary ID 进行比较,它与两者中任意一个相同,就算检查通过。同时,新主库已经同步了上一任主库的主从复制缓冲区,只要其他从库的 Replication Offset 落到主从复制缓冲区内,就可以进行部分同步。部分同步都完成之后,新升级的主库会将自己的 Main ID 同步给各个从库,各个从库将 Replication ID 修改为新主库的 Main ID。

有小伙伴可能会问,为什么从库升级成新主库之后,要重新生成 Replication ID 呢?我们一直沿用 Secondary ID 不就可以了吗?这主要是因为旧主库下线,并不一定是因为宕机,也可能是因为网络隔离,如果新主库不自己生成新 Replication ID,而是沿用 Secondary ID,那在网络隔离恢复之后,就会有两个主库同时存在,我们也无法判断当前的从库复制的是哪个主库,毕竟 Replication ID 值都一样。

主库下的主从复制

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

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

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

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

从库在收到三条 REPLCONF 命令的响应之后,才会发送 PSYNC 命令。

Redis 中的主从复制协议有多次升级,所以这里我们从 2.8 版本开始一步步介绍 Redis 主从复制协议的升级和优化过程。

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

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

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 中,如果存在,就进行部分同步;如果不存在,就进行全量同步。
在这里插入图片描述

replication backlog: 它是为了从库断开之后,如何找到主从差异数据而设计的环形缓冲区,从而避免全量复制带来的性能开销。如果从库断开时间太久,repl_backlog_buffer环形缓冲区被主库的写命令覆盖了,那么从库连上主库后只能乖乖地进行一次全量复制,所以repl_backlog_buffer配置尽量大一些,可以降低主从断开后全量复制的概率。而在repl_backlog_buffer中找主从差异的数据后,如何发给从库呢?这就用到了replication buffer。

replication buffer: Redis和客户端通信也好,和从库通信也好,Redis都需要给分配一个 内存buffer进行数据交互,客户端是一个client,从库也是一个client,我们每个client连上Redis后,Redis都会分配一个client buffer,所有数据交互都是通过这个buffer进行的:Redis先把数据写到这个buffer中,然后再把buffer中的数据发到client socket中再通过网络发送出去,这样就完成了数据交互。所以主从在增量同步时,从库作为一个client,也会分配一个buffer,只不过这个buffer专门用来传播用户的写命令到从库,保证主从数据一致,我们通常把它叫做replication buffer。

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 使用共享缓冲区优化后,主从复制的结构图:
在这里插入图片描述

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

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 是个稀疏索引

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 的核心结构:
在这里插入图片描述

部分同步

首先,会检查 从库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 自然也就无法继续给从库更多的数据来进行部分同步,需要触发一次全量同步。

在这里插入图片描述

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

更新从库 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 响应是立刻写回给从库的,而不是先写入缓冲区中等待 IO 线程写回。

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

在这里插入图片描述

个人公众号: 行云代码

参考文章

主从复制核心原理剖析

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

uncleqiao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值