Redis主从复制原理及其演变

Redis 的主从复制经历了多次演化,本文将从最基本的原理和实现讲起,并根据其版本迭代过程,逐步呈现其主从复制的演化,让大家了解到 Redis 主从复制的原理,以及各个改进版本解决了什么问题,最终看到 Redis 7.0 主从复制的全貌。

 一、什么是主从复制?

在数据库语境下,复制(replication)就是将数据从一个数据库复制到另一个数据库中。主从复制,是将数据库分为主节点和从节点,主节点源源不断地将数据复制给从节点,保证主从节点中存有相同的数据。

有了主从复制,数据可以有多份副本,这带来了多种好处:

  • 第一,提升数据库系统的请求处理能力。单个节点能够支撑的读流量有限,部署多个节点,并构成主从关系,用主从复制保持主从节点数据一致,如此主从节点一起提供服务。
  • 第二,提升整个系统的可用性。因为从节点中有主节点数据的副本,当主节点宕机后,可以立刻提升其中一个从节点为主节点,继续提供服务。

 二、Redis 主从复制原理 

主从复制,直观的做法是主节点产生一份数据的快照发送给从节点,以快照数据为基准,将之后增量的数据变更同步给从节点,如此就能保证主从数据的一致。总体来看,主从复制一般包含全量数据同步、增量同步两个阶段。

在 Redis 的主从复制实现中,包含两个类似阶段:全量数据同步和命令传播

  • 全量数据同步主节点产生一份全量数据的快照,即 RDB 文件,并将此快照发送给从节点。且从产生快照时刻起,记录新接收到的写命令。当快照发送完成后,将累积的写命令发送给从节点,从节点执行这些写命令。此时基准已经建立完成。
  • 命令传播:全量数据同步完成后,主节点将执行过的写命令源源不断地发送给从节点,从节点执行这些命令,保证主从节点中数据有相同的变更,如此保证主从节点数据的一致。

下图中给出了主从复制的整个过程:

  • 主从关系建立后,从节点向主节点发送一个 SYNC 命令请求进行主从同步。
  • 主节点收到 SYNC 命令后,执行 fork 创建一个子进程,子进程将所有的数据编码存储到 RDB(Redis Database) 文件中,这就产生了数据库的快照。
  • 主节点将此快照发送给从节点,从节点接收快照并载入。
  • 主节点接着将生成快照、发送快照期间积压的命令发送给从节点。
  • 此后,主节点源源不断地新执行的写命令同步到从节点,从节点执行传播来的命令,命令执行后,从库中的数据也就得到了更新。如此,主从数据保持一致。需要说明的是,命令传播存在时延的,所以任意时刻,不能保证主从节点间数据完全一致。

以上就是 Redis 主从复制的基本原理,很简单很容易理解,但存在一些不美好的地方:

  • fork 耗时过长,阻塞主进程:执行fork 时候,需要拷贝大量的内存页表,这是一个耗时较多的操作,尤其当内存使用量较大的时候。组内同学曾做过测试,内存占用 10GB 时,fork 需要消耗 100 多毫秒。fork 的时候主进程阻塞 100 多毫秒,这对 Redis 而言,实在太长了。另外,fork 之后,如果主库中有不少的写入,那么由于写时复制机制,会额外消耗不少的内存,还会增大响应时间。
  • 主从间网络闪断:假如主从之间的网络出现了故障,连接意外断开,主节点无法继续传播命令至该从节点。之后网络恢复,从节点重新连接上主节点后,主节点不能再继续传播新接收到的命令了,因为从节点已经漏掉了一些命令。此时,从节点需要从头再来,再次执行全部的同步过程,而这要付出很高的代价。网络闪断是常发生的事情,闪断期间主节点中可能只写入了比较少的数据,但就因为这很少的一部分数据,需要让从节点进行一次代价高昂的全量同步。这种做法是非常低效的,该如何解决这问题呢?下一节部分重同步给你答案。

 三、Redis 部分重同步 

网络短暂断开后,从节点需要重新同步,这很浪费资源,不够环保。从节点为什么需要重新同步呢?因为主从断开期间有部分命令没有同步到从节点上去。如果忽略这些命令继续传播后续的命令,则会导致数据的错乱,因此丢失掉的命令是不能忽略的。为什么不将那些命令保存下来呢?这样当从节点重新连接后,就可以将断连期间的命令补充给它了,这样就不需要重新全量同步了。

Redis 2.8 版本后,引入了部分同步。它在主节点中维护了一个复制积压缓冲区,命令一方面会传播到从节点,另外还会记录在这个缓冲区中。保持所有的命令是不必要的,Redis 中使用了一个环形的缓冲区,这样就可以只保留最近的一些命令了。

命令已经保存下来了,但从节点重新连接后,主节点该从什么地方开始给从节点发送命令呢?如果能给所有命令编一个号,则从节点只需要告诉主节点自己最后收到的命令的编号,主节点就知道该从什么位置发送命令了。Redis 的实现中是对字节进行编号,这个编号在 Redis 的语境中叫做复制偏移量。

有了部分同步后,主从复制的流程变成了下面这样:

主从复制的时候不再使用 SYNC 命令,而是使用 PSYNC,意思的 Partial SYNC。PSYNC 的语法如下:

PSYNC <master id> <replication offset>

命令中的两个参数,一个是主节点的编号,一个是复制偏移量。每个Redis节点都有一个 40 字节的编号,PSYNC 命令中携带的编号是期望进行同步的主节点的编号。复制偏移量则表示当前从节点想要从什么地方开始部分同步。

如果是第一次进行主从复制,自然是不知道主节点的编号,复制偏移量也无意义,此时使用 PSYNC ? -1 来进行全量同步。另外,如果从节点指定的复制偏移量不在主节点的复制积压缓冲区的范围内,部分同步会失败,会转向全量同步。

有了部分同步,网络闪断后就可以避免全量同步了。但是因为主节点只能保留最近的部分命令,保存多少取决于复制积压缓冲区的大小。如果从节点断开时间过长,或者断开期间主节点新执行的写命令足够多,漏掉的命令就无法全部保存到复制积压缓冲区中了。加大复制积压缓冲区可以尽可能多地避免全量同步,但这同时会造成额外的内存消耗。

部分同步消耗了部分内存来保存最近执行的写命令,避免闪断后的全同步,这是很直观、很容易想象的解决方案。这种方案很好,它是否还存在其他问题呢?考虑以下问题:

  • 假如从节点重启了怎么办?部分同步依赖主节点的编号和复制偏移量,从节点在初次同步的时候会获取到主节点的编号,并在之后的同步中不断调整复制偏移量,这些信息都存储在内存中。当从节点意外重启后,尽管本地存有 RDB 或 AOF 文件,还是需要进行一次全量同步。但实际上完全可以载入本地数据,并执行部分同步即可。
  • 假如主从切换了怎么办?假如主节点意外宕机,外围监控组件执行了主从切换。此时其他从节点对应的主节点就变化了,从节点中记录的主节点编号就匹配不上新的主节点了,此时会进行一次全量同步。但实际上所有的从节点在主从切换之前同步进度应该是差不多的,而且新提升的从节点包含的数据应该最全,切主后所有从节点都执行一次全量同步,这实在不合理。

以上问题如何解决,请继续往后看。

 四、同源增量同步 

从节点重启后丢失了原主节点编号和复制偏移量,这导致重启后需要全量同步,这很好办,把这些信息存下来就可以了。

主从切换后,主节点信息变化了,导致从节点需要全量同步,这也容易解决,只需能确认新主节点上的数据是从原主节点复制来的,那就可以继续从新的主节点上进行复制。

Redis 4.0 以后,对 PSYNC 进行了改进,提出了同源增量复制的解决方案,该方案解决了前面提到的两个问题。

从节点重启后,需要跟主节点全量同步,这本质上是因为从节点丢失了主节点的编号信息,在 Redis 4.0 后,主节点的编号信息被写入到 RDB 中持久化保存。

切主后,从节点需要和新主节点全量同步,本质原因是新的主节点不认原主节点的编号。如果从节点发送 PSYNC <原主节点编号> <复制偏移量>,新的主节点能够识别到这里主节点的编号其实是原主节点,且自己的数据就是从该节点复制来的。那么新的主节点就应该明白,他和其他从节点师出同门,应该接收部分同步。如何才能识别呢,只需要让从节点在切换为主节点时,将自己之前的主节点的编号记录下来即可。

Redis 4.0 以后,主从切换后,新的主节点会将先前的主节点记录下来,观察 info replication 的结果,可以可以看到 master_replidmaster_replid2 两个编号,前者是当前主节点的编号,后者为先前主节点的编号:

127.0.0.1:6379> slaveof no one
OK
127.0.0.1:6379> info replication
# replication
role:master
...
master_replid:a8s7f6g53k3n5k90j2h3j43ja872h1y63u4m5b7h
master_replid2:k90j2h3j43ja863u4a8s7f6g5372hk3n5m5b7h1y
master_repl_offset:394820
second_repl_offset:394821
...
repl_backlog_histlen:5180

Redis 中目前值保留了两个主节点编号,但完全可以实现一个链表,将过完的主节点的信息都记录下来,这样就可以追溯的更远了。这样以来,如果一个从节点断开后,执行了多次主从切换,该从节重新连接后,依然可以识别出它们的数据是同源的。但 Redis 没有这么做,这是因为没有必要,因为就算数据是同源的,但复制积压缓冲区中保存的数据是有限的,多次主从切换后,复制积压缓冲区中保存的命令已经无法满足部分同步了。

有了同源增量复制后,主节点切换后,其他从节点可以基于新的主节点继续增量同步。

此时,主从复制看起来已经不存在太大的问题了。但做 Redis 的那帮家伙,总在追求极致的性能,挖空心思想着能不能再做些优化。下面我将描述 Redis 主从复制的一些优化策略。

 五、无盘全量同步和无盘加载 

Redis 执行全量复制时,需要生成当前数据库的一份快照,具体做法是执行 fork 创建子进程,子进程遍历所有数据并编码后写入 RDB 文件中。RDB 生成后,在主进程中,会读取此文件并发送给从节点。

读写磁盘上的 RDB 文件是比较耗资源的,在主进程中执行势必会导致 Redis 的响应时间变长。因此一个优化方案是 dump 后直接将数据通过 socket 发送数据给从节点,不需要将数据先写入到 RDB 中。Redis 6.0 中实现了这种无盘全量同步和无盘加载的策略。

采用无盘全量同步,避免了对磁盘的操作,但也有缺点,一般情况下,在子进程中直接使用网络发送数据,这比在子进程中生成 RDB 要慢,这意味着子进程需要存活的时间相对较长。子进程存在的时间越长,写时复制造成的影响就越大,进而导致消耗的内存会更多。

在全量复制时候,从节点一般是先接收 RDB 将其存在本地,接收完成后再载入 RDB。同样地,从节点也可以直接载入主节点发来的数据,避免将其存入本地的 RDB 文件中,而后再从磁盘加载。

 六、共享主从复制缓冲区 

在主节点的视角中,从节点就是一个客户端,从节点发送了 PSYNC 命令后,主节点就要与它们完成全量同步,并不断地把写命令同步给从节点。Redis 的每个客户端连接上存在一个发送缓冲区。

主节点执行了写命令后,就会将命令内容写入到各个连接的发送缓冲区中。发送缓冲区存储的是待传播的命令,这意味着多个发送缓冲区中的内容其实是相同的。而且,这些命令还在复制积压缓冲区中存了一份呢。这就造成了大量的内存浪费,尤其是存在很多从节点的时候。

在 Redis 7.0 中,实现了一个共享主从复制缓冲区的方案解决了这个问题。该方案让发送缓冲区与复制积压缓冲区共享,避免了数据的重复,可有效节省内存。

 七、Forkless 全量同步 

前文中提到,全量同步时需要 fork 一个子进程,但 fork 是一个开销较大的操作。我的同事曾做过实验,内存 10GB 的 Redis 实例,fork 会导致 Redis 主线程阻塞长达 100 多毫秒。这 100 毫秒,会使大量的上层调用超时失败。为了避免 fork 影响 Redis 服务的稳定性,forkless 的方案被提出。即,全量同步时不依赖于 fork。

如果不使用 fork,该如何得到一份隔离的数据作为快照呢?答案是不隔离。可以分多次逐步遍历 Redis 的主哈希表,一边遍历一边写 RDB 文件。当有新的写操作修改了遍历过的数据时,将变更记录下来。遍历完成后,将变更追加到 RDB 文件中。如此就可以在不执行 fork 的条件下,生成一份数据的快照。

此方案原理看似很简单,但在实现中需要处理很多细节,比如:大 key 时候会消耗较长时间、多 key 命令的处理、哈希表 rehash、阻塞命令的处理等等。所幸此方案已经在 BDRP Redis 6.0 内核中实现了。

八、总结 

本文回顾并总结了 Redis 主从复制的演化过程,并解释了各次演化所解决的问题。最后,描述了对 Redis 主从复制进行优化的一些策略。

下面是对全文的总结:

  • 宏观来看 Redis 的主从复制分为全量同步和命令传播两个阶段。主节点先发送快照给从节点,然后源源不断地将命令传播给从节点,以此保证主从数据的一致。
  • Redis 2.8 之前的主从复制存在闪断后需要重新全量同步的问题,Redis 2.8 引入了复制积压缓冲区解决了这一问题。
  • 在 Redis 4.0 中,同源增量复制的策略被提出,解决了主从切换后从节点需要全量同步的问题。至此,Redis 的主从复制整体上已经比较完善了。
  • Redis 6.0 中,为进一步优化主从复制的性能,无盘同步和加载被提出,避免全量同步时读写磁盘,提高主从同步的速度。
  • 在尚未发布的 Redis 7.0 中,采用了共享主从复制缓冲区的策略,降低了主从复制带来的内存开销。
  • 为了避免调用 fork 时对主进程的造成长时间的阻塞,forkless 方案被提出,在不依赖 fork 的情况下完成全量同步,降低主从复制时对业务的影响。

欢迎关注下方“非著名资深码农“公众号进行交流~

发表评论

邮箱地址不会被公开。