「腾讯云 NoSQL」技术之 Redis 篇:Redis 主从复制机制的原理与演进路线

导语
Redis 是一个高性能的内存键值数据库,常用于缓存、分布式锁、会话存储以及排行榜等高并发场景,凭借极快的读写速度和丰富的数据结构(如字符串、哈希、列表、集合、有序集合等)在互联网业务中被广泛采用。随着业务规模的扩大,单节点 Redis 很难同时兼顾性能与可靠性,这就催生了对数据冗余与高可用的需求。为了解决单点故障、提升读吞吐能力并支持数据迁移,Redis 提供了主从复制(Replication)机制:通过将主节点的数据异步复制到一个或多个从节点,实现读写分离和多副本容灾。本文将系统地剖析 Redis 主从复制的工作原理,并梳理其在各个版本中的演进过程。
作者:腾讯云 NoSQL 团队-李鸿瑞
1 主从复制简介
图 1、Redis 主从复制示意图
Redis 主从复制(Replication)是一种核心的数据冗余和高可用机制。简单来说,就是可以给一个主节点连上几个副本,每个副本与主节点拥有的数据完全一致。主从复制主要用于解决以下问题:
数据冗余与容灾
通过将主节点(Master)的数据异步复制到多个从节点(Replica or Slave),避免单点故障导致的数据丢失。即使主节点宕机,从节点仍可提供数据备份,保障业务连续性。
读写分离,提高读并发能力
主节点负责处理写操作(如 SET、DEL),从节点负责处理读操作(如 GET)。通过将读请求分摊到多个从节点,显著提升系统的读性能和吞吐量。
数据迁移
Redis 主从复制机制实现了节点间的数据同步,且在数据同步期间主节点可以正常处理请求,是一个很好的实例间数据热迁移的方式。
2 全量同步
主从复制对于高可用的意义是显著的,但是,当陌生的从节点申请挂从时,主节点首先要考虑的是如何把自己的数据完整地、并且尽可能快速地发送给从节点。并且在全量同步期间,主节点还必须同时处理新的读写请求,在这期间发生的数据变动也必须传递到从节点。如此看来,实现高可用的道路上注定困难重重,接下来,让我们来探究一下 Redis 是如何巧妙地化解难题,实现主从节点间全量同步的。
2.1 全量同步的实现
为了将自己当前的数据完整地发给从节点,主节点首先会生成一个 RDB 文件,它是主节点当下的数据快照,存储了主节点在该时刻的所有数据。为了避免 RDB 的生成影响主进程的性能,主节点会执行 BGSAVE,fork 出一个子进程来负责 RDB 快照的生成。待 RDB 快照生成后,主节点将其发送给从节点,从节点接收后清空本地数据并加载 RDB。
此外,正如前文提到的,主节点上述过程期间的数据变更也必须传递到从节点。为了实现这点,主节点会将 RDB 生成、接收、加载期间的写命令都暂存到自己给副本客户端准备的输出缓冲区中,待从节点加载完 RDB 后发送给从节点。如此一来,从节点便拥有了与主节点一模一样的数据,并且同步期间主节点能照常处理请求。
全量同步的流程如下图所示:
图 2、全量同步流程示意图
从全量同步的过程中,我们不难发现 RDB 文件以及输出缓冲区的处理是整个过程中的重点,我们紧接着来讨论一下 Redis 处理 RDB 文件以及输出缓冲区的一些细节。
2.2 RDB 的生成与传输
RDB 的无盘传输
在 Redis 主从复制的全量同步过程中,传统方式是先将 RDB 文件写入磁盘,然后再从磁盘读取并通过网络发送给从节点,经过了两次磁盘 I/O。这种方式虽然简单,但会带来额外的磁盘 I/O 开销,并且在磁盘性能较差或数据集较大时,可能成为复制的瓶颈。
为了解决这一问题,Redis 从 2.8.18 版本开始引入了无盘传输。在无盘传输模式下,主节点在执行 BGSAVE 生成 RDB 数据时,不再将数据写入磁盘文件,而是直接通过 socket 将 RDB 数据流发送给从节点,从而省去了磁盘写入和读取的开销。
图 3、无盘复制示意图
RDB 复用
如果同时有多个从节点与主节点进行全量同步,主节点需要为每个从节点都生成一份 RDB 吗?答案是只需要一份就好了。Redis 实现了 RDB 的复用机制。如果当前有 BGSAVE 正在进行并且有其他从节点正在等待当前 BGSAVE 结束,则可以复用这次 BGSAVE 生成的 RDB,从而减少 BGSAVE 的次数。其大致流程如下:
图 4、RDB 复用流程
值得注意一点的是,如果使用了给另一个从节点 s 准备的 RDB,那么为了对齐数据,还需要将 s 的输出缓冲区中的写命令也 copy 过来。
2.3 写命令暂存与数据对齐
在全量同步过程中,从节点的数据实际上有两个来源:主节点发来的 RDB 快照以及输出缓冲区中的写命令。Redis 需要保证:
数据对齐:输出缓冲区中的第一条命令正好是 RDB 快照后的第一条命令。
加载顺序正确:先加载 RDB 快照,然后再加载输出缓冲区中的写命令。
Redis 是如何保证上述两个条件的呢?让我们深入源码进行分析。
Redis 的主从复制实现有一套完善的状态机设计,与本小节相关的是如下 4 个状态:
当主节点接收到写命令时,会调用 replicationFeedSlaves 函数,向每个从节点对应的客户端输出缓冲区也写入一份同样的数据:
从 canFeedReplicaReplBuffer 函数的实现中可以看到,只要从节点状态不是 SLAVE_STATE_WAIT_BGSAVE_START,写命令就会通过 addReply 系列函数添加到从节点对应的客户端输出缓冲区中。这意味着在以下状态下,命令都会被缓冲:
SLAVE_STATE_WAIT_BGSAVE_END(等待 RDB 生成完成)
SLAVE_STATE_SEND_BULK(发送 RDB 中)
SLAVE_STATE_ONLINE(在线状态)
这保证了在主节点开始为某个从节点生成 RDB 快照之前,新的写命令不会进入其对应的客户端输出缓冲区中;主节点一旦开始生成 RDB,之后的写命令便会进入输出缓冲区。并且 Redis 的事件处理是单线程模型,执行命令必然在从节点状态变化的前或者后,而不会在状态变化期间并发执行。所以严格的状态控制确保了输出缓冲区中的第一条命令正好是 RDB 快照后的第一条命令。
那数据的加载顺序是如何保证的呢?这依赖于主节点对发送顺序的控制。在 RDB 快照的生成,传输和加载阶段,主节点的增量写命令都只会缓存在主从连接的输出缓冲区里,不会往 socket 中写入;一直等到从节点加载完 RDB 后,发送 REPLCONF ACK 命令通知主节点,主节点才会开始把输出缓冲区中的增量命令真正写入 socket。我们来看看 Redis 的具体实现。
可以看到,在从节点加载完 RDB 后,会通知到主节点,主节点接到通知后给该从节点的连接安装写处理器 sendReplayToClient,之后输出缓冲区中的写命令就可以发送给从节点了。
总之,数据对齐依赖于严格的状态机控制,而加载顺序依赖于在正确的时间点安装写处理器。本小节分析的核心流程可总结为以下时序图:
图 5、数据对齐与加载顺序控制相关过程时序图
2.4 主从同步状态机
前面已经多次提到过 Redis 通过状态机来实现对主从复制流程的控制。图 6 是 Redis 主从复制状态机的示意图。由于主从复制同时涉及到主节点和从节点两个角色,因此主节点和从节点各有一套状态机。后面会讲到 Redis 的增量同步,它可以大大加快主从同步的速度。
图 6、Redis 主从复制状态机
3 命令传播
3.1 命令传播的实现
全量同步结束后,主从节点就进入了相对稳定的命令传播阶段。如果说全量同步期间主从节点间传递的数据是一波“汹涌的洪流”,那全量同步结束后,传递的数据则是一条“平缓的小溪”。命令传播的方式非常简单——主节点每收到一条写命令,就将该命令发送给每个从节点。
图 7、命令传播
除了将命令发给从节点外,主节点还会将命令发送一份到复制积压缓冲区中(上图中的 backlog,后文会细讲),用于增量同步。
这里稍加深入,介绍一下 Redis 命令的编码格式。Redis 命令的编码使用 Redis serialization protocol (RESP)格式。一条命令如果有 argc 个参数,第 i 个参数的长度为 len[i], 那么转换为 RESP 格式为*
例如命令 SET KEY VALUE 转换为 RESP 格式为
主节点发送给从节点和复制积压缓冲区的命令都为 RESP 格式。
3.2 数据一致性
世界上每一个数据冗余机制都会面临数据一致性问题,Redis 主从复制也不例外。
出于性能方面的考虑,Redis 的命令传播是异步的。当主节点执行完命令,在客户端获得返回值“OK”时,命令可能还没传到从节点,这就产生了主从节点数据不一致的问题。如果此时用户向从节点发送读命令,那么得到的返回结果就是(nil)。用户明明刚写了一个 key,却读不到这个 key。
To be honest,Redis 并不是一个擅长处理数据不一致问题的数据库,这可能是追求高性能而不得不作出的妥协。但 Redis 依旧为了数据一致性做出了一些努力,主要是 min-replicas-to-write 配置和 WAIT 命令。
min-replicas-to-write
min-replicas 功能保证数据一致性的方式是去验证主从节点间网络连接状态是否良好。如果主从之间网络连接延迟较高,那么出现数据不一致现象的概率也越高,此时主节点会拒绝写入。更具体一点,在写操作前,Redis 主节点会检查当前有多少个网络连接状态良好的从节点,只有这些从节点的数量不少于 min-replicas-to-write 时才会允许主节点执行写命令。
网络连接的流畅度由 lag 值来衡量,lag 是主节点距离上一次接收到从节点心跳的时间间隔。例如使用以下配置,主节点在接收到写命令之后,会确保自己至少拥有 3 个 lag 值小于 1s 的从节点才会进行写操作,否则该命令将会被拒绝。
WAIT 命令
如果说 min-replicas-to-write 是内核自动去尽可能维护数据一致性,那么 WAIT 命令就是由用户去强行保证数据一致性。
WAIT 命令的使用方式为
WAIT 命令会持续阻塞 redis-client,直到确认有至少 num_replicas 个从节点与主节点完全同步,或达到指定的超时时间 milliseconds_timeout 时返回。
WAIT 命令的使用示例如下,在一次性向主节点写入 1000000 个 key 后,为了确保主从一致,先运行一次 WAIT 2 10000,结果返回 2。说明两个从节点都已经同步完成,接下来就可以放心地向两个从节点发送读请求啦。
4 Redis 主从复制演进史
自 2009 年 Redis 诞生,到今天 Redis 已更新迭代到 8.x 版本,Redis 主从复制在持续不断地优化演进。无数思维活跃的开源贡献者们向社区奉献他们的智慧,让 Redis 主从复制更快、更稳、更省内存。接下来让我们来细细欣赏历史上 Redis 主从复制机制的几次重要升级。
4.1 Redis 2.8: 增量同步(PSYNC)
我们先来考虑这么一个场景:Redis 从节点因网络原因断开了与主节点的连接,但网络很快就恢复了,从节点在短暂断连后重新连上了主节点。在过去的十几秒内,主节点已经接受了一些写命令并更新了数据。为了与主节点再次同步数据,从节点只好向主节点申请全量同步,收 RDB,加载 RDB,balabala。但事实上,在断连的短暂时间内,从节点只有很少部分数据与主节点不一致,大多数数据都是一样的。此时进行全量同步显然是一个费时费力的操作,有没有一个办法,能让主节点只把从节点缺失的那部分数据发过去就完事儿了呢?
增量同步能做到。它诞生于 Redis 2.8 版本,生来就是短暂断连场景的王者
在介绍增量同步的具体实现前,我们先来讲一下主节点如何判断是否能进行增量同步。判断的依据来源于 replica 发送的 PSYNC 命令中的两个参数:replid 和 offset。
(PSYNC 命令的基本格式为 PSYNC <replid> <offset>)
replid,即 replication id,是数据集的标记。id 一致说明是同一数据集。每个 master 有唯一的 replid,replica 则会继承所属 master 的 replid,标识自己是从哪一个 master 中同步数据。
offset,即偏移量,用于标识数据同步到了哪一步。offset 是源自复制积压缓冲区(replication backlog)的概念,一个偏移量就对应了写命令的一个字节。写命令在复制积压缓冲区中的存储格式为前面介绍过的 RESP。
在介绍了上述两个参数的概念后,我们可以简单分析出增量同步的条件:
master 的 replid 与 psync 命令中的 replid 一致。
pysnc 命令中的 offset 在复制积压缓冲区的范围内。
增量同步实现的核心数据结构是复制积压缓冲区。它是 Redis 主节点维护的一个固定大小的环形数组,用于存储最近一段时间内发送给从节点的写命令数据流。其基本数据结构如下:
当主节点接收写命令并调用 replicationFeedSlaves 时,除了将写命令传播给从节点,还会将写命令存一份到复制积压缓冲区中,调用的函数为 feedReplicationBacklog。
feedReplicationBacklog(void *ptr, size_t len)将 ptr 处的长度为 len 的字节 copy 到 server.repl_backlog 中,并更新相关偏移量。
当 replica 断连一段时间重连后,只需要 master 向 replica 发送 repl_backlog 中落后部分的写命令即可,避免了全量同步的较大开销。例如在图 8 中,用一个圆圈表示复制积压缓冲区,左边的 master 只需要将红色部分的字节发送给从节点即可;而右边 master 的复制积压缓冲区已经写满了一圈并且覆盖到了未同步的数据,因此只能遗憾地进行全量同步。
图 9、复制积压缓冲区示意图[3]
增量同步的流程也非常简单,如下图所示:
图 10、增量同步流程示意图
4.2 Redis 4.0: PSYNC2
Redis 复制在 PSYNC2 前有以下两点问题:
首先是从节点重启导致不必要的全量同步。上一章节谈到过,增量同步依赖 replid 和 offset,目前信息仅存于内存。从节点重启后,内存中的 replid 和 offset 就丢失了,从而无法执行增量同步,被迫进行全量同步,造成资源浪费。
图 11、从节点重启丢失 replid
如上图所示,从节点重启后,replid a 丢失,生成了一个新的 replid b,与主节点不一致,从而只能全量同步,尽管与主节点只有少部分数据不一致。
然后是主从切换导致不必要的全量同步(一主多从场景)。假如主节点意外宕机,其中一个复制偏移量最大的从节点被提升为新的主节点,并生成一个新的 replid,导致剩余从节点与新主节点的 replid 不一致,必须全量同步。具体情况如图 11 所示。
图 12、主从切换后,从节点不认识新主[4]
为解决上述问题,Redis 4.0 设计了 PSYNC2。
从节点重启后的增量同步
要解决从节点重启后丢失 replid 和 offset 的问题,只需要将主节点 ID 和复制偏移量持久化到 RDB 文件中,重启后便可恢复这些信息。没错,就是这么简单直接。
主从切换后的增量同步
主从切换后不能增量同步的原因在于,提主的从节点变成主节点后就“忘了旧主”,因此解决办法就是让从节点提主后依旧“认旧主”。PSYNC2 设计下,提主后的新主节点会记录自己原先的主节点 ID(master_replid2),并保留一段复制积压缓冲区(配套 second_replid_offset)。从节点找新主节点做数据同步时,若其原主节点 ID 与新主节点的 master_replid2 匹配,且偏移量在新主节点的复制积压缓冲区范围内,则可直接执行增量同步。
PSYNC2 下主从复制 replid 的变更流程详见下图。节点 B 在提主后将 A 的 id 记录在 replid2 中。申请同步时,A、C 的 replid a 与 B 的 replid2 a 一致,因此能进行增量同步。同步后 A、C 的 replid 变更为新主的 b。
图 13、PSYNC2 下 replid 变更流程[4]
4.3 Redis 6.0: 无盘加载
Redis 6.0 支持从节点无盘加载来自主节点的 RDB 数据,即无需将 RDB 数据存储到本地就可以将其加载到内存中。
无盘加载通过网络 socket 一边读取数据,一边加载数据。为避免数据加载异常,需要在加载前使用临时 db 备份之前内存的数据。
可以发现,从 6.0 版本开始,Redis 主从复制已经支持同时支持了 RDB 的无盘传输和无盘加载,将 RDB 相关的操作完全无盘化了。
4.4 Redis 7.0: 共享(全局)复制缓冲区
原有复制缓冲区设计存在以下两点问题:
多从节点导致主节点内存占用过多
每个从节点在主节点上都有独立的 output buffer,全量同步和增量同步时都会将写命令写入所有从节点的 output buffer 以及 repl_backlog。在全量同步阶段,缓冲区数据量大,容易触发 client-output-buffer-limit,导致主节点断开从节点连接,同步失败。不难发现,全量同步时,每个从节点的 output buffer 与主节点的 repl_backlog 中有许多相同的数据,这些相同的数据被拷贝了多份,造成了内存的浪费。
output buffer 数据拷贝与释放的阻塞问题
我们在 3.1.1 小节提到过,为了让多个从节点共享一次 BGSAVE 生成的 RDB,Redis 会将已在同步的从节点 output buffer 数据拷贝到新请求全量同步的从节点 output buffer 中。当 output buffer 数据量很大时,拷贝操作可能耗时百毫秒甚至秒级,造成阻塞。当 output buffer 触发大小限制被关闭连接时,释放大量数据的过程同样可能耗时较长,对 Redis 性能造成影响。
为解决上述两点问题,Redis 7.0 提出了共享复制缓冲区的方案。共享复制缓冲区的核心思想是:主节点在命令传播时,将数据写入一个全局共享的复制缓冲区,所有从节点按各自进度引用其中的不同位置,从而避免为每个从节点维护一份相同数据的 output buffer,repl_backlog 也共用这份数据。相关数据结构代码即示意图如下:
从上面数据结构的定义中可以看到,共享复制缓冲区在 redisServer 中设置了一个全局的复制缓冲区块列表 server.repl_buffer_blocks。更新后的复制积压缓冲区和客户端输出缓冲区不再需要单独分配一片内存空间存储命令字节,只需要维护一个复制缓冲区的块节点引用和在当前块中的读取位置即可。
图 14、共享复制缓冲区示意图
有了上述结构,就不用再为每个 replica 维护一个 output buffer,内存大小不会再随着 replica 数量线性增长;此外对 master 而言,只需要为每个 replica 维护一个对共享复制缓冲区的引用信息即可,所以之前的数据深拷贝变成了更新引用信息,非常轻量,不再会有阻塞问题。
此外,由于现在我们使用链表管理复制积压区,遍历整个链表来查找对应节点可能会很耗时。因此,Redis 7.0 创建了一个 rax 树来索引部分节点(每 64 个节点创建一个索引),以加快查找速度。
4.5 Redis 8.0: RDB 通道
有了 Redis 全量同步的精巧设计与 Redis 各版本的持续演进,全量同步依旧存在以下两个痛点:
主节点客户端输出缓冲区压力过大:在全量同步期间,RDB 生成、发送、加载期间传入的所有写命令都暂存到输出缓冲区中。对大实例而言,这一过程耗时往往较长,导致主节点输出缓冲区中累积大量命令,带来很大的内存压力。一旦累积的命令超过了输出缓冲区的限制,主节点将终止该从节点连接,导致复制失败。
主节点 CPU 负载较高:使用无盘传输时,由于 TLS 连接的限制,子进程需要将 RDB 字节通过管道传输给主进程,再由主进程转发给副本。这个中间转发过程涉及了多次系统调用,带来了额外的 CPU 开销。
为了解决以上痛点,Redis 8.0 引入了一种全新的复制机制——RDB Channel,其设计示意图如下。
图 15、RDB Channel 设计示意图[5]
在 RDB Channel 的帮助下,主节点与从节点间建立了两条通道,分别用于传输 RDB(RDB Channel)与 RDB 生成、传输、接收、加载期间的增量命令(Main Channel)。两条通道同时传输数据,增量命令不再暂存到主节点端的输出缓冲区中,而是存入从节点端的内存缓冲区中。
Redis 双通道复制的一个巧妙设计在于其复用了 PSYNC 的逻辑。 具体来说,从节点首先通过 RDB 通道发送 PSYNC ? -1 命令向主节点请求 RDB 数据,主节点随即启动 BGSAVE 开始生成 RDB,在开始生成前通过 RDB 通道响应+FULLRESYNC <replid> <offset>,将当前的复制 ID 和偏移量告知从节点。从节点接收并解析这个响应后,会将 replid 和 offset 保存下来,作为后续主通道建立的关键参数。接着,从节点利用这些信息通过主通道发送 PSYNC <replid> <offset>命令,此时主节点会将其识别为部分重同步请求并响应+CONTINUE。这样一来,主节点会将 RDB 生成、发送、加载期间新收到的写命令通过主通道发送给从节点。可以看到,主通道的这部分逻辑与部分重同步完全一致,是对现有机制的优雅复用。
图 16、双通道模式下主从节点部分通信信息
RDB Channel replication 带来的好处如下:
降低主节点输出缓冲区压力。暂存增量命令的任务从主节点转移给了从节点,从而降低了主从同步期间因为输出缓冲区打满导致连接断开的风险。由于从节点在全量同步期间扮演的角色不如主节点重要,因此让从节点担任该任务是更加合适的。
降低主节点主进程的 CPU 负载。通过为 RDB 传输开辟一条新的专用连接,子进程可以直接访问该新连接,从而消除了子进程使用主节点“子进程 -> 主进程”管道的需要,降低了主进程的 CPU 负载。另外一个好处是,即使主线程那边有命令阻塞,也不会影响 rdb channel 继续发送,rdb 数据的传输会更加稳定。
全量同步速度更快。原来需要先传 RDB 数据,再传增量命令。现在有了两个通道,这两步可以同时进行,加速了全量同步速度。
在测试中,该特性的贡献者对一个 10 GB 的数据集进行了完整同步,同时在此期间额外产生了 2684 万次写操作,这些写操作在复制过程中生成了 25 GB 的变更数据。借助新的复制机制,主节点在复制期间处理写操作的平均速率提高了 7.5%。此外,复制所需时间减少了 18%,且主节点上的复制缓冲区峰值大小降低了 35%。[6]
5 总结
从本文可以看到,Redis 主从复制机制围绕“如何在不牺牲性能的前提下保证数据高可用与一致性”这一核心目标不断演进:从最初基于 RDB 快照的全量同步,到利用复制积压缓冲区实现的增量同步,构建出一套完整而可靠的数据复制链路。随后,Redis 又在多个版本中持续优化复制细节:4.0 的 PSYNC2 缓解了主从切换和重启场景下的全量同步压力,6.0 的无盘加载进一步在从节点端减轻了磁盘负担,7.0 的共享复制缓冲区则显著优化了多从场景下的内存占用和阻塞问题,8.0 的 RDB Channel 大大减小了主节点的内存压力,缩短了复制时间。理解这些机制与源码中的关键实现,不仅有助于我们在生产环境中更合理地部署与调优 Redis,也为设计其他分布式系统的复制与高可用方案提供了有价值的工程经验与借鉴。
参考资料
[1] Redis replication, Redis replication | Docs
[3] 《Redis 学习笔记——Redis 高级篇之分布式缓存》,Redis学习笔记--Redis高级篇之分布式缓存分布式缓存 1.Redis持久化 Redis有两种持久化方案: RDB - 掘金
[4] 《Redis 主从复制演进史与奇思妙想》,Redis主从复制演进史与奇思妙想 | 咕咕
[5] Second Channel For RDB, Second Channel For RDB · Issue #11678 · redis/redis · GitHub
[6] Redis 8 is now GA, loaded with new features and more than 30 performance improvements, Redis 8 is now GA, loaded with new features and more than 30 performance improvements | Redis
版权声明: 本文为 InfoQ 作者【腾讯云数据库】的原创文章。
原文链接:【http://xie.infoq.cn/article/57bf42f3e5704b3fd45ef4356】。文章转载请联系作者。







评论