主从复制是怎么实现的

问题引入

在这之前,我们已经了解了 redis 的持久化:AOF 和 RDB 。正是这两个持久化机制保证了即使 redis 重启之后,数据依然存在。

但是,虽然 redis 可以支持持久化,可数据保存在一台服务器上,如果这台服务器出现了重大问题,那就完蛋了。

当数据只保存在一台服务器上的时候,会有两个极大的潜在隐患。

一个是单点故障风险。如果单机 Redis 服务器出现故障,比如硬件损坏、软件崩溃等,那么依赖该 Redis 服务的应用程序将无法正常运行。因为没有备用的服务器可以接管工作,所有与 Redis 相关的功能,如缓存数据读取、分布式锁操作等都会受到影响。例如,一个基于 Redis 实现登录验证的网站,当单机 Redis 服务器故障时,用户登录功能可能会出现异常,无法正常验证用户身份。

另外一个是数据丢失风险。单机 Redis 如果没有及时进行数据持久化或者持久化策略配置不当,一旦服务器出现故障,内存中的数据可能会丢失。即使配置了持久化,在两次持久化操作之间的数据更新也可能会丢失。例如,在一个实时交易系统中,使用 Redis 存储交易订单的临时信息,如果 Redis 服务器突然崩溃,而此时还未将最新的订单信息持久化,这些订单信息就会丢失,可能导致交易数据不一致等问题。

所以为了规避上述这两种风险,我们可以把数据备份到不同的服务器上,这样的话,即使其中一台服务器出现了问题,但是其他的服务器依然能正常运行。

但是,好多台服务器备份同样的数据,该如何保证数据的一致性呢?

redis 提供了主从复制模式,主从复制保证了不同服务器之间数据的一致性。在这个模式中,从节点可以备份主节点的数据,即使主节点挂了,数据也不会丢失。从节点可以分担主节点的读请求,提高系统性能。而且,即使主节点挂了,从节点还能继续提供服务,保证系统的高可用性。

简而言之,就是主节点负责写数据,从节点负责读数据,主节点把数据同步到从节点,这样数据就一致了。

第一次同步

在 Redis 的主从复制中,我们有两个主要角色:主服务器从服务器。那么,如何确定谁是主服务器,谁是从服务器呢?

确定哪个是主服务器,哪个是从服务器,通常是通过配置来设定。在一个 Redis 集群中,主服务器是唯一的,所有的数据修改操作都通过它进行。而从服务器则是主服务器的复制副本,它们实时同步主服务器上的数据。

当我们设置主从复制时,我们会在从服务器上指定主服务器的地址,这样从服务器就知道自己要从哪个服务器获取数据。

我们一般会在从服务器上执行这条命令:

replicaof <主服务器的 IP 地址> <主服务器的 Redis 端口号>

这样设置好之后,就可以进行主从复制的第一次同步了。

主从复制中,第一次同步的过程可以分为三个阶段:

  • 建立连接,协商同步
  • 主服务器同步全量数据
  • 主服务器同步增量命令

第一阶段

当从服务器启动并连接到主服务器时,它会发送一个 PSYNC 命令来请求同步。这个命令是 Redis 复制机制中的关键,它用于告诉主服务器从哪个位置开始同步数据。

PSYNC 命令的作用是确定主从服务器之间的同步方式:是全量同步(fullresync)还是部分同步。

PSYNC 命令中,除了包含从服务器的请求信息外,还会传递一个 runid(运行标识符),这是每个 Redis 服务器实例启动时生成的一个唯一标识符。

runid 用来帮助主服务器判断从服务器是否是第一次连接,还是之前有过连接并丢失了同步。通过 runid,主服务器能够识别从服务器之前是否正常同步过数据。

如果从服务器之前曾与主服务器正常连接并同步数据。如果主服务器看到来自从服务器的 PSYNC 命令中的 runid 和自己记录的 runid 一致,主服务器就知道从服务器是之前有同步过数据的,理论上可以尝试执行部分同步。

如果 runid 不一致,或者从服务器的连接是全新启动的,主服务器就知道从服务器是第一次同步数据,这时候会进行全量同步。

在主从复制过程中,主服务器会将所有的写操作(如 SETINCR 等)以命令的形式追加到一个日志中,称为 复制偏移量(offset)。每个写命令都有一个唯一的偏移量,用来标识命令在日志中的位置。

当从服务器发送 PSYNC 命令时,它不仅会带上自己的 runid,还会带上它最后接收到的偏移量 offset。这个 offset 用来告诉主服务器,从服务器已经接收到哪些数据,主服务器只需要从 offset 之后的命令开始同步。

比如说,从服务器在某个时刻与主服务器断开了连接,并且在断开前已经同步了前 1000 个操作。当它重新连接时,发送 PSYNC 命令并告知主服务器它已经接收过 1000 个操作。

如果主服务器的日志中有更多的新操作,它会继续从 offset = 1000 之后的操作发送给从服务器,而不是重新发送所有的操作。

如果主服务器收到的 PSYNC 命令包含的 runid 与当前的状态不匹配,或者从服务器的偏移量 offset 已经不可用(比如主服务器的日志滚动或丢失了部分数据),那么主服务器就会认为从服务器的状态已不一致,这时会进行 全量同步,即发送整个数据库的快照给从服务器。

在这种情况下,主服务器会响应 PSYNC 命令,告诉从服务器执行 fullresync(全量同步)。

第二阶段

当从服务器请求主服务器进行同步时,主服务器会启动一个背景进程,通过执行 BGSAVE 命令生成一个 RDB 文件。这个命令的作用是将当前数据库的所有数据保存到磁盘上的 RDB 文件中,RDB 文件是Redis的持久化文件,包含了当前数据库的完整快照。

BGSAVE 命令不会阻塞主服务器的主线程,而是通过创建一个子进程来执行数据持久化操作。这样,主线程依然能够继续接收和处理客户端的请求。主线程不会因为生成RDB文件而被挂起。

生成RDB文件的过程是异步的,Redis主服务器会先向操作系统请求创建一个子进程,这个子进程会负责实际的数据保存工作。与此同时,主线程仍然能正常工作,处理新的命令和请求。

但是这里有一个问题, 就是由于主服务器在生成RDB文件时依旧可以接收客户端的请求并处理命令,而这些命令的执行并没有被记录到刚刚生成的RDB文件中。所以,在这个过程中,主服务器的数据会发生变化,但这些变化不会出现在正在生成的RDB文件里。此时,主服务器的数据和从服务器的数据就可能出现不一致的情况。

那么为了解决这个问题,为了保证数据的一致性,主服务器需要在生成RDB文件的过程中依然能够记录并同步所有的写操作。为此,Redis通过一个叫做复制缓冲区(replication buffer)的机制来暂存主服务器在生成RDB文件期间的所有写操作命令。

具体来说,主服务器会在以下三个关键时刻将所有新的写操作命令记录到复制缓冲区中:

  • 主服务器生成 RDB 文件期间:

BGSAVE 执行时,主服务器开始创建RDB文件。如果在此期间有新的写操作命令(例如 SET key value),这些命令不会立即写入RDB文件,而是被保存在复制缓冲区里。主服务器的主线程仍然会继续接收新的写请求。

  • 主服务器发送 RDB 文件给从服务器期间:

当生成的RDB文件完成后,主服务器开始通过复制协议将该文件发送给从服务器。在这个过程中,主服务器仍然会接收到新的写操作请求,这些写操作会被记录到复制缓冲区中,直到从服务器完成RDB文件的加载。

  • 从服务器加载 RDB 文件期间:

从服务器在收到RDB文件后,会清空现有数据,并将RDB文件中的数据加载到内存中。加载过程是一个同步操作,所以在此期间主服务器的写操作命令仍然会通过复制缓冲区保留在缓冲区中,等待从服务器加载完RDB文件后同步过去。

第三阶段

当从服务器接收到主服务器发送的 RDB 文件时,它会首先丢弃自己现有的所有数据。这一步是为了避免将主服务器的新数据与从服务器上已有的旧数据混合,确保从服务器开始从一个干净的状态重新加载数据。

然后,从服务器会将 RDB 文件中的数据载入到内存中。这是一个同步操作,从服务器会逐条加载RDB文件中的数据,直到完成整个数据集的恢复。

在这个过程中,从服务器的数据库内容会被完全替换为主服务器的 RDB 文件中的内容。所有先前存在的键值对都会被清空,新的数据将完全覆盖原有数据。

并且载入数据的过程是单线程的,所有的加载工作完成后,从服务器才会继续执行后续的操作。

完成 RDB 文件的加载后,从服务器会向主服务器发送一个确认消息,表示它已经成功加载并准备好继续同步后续的数据。这时,从服务器的数据和主服务器的数据完全一致。

在从服务器完成 RDB 文件的加载后,主服务器开始将之前保存在 replication buffer(复制缓冲区)中的写操作命令发送给从服务器。我们之前提到,在生成RDB文件的过程中,主服务器会把所有的新写操作命令记录在复制缓冲区中,以便后续同步。

此时,主服务器将复制缓冲区中的命令按顺序发送给从服务器,这些命令通常是主服务器执行的写操作,比如 SETDELHSET 等。

命令传播

当主从服务器完成首次同步后,主从服务器之间便会建立并维护起一个 TCP 连接。在这之后,主服务器能够借助这个 TCP 连接,持续地把写操作命令传送给从服务器。从服务器收到命令后会进行执行,以此让自身的数据库状态和主服务器的数据库状态达成一致。

这个 TCP 连接属于长连接,之所以采用长连接的形式,是为了规避频繁地进行 TCP 连接的建立和断开所产生的性能损耗。上述这样的一个过程,就被叫做基于长连接的命令传播。通过基于长连接的命令传播这种方式,能够确保在完成第一次同步之后,主服务器和从服务器的数据始终保持一致。

分摊主服务器的压力

经过前面的分析,我们已经了解到在首次数据同步阶段,主服务器需要执行两项较为耗时的任务:生成RDB文件以及传输该文件。

主服务器能够连接多个从服务器。一旦从服务器数量庞大,并且它们都与主服务器进行全量同步,便会衍生出两大棘手问题。

一方面,主服务器依靠bgsave命令来生成RDB文件,这一过程需要借助fork()函数创建子进程。要是主服务器所承载的内存数据量极其庞大,在执行fork()函数时,极有可能阻塞住主线程。而主线程一旦受阻,Redis便无法正常处理各类请求,进而严重影响系统的正常运行。

另一方面,传输RDB文件这一操作会大量占用主服务器的网络带宽资源。网络带宽被过度占用后,主服务器在响应命令请求时,速度会大幅下降,响应效率大打折扣,最终导致整个系统的性能受到显著影响 。

就好比一家初创公司,在起步阶段,由于员工数量有限,老板可以轻松地对每一位员工进行直接管理,一切事务都在老板的掌控之中。但随着公司不断发展壮大,新员工陆续加入,人员规模持续扩充,老板渐渐发现自己力不从心,难以再独自承担管理全体员工的重任。

为了解决这一管理困境,老板采取了一个明智的办法,那就是设立经理职位。每位经理负责管理多名普通员工,如此一来,老板无需再对众多基层员工进行细致管理,只需要专注于管理这些经理,就能确保整个公司的管理体系顺畅运行。

Redis的架构模式与之极为相似。在Redis的系统里,从服务器并非一成不变地只扮演接收数据的角色。从服务器也能够拥有自己的从服务器。在这里,我们可以把拥有从服务器的从服务器类比为公司里的经理角色。它一方面像普通从服务器一样,能够接收来自主服务器的同步数据;另一方面,它又具备主服务器的部分特性,可以将接收到的数据以主服务器的形式同步给自己的从服务器,从而构建起更为复杂且高效的数据同步层级结构 。

增量复制

当主服务器和从服务器完成首次同步操作后,它们会依托于长连接来持续进行命令的传播,从而让从服务器能够不断更新数据,保持与主服务器的数据一致性。

然而,网络状况是复杂多变且难以完全预测的,经常会出现各种意外情况。比如,可能会突然出现网络延迟,数据传输的速度变慢;又或者直接发生网络连接断开的情况。

一旦主从服务器之间的网络连接断开,那么原本基于长连接的命令传播就无法正常进行了。由于从服务器接收不到主服务器新的命令和数据更新,它的数据就不能再和主服务器的数据保持同步一致。这样一来,当客户端从从服务器读取数据时,就很有可能获取到旧的数据,而不是最新的、与主服务器一致的数据。

所以这里就会出现一个问题:当主从服务器之间断开的网络重新恢复正常后,如何才能让它们继续保持数据的一致性呢?

在Redis 2.8版本之前,一旦主从服务器在命令同步过程中经历网络断开又恢复,从服务器就会和主服务器重新执行一次全量复制。这种方式会产生巨大的资源消耗,无论是数据传输占用的带宽,还是主从服务器处理数据的计算资源和时间成本都非常高,显然不是一种高效的方案,亟待优化。

而从Redis 2.8版本起,情况有了很大改善。当网络断开又恢复后,主从服务器会启用增量复制机制。简单来说,此时不再进行完整的数据重新复制,只将网络断开这段时间内,主服务器接收到并执行的写操作命令,同步给从服务器。这样一来,既减少了数据传输量,又能快速让从服务器跟上主服务器的数据更新节奏,以更高效的方式维持了主从服务器之间的数据一致性 。

Redis增量复制主要通过三个步骤实现:

网络恢复后,从服务器向主服务器发送 psync 命令,此时 psync 命令里的 offset 参数不再是 -1;

主服务器接收到命令后,会用 CONTINUE 响应,明确告知从服务器后续将以增量复制方式同步数据;

最后,主服务器把主从服务器断线期间执行的写命令传给从服务器,从服务器执行这些命令,完成数据同步。

但这里有个核心问题:主服务器怎么确定该给从服务器发送哪些增量数据呢?

redis 中存在两个参数:repl_backlog_buffer 和 replication offset。

repl_backlog_buffer 是一个“环形”缓冲区,主从服务器断连后,它能帮助找到二者之间存在差异的数据;而replication offset 则标记着缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器用 master_repl_offset 记录自身数据写入位置,从服务器用 slave_repl_offset 记录自己的数据读取位置,二者配合实现精准的增量数据同步。

我们知道 repl_backlog_buffer 缓冲区对Redis增量复制很关键,那它是什么时候写入数据的呢?

在主服务器进行命令传播时,它会开启“双线操作”:一方面,将写命令发送给从服务器;另一方面,把同样的写命令写入 repl_backlog_buffer 缓冲区,所以这个缓冲区始终保留着近期传播的写命令。

当网络断开又恢复,从服务器重新连接主服务器时,会通过 psync 命令把自己的复制偏移量 slave_repl_offset 传给主服务器。主服务器对比自身的 master_repl_offset 和接收到的 slave_repl_offset 的差距,以此来决策采用哪种同步方式:要是发现从服务器需要的数据还在 repl_backlog_buffer 缓冲区中,就选择增量同步,只传差异数据;反之,如果数据已不在该缓冲区里,就只能进行全量同步,重新传输全部数据。

一旦主服务器在 repl_backlog_buffer 中确定了主从服务器之间的差异数据,也就是增量数据,就会将其写入 replication buffer 缓冲区。这个 replication buffer 缓冲区之前提到过,专门用来缓存即将传播给从服务器的命令,为后续的增量数据传输做好准备 。

repl_backlog_buffer 作为环形缓行缓冲区,默认大小仅为1MB。这意味着一旦缓冲区被写满,主服务器继续写入新数据时,就会覆盖掉缓冲区里原有的旧数据。

想象一下,若主服务器的数据写入速度像瀑布一样迅猛,而从服务器的数据读取速度却像小溪流水般缓慢,那缓冲区里的数据很快就会被新数据覆盖掉。 当网络中断后恢复连接,要是从服务器需要读取的数据不巧已经被覆盖,主服务器就只能采用全量同步的方式进行数据同步。

全量同步需要传输全部数据,相比只传输差异数据的增量同步,会带来更大的性能损耗,无论是对网络带宽,还是对服务器的计算资源和时间成本都造成更高的消耗。 为了防止网络恢复时主服务器频繁使用全量同步,我们可以通过调整 repl_backlog_buffer 缓冲区的大小来解决问题。把缓冲区设置得更大一些,就好比给数据多准备了一个更大的“仓库”,这样能降低从服务器所需数据被覆盖的可能性,从而让主服务器更多地采用高效的增量同步方式。

那么,repl_backlog_buffer 缓冲区到底该调整到多大才合适呢?其实,它的最小大小是可以通过特定公式来估算的。

second * write size per second

下面我来说说估算 repl_backlog_buffer 缓冲区大小公式的含义。

公式里的 second ,指的是从服务器断开连接后,重新成功连接上主服务器所花费的平均时间,单位是秒。这体现了网络中断到恢复的时间跨度。

另一个参数 write_size_per_second ,代表的是主服务器平均每秒钟产生的写命令数据量大小,反映了主服务器的数据更新速度。

举个实际例子,假设主服务器每秒平均会产生1MB的写命令数据,而从服务器在断开连接后,平均要5秒才能重新连接上主服务器。按照公式计算,repl_backlog_buffer 缓冲区的大小就不能小于5MB。因为如果小于这个数值,在从服务器断开连接的这5秒内,主服务器新产生的写命令数据就会覆盖掉旧数据,导致后续无法进行增量同步。

考虑到实际运行中可能会出现各种突发状况,比如网络波动加剧、主服务器瞬间产生大量写命令等,为了更稳妥地保证增量同步能够顺利进行,我们可以把 repl_backlog_buffer 的大小设置为计算结果的2倍。在刚才的例子中,也就是将缓冲区大小设置为10MB,以此增强系统的容错能力。

至于如何修改 repl_backlog_buffer 缓冲区的大小,操作并不复杂,只需要在Redis的配置文件中找到对应的参数项,然后修改其值就可以完成设置了。

repl-backlog-size 1mb

总结

在Redis的主从复制机制中,存在三种工作模式,分别是全量复制、基于长连接的命令传播,以及增量复制,它们在不同场景下各司其职。

当主从服务器初次进行数据同步时,采用的是全量复制模式。这个过程中,主服务器需要完成两项较为耗时的任务:一是生成RDB文件,将自身的数据完整地以文件形式保存下来;二是把生成的RDB文件传输给从服务器。如果同时有大量从服务器与主服务器进行全量复制,主服务器的压力会非常大。

为缓解这种压力,可以将部分从服务器升级为“经理角色”,使其也能拥有自己的从服务器,通过构建多级复制结构,把数据同步的压力分散开来。 一旦主从服务器完成第一次全量同步,它们之间就会建立并维护一个长连接。此后,每当主服务器接收到写操作命令,都会通过这个长连接将写命令传播给从服务器。这样一来,从服务器能够及时执行相同的写操作,从而保证与主服务器的数据始终保持一致。

当主从服务器之间的网络连接意外断开又恢复时,增量复制模式就发挥作用了。不过,增量复制能否顺利实施,与 repl_backlog_size 参数的配置大小密切相关。repl_backlog_size 控制着用于记录主服务器写操作的缓冲区大小。如果这个值设置得过小,在网络断开期间,主服务器新写入的数据可能会覆盖掉缓冲区中从服务器尚未读取的数据。等网络恢复后,主服务器发现从服务器需要的数据已不存在,就不得不再次采用全量复制的方式重新同步数据。

因此,为了减少全量复制的发生频率,提高数据同步的效率,需要合理调大 repl_backlog_size 参数的值,降低因数据覆盖而导致全量同步的概率。

发表评论

后才能评论