认识集群模式
在使用 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 引入的部分同步功能了,它的核心流程如下。
- 从库与主库网络连接建立成功之后,从库不会再发送 SYNC 命令,而是发送
PSYNC 命令
来发起部分同步请求,在这个请求中,会包含一个数据集的唯一标识,也就是 Replication ID 以及从库数据集的状态,也是 Replication Offset。 - 主库在收到 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 命令可以解决主从库之间闪断后恢复的问题,但是在下面两个场景中,依旧会触发
-
全量同步:一个是从库出现重启之后,即使主从库的数据依旧保持一致,但由于从库存储的 Master Replication ID 丢失了,按照上述 PSYNC 的机制,还是会触发一次全量;
-
二是主从切换、从库提升为主库的场景,因为新主库会重新生成一个 Replication ID,与上一任主库的 Replication ID 不一致,所以会导致剩余全部从库进行一次全量同步。
这两个场景下,即使主从库的数据集完全一致,也要进行全量同步,显然是一种浪费资源的行为。在 Redis 4.0 版本中,将 PSYNC 升级到了 PSYNC 2
,主要作用体现在两个方面。
- 在 Redis 服务关闭的过程中,会进行一次 RDB 持久化,此时会将内存中维护的 Replication ID 和 Replication Offset 写入到 AUX(rdb格式) 部分中。在 Redis 重启过程中,会加载这个 RDB 文件,也就会从 AUX 部分恢复原来的 Replication ID 和 Replication Offset ,如果恢复的这两个值依旧符合前面介绍的部分同步的条件,就无需进行全量同步了,只需要进行部分同步即可
- 为了避免主从切换带来的、不必要的全量同步,在 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 的值。此时,要发送给从库的数据就是下图中红色的部分。
个人公众号: 行云代码