16. Redis主从同步

楔子

主从同步(主从复制)是 Redis 高可用服务的基石,也是多机运行中最基础的一个。我们把主要存储数据的节点叫做主节点(master),把其他通过复制主节点数据的副本节点叫做从节点(slave),如下图所示:

在 Redis 中一个主节点可以拥有多个从节点,一个从节点也可以是其他从节点的主节点,如下图所示:

而 Redis 为了保证数据副本的一致性,主从节点之间采用的是读写分离的方式。

  • 读操作:主节点、从节点都可以执行;
  • 写操作:首先在主节点执行,然后主节点再将写操作同步给从节点,从节点再执行;

那么问题来了,为什么要采用读写分离的方式呢?

可以设想一下,如果不管是主节点还是从节点,都能接收客户端的写操作,那么一个直接的问题就是:如果客户端对同一个数据(例如 k1)前后修改了三次,每一次的修改请求都发送到不同的实例上,在不同的实例上执行,那么这个数据在这三个实例上的副本就不一致了(分别是 v1、v2 和 v3)。在读取这个数据的时候,就可能读取到旧的值。

如果我们非要保持这个数据在三个实例上一致,就要涉及到加锁、实例间协商是否完成修改等一系列操作,但这会带来巨额的开销,当然是不太能接受的。而主从节点模式一旦采用了读写分离,所有数据的修改只会在主节点上进行,不用协调三个实例。主节点有了最新的数据后,会同步给从节点,这样主从节点的数据就是一致的。

主从同步

首先说一下开启主从同步的优点:

  • 性能方面:有了主从同步之后,可以把查询任务分配给从节点,用主节点来执行写操作,这样极大的提高了程序运行的效率,把所有压力分摊到各个节点上了;
  • 高可用:当有了主从同步之后,当主节点宕机之后,可以很迅速的把从节点提升为主节点,为 Redis 服务器的宕机恢复节省了宝贵的时间;
  • 防止数据丢失:当主节点磁盘坏掉之后,其他从节点还保留着相关的数据,不至于数据全部丢失。

那么下面就来看看如何开启主从同步,既然是主从,那么意味着至少要有两个节点。当然可以使用 docker 启动多个容器来进行模拟,不过我阿里云上有三台服务器,所以这里就不使用 docker 了。当前我阿里云上的三台服务器信息如下:

  • 47.94.174.89,主机名:satori,2 核心 8GB 内存
  • 47.93.39.238,主机名:matsuri,2 核心 4GB 内存
  • 47.93.235.147,主机名:aqua,2 核心 4GB 内存

我们之前使用的都是 satori 主机,那么下面就用 satori 主机做主节点,matsuri 主机和 aqua 主机做从节点。当然首先我们要在 matsuri 主机和 aqua 主机上也安装 Redis,这个过程就不演示了,我们三个节点安装的都是相同版本的 Redis,配置如下。

# satori 节点
bind 0.0.0.0
requirepass satori
daemonize yes

# matsuri 节点
bind 0.0.0.0
requirepass matsuri
daemonize yes

# aqua 节点
bind 0.0.0.0
requirepass aqua
daemonize yes

下面我们启动三个节点的 Redis 来试一下,首先 Redis 开启主从同步非常简单,只需要在从节点的配置文件中指定 slaveof <master_ip> <master_port> 即可,当然如果主节点设置了密码,那么从节点还需要指定 masterauth <master的密码>

输入 info replication 即可查看状态,其中 role 表示节点的身份,connected_slaves 表示对应的从节点数量。显然这三个节点都是主节点,原因是我们在启动 matsuri 节点个 aqua 节点的时候只配置了 IP、密码、是否后台启动,并没有设置主从相关的参数,所以它们启动之后都是主节点。

然后下面我们在命令行中进行设置,我们先以 aqua 节点为例:

一旦执行了slaveof(由于 slave 这个单词存在歧视,所以 Redis 中也可以使用 replicaof,是等价的),那么这台 Redis 主节点就变成了其它主节点的从节点,然后从节点上的数据会被清空,主节点将自身的数据副本同步给从节点。尽管 aqua 这个节点设置了 name 这个 key,但是当它执行了 slaveof 之后就变成了从节点,所以自身的数据就没清空了,而主节点又没有 name 这个 key,所以最后 get name 的结果为 nil。

然后我们将 matsuri 节点也设置为 satori 节点的从节点:

最后再来看看主节点 master 的状态:

以上我们就实现了主从同步,我们在主节点设置 key,然后看看在从节点上能不能获取得到。

# matsuri 节点是从节点,此时没有 name 这个 key
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> 

# 我们在主节点 satori 上设置
127.0.0.1:6379> set name kagura_nana
OK
127.0.0.1:6379>

# 再回到从节点 matsuri 上查看,发现 key 已经成功地被设置了
127.0.0.1:6379> get name
"kagura_nana"
127.0.0.1:6379>

# aqua 节点也是同理,主节点会将数据同步到所有的从节点
127.0.0.1:6379> get name
"kagura_nana"
127.0.0.1:6379> 

因此一旦成为从节点之后,自身的数据就会被清空,然后同步主节点的数据,因为此时已经和指定的 master 建立联系了。

# 如果继续连接,会提示我们已经连接到指定的 master 了
127.0.0.1:6379> slaveof 47.94.174.89 6379  
OK Already connected to specified master
127.0.0.1:6379> 

但是问题来了, 如果我们在从节点上写数据,会不会同步到主节点上面呢?

# 在 matsuri 节点上写数据
127.0.0.1:6379> set name kagura_mea
(error) READONLY You can't write against a read only replica.
127.0.0.1:6379> # 答案是根本不允许在从节点上进行写操作

一旦成为从节点,那么它就不能写入数据了,只能老老实实地从主节点备份数据。所以在默认情况下,处于复制模式的主节点既可以执行写操作也可以执行读操作,而从节点则只能执行读操作。如果想让从节点也支持写操作,那么设置 config set replica-read-only no 即可,或者在配置文件中修改,便可以使从节点也开启写操作,但是需要注意以下几点:

  • 在从节点上写的数据不会同步到主节点;
  • 在进行完整数据同步时,从节点数据会被清空;

但是一般来说,我们都不会让从节点执行写操作,而是按照 Redis 的默认策略,采用主从节点读写分离的方式。

  • 读操作:主节点和从节点都可以执行
  • 写操作:只能主节点执行,然后主节点将写操作同步给从节点,然后从节点再执行、从而让数据和主节点保持一致

那么问题来了,问啥要采用读写分离的方式呢?可以试想一下,如果不管主节点还是从节点都能接收写操作(将 replica-read-only 设为 yes),那么一个直接的问题,以我们上面的三个节点为例:如果客户端对同一个 key 修改了三次,每一次的修改请求都发到不同的实例节点上,那么数据在多个节点之间就不一致了,而在读取的时候就有可能读取到旧的值。

如果我们非要让这个数据在三个节点上保持一致,就要涉及到加锁、实例间协商是否完成修改等一系列操作,但这会带来巨额的开销,当然是不太能接受的。而主从节点模式一旦采用了读写分离,所有数据的修改只会在主节点上进行,不用协调三个节点。主节点有了最新的数据后,会同步给从节点,这样,主从节点的数据就是一致的。

所以我们看到实现主从同步还是很简单的,就是指定一个 slaveof,可以在配置文件中指定,可以在启动时通过命令行的方式指定,还可以在进入控制台的时候设置。当然如果主节点设置了密码,那么从节点还要指定 masterauth,整体没什么难度。那么如何关闭主从同步呢?

那么如何关闭主从同步呢?关闭主从同步有两种方式:第一种是通过 slave <other_master_ip> <other_master_port>,将该从节点指向其它的主节点,但是该机器依旧是 slave;另一种方式是通过 slaveof no one,这种方式就是关闭主从同步,然后该机器也会由 slave 变成 master。

我们测试一下:

主从同步是如何工作的?

我们总说 Redis 具有高可靠性,主要从两方面考量:一是数据尽量少丢失、而是服务尽量少中断。AOF 和 RDB 保证了前者,而对于后者,Redis 的做法是增加副本冗余量,将一份数据同时保存在多个实例上。即使有一个实例出现了故障,需要过一段时间才能恢复,其他实例也可以对外提供服务,不会影响业务使用。

那么问题来了,主从同步是如何完成的呢?主节点数据是一次性传给从节点,还是分批同步?要是主从节点间的网络断连了,数据还能保持一致吗?下面就来聊聊主从同步的原理,以及应对网络中断风险的方案。

我们先来看看主从节点间的第一次同步是如何进行的,这也是 Redis 实例建立主从节点模式后的规定动作。

注:我们这里说主节点和从节点实际上不是特别恰当,因为我们指的不是机器或者说节点本身,而是指节点上的 redis-server 进程,所以更准确的说法应该叫主库和从库。不过这些都无所谓了, 不影响理解就行。

主从节点之间如何建立第一次同步?

当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 slaveof(Redis 5.0 开始使用 replicaof)命令形成主节点和从节点的关系,以我们之前的 matsuri 节点为例:

127.0.0.1:6379> slaveof 47.94.174.89 6379
# 如果 master 设置了密码,还需要指定密码
127.0.0.1:6379> config set masterauth satori

一旦连接成功,那么 "从节点" 会从 "主节点" 上拉取数据,而这个过程可以分为三个阶段。

第一阶段是主从节点之间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从节点和主节点建立连接之后会告诉主节点即将进行同步,主节点确认回复后,那么之间的同步就可以开始了。

具体来说,从节点给主节点发送 psync 命令,表示要进行数据同步,主节点根据这个命令的参数来启动复制,psync 命令包含了主节点的 runID 和复制进度 offset 这两个参数。

  • runID:每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从节点和主节点第一次复制时,因为不知道主节点的 runID,所以将 runID 设为 "?"
  • offset:设置为 -1,表示第一次复制

主节点收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主节点 runID 和主节点目前的复制进度 offset,返回给从节点。从节点收到响应后,会记录下这两个参数。但这里有个地方需要注意,FULLRESYNC 表示此次复制(第一次)采用的是全量复制,也就是说主节点会把当前数据全部复制给从节点。

在第二阶段,通过生成的 RDB 文件,主节点将全部的数据都同步给从节点,从节点完成加载。

具体做法是,主节点执行 bgsave 命令,生成 RDB 文件,接着将文件发给从节点。从节点接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从节点在通过 replicaof(slaveof)命令开始和主节点同步前,可能保存了其他数据。为了避免之前数据的影响,从节点需要先把当前数据库清空。

而在主节点将数据同步给从节点的过程中,主节点不会被阻塞,仍然可以正常接收请求,否则 Redis 的服务就被中断了。但是这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中,为了保证主从节点的数据一致性,主节点会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。

小问题:我们说过 AOF 记录的操作命令更全,相比于 RDB 丢失的数据更少,那么这里为啥使用 RDB 不用 AOF 呢?原因很简单,AOF 记录的具体的写命令,RDB 记录的是经过压缩的二进制数据,占用内存更小,更紧凑。如果是设置到备份、传输,那么显然使用 RDB 要更合适一些。

最后,也就是第三个阶段,主节点会把第二阶段执行过程中新收到的写命令,再发送给从节点。具体的操作是,当主节点完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从节点,从节点再重新执行这些操作。这样一来,主从节点就实现同步了。

主从级联模式分担全量复制时的主节点压力

主从节点之间第一次进行的同步也被成为 "全量同步",因为同步的全量数据,所以主节点在生成 RDB 文件、和传输 RDB 文件会比较耗时。而主从节点之间是第一次同步,因此这一点显然是无法避免的,但问题是如果从节点非常的多怎么办?显然如果从节点数量很多,而且都要和主节点进行全量复制的话,就会导致主节点忙于 fork 子进程生成 RDB 文件,进行数据全量同步。fork 这个操作会阻塞主线程处理正常请求,从而导致主节点响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主节点的网络带宽,同样会给主节点的资源使用带来压力。那么,有没有好的解决方法可以分担主节点压力呢?

其实是有的,这就是 "主 - 从 - 从" 模式。在刚才介绍的主从模式中,所有的从节点都是和主节点连接,所有的全量复制也都是和主节点进行的。而现在我们可以通过 "主 - 从 - 从" 模式将主节点生成 RDB 和传输 RDB 的压力,以级联的方式分散到从节点上。

简单来说,我们在部署主从集群的时候,可以手动选择一个从节点(比如选择内存资源配置较高的从节点),用于级联其他的从节点。然后,我们就可以随便选择一些从节点(例如三分之一的从节点),在这些从节点上执行如下命令,让它们和刚才所选的从节点,建立起主从关系。

replicaof  所选从节点的IP 6379

此时这些从节点就知道了,以后没必要再和主节点进行交互了,只需要和级联的从节点进行写操作同步即可,这样就可以减轻主节点的压力,因为我们说一个从节点也可以是其它节点的主节点。

好了,到这里我们算是了解了主从节点之间通过全量复制实现数据同步的过程,以及通过 "主 - 从 - 从" 模式分担主节点压力的方式。而一旦主从节点完成了全量复制,它们之间就会一直维护一个网络连接,主节点会通过这个连接将后续陆续收到的命令操作再同步给从节点,这个过程也称为 "基于长链接的命令传播",可以频繁避免建立连接的开销。

听上去好像很简单,但不可忽视的是,这个过程存在着风险点,最常见的就是网络断连或阻塞。如果网络断连,主从节点之间就无法进行命令传播了,从节点的数据自然也就没办法和主节点保持一致了,客户端就可能从 "从节点" 读到旧数据。接下来,我们就来聊一聊网络断连的解决办法。

主从节点之间网络断了怎么办?

在 Redis 2.8 之前,如果主从节点在命令传播时出现了网络闪断,那么,从节点就会和主节点重新进行一次全量复制,而全量复制我们知道开销是非常大的,因为全量复制是把主节点中所有数据全都复制一遍。虽然可以通过 "主 - 从 - 从" 减少主节点的压力,但很明显全量复制依旧不是一个合适选择(它只适合在第一次同步的时候使用)。

从 Redis 2.8 开始,网络断了之后,主从节点会采用增量复制的方式继续同步。听名字大概就可以猜到它和全量复制的不同:全量复制是同步所有数据,而增量复制只会把主从节点网络断连期间主节点收到的命令,同步给从节点。那么,增量复制时,主从节点之间具体是怎么保持同步的呢?这里的奥妙就在于 repl_backlog_buffer 这个缓冲区,我们先来看下它是如何用于增量命令的同步的。

当主从节点断连后,主节点会把断连期间收到的写操作命令写入到 replication buffer 中,而除了 replication buffer 之外,这些命令同时还会被写入到 repl_backlog_buffer 这个缓冲区。repl_backlog_buffer 是一个环形缓冲区,主节点会记录自己写到的位置,从节点则会记录自己已经读到的位置

刚开始的时候,主节点和从节点的写读位置在一起,这算是它们的起始位置。随着主节点不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小。对主节点来说,对应的偏移量就是 master_repl_offset,主节点接收的新的写操作越多,这个值就会越大。同样,从节点在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从节点已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。

主从节点的连接恢复之后,从节点首先会给主节点发送 psync 命令,并把自己当前的 slave_repl_offset 发给主节点,主节点会判断自己的 master_repl_offset 和 slave_repl_offset 之间的差距。

在网络断连阶段,主节点可能会收到新的写操作命令,所以一般来说,master_repl_offset 会大于 slave_repl_offset。此时,主节点只用把 master_repl_offset 和 slave_repl_offset 之间的命令操作同步给从节点就行。就像刚刚示意图的中间部分,主节点和从节点之间相差了 "set k4 v4" 这个操作,在增量复制时,主节点只需要把它们同步给从节点,就行了。

在网络断连阶段,主节点可能会收到新的写操作命令,所以,一般来说,master_repl_offset 会大于 slave_repl_offset。此时,主节点只用把 master_repl_offset 和 slave_repl_offset 之间的命令操作同步给从节点就行。

但是有一点需要注意,repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区满后,主节点会继续写入,此时就会覆盖掉之前的操作。比如图中的 "set k9 v9" 就将最开始的 "set k1 v1" 给覆盖掉了,因此如果从节点读取的速度比较慢,那么就有可能造成从节点还未读取的操作被主节点新写的操作给覆盖掉了,从而导致主从节点之间的数据不一致。

因此我们要想办法避免这一情况,一般而言,我们可以调整 repl-backlog-size 这个参数,这个参数就是用来控制 repl_backlog_buffer 这个环形缓冲区的空间大小的。但设置为多大才合适呢?首先有一个计算公式:" 缓冲空间大小 = 主节点写入命令速度 * 操作大小 - 主从节点间网络传输命令速度 * 操作大小 ",所以我们需要根据实际情况下计算的结果进行设置。只不过在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 " repl-backlog-size = 根据公司计算得到的缓冲空间大小 * 2 ",这就是 repl-backlog-size 的最终值。

举个例子,如果主节点每秒写入 2000 个操作,每个操作的大小为 2KB,网络每秒能传输 1000 个操作,那么,有 1000 个操作需要缓冲起来,这就至少需要 2MB 的缓冲空间。否则,新写的命令就会覆盖掉旧操作了。但我们说为了应对可能的突发压力,我们需要将结果乘以 2,因此最终应该把 repl-backlog-size 设为 4MB。

这样一来,增量复制时主从节点的数据不一致风险就降低了。但如果并发请求量非常大,连两倍的缓冲空间都存不下新操作请求的话,此时主从节点数据仍然可能不一致。针对这种情况,一方面,我们可以根据 Redis 所在服务器的内存资源再适当增加 repl-backlog-size 值,比如说设置成缓冲空间大小的 4 倍,另一方面,你可以考虑使用切片集群来分担单个主节点的请求压力。关于切片集群,我们后面再聊。

所以 repl-backlog-size 这个参数非常重要,因为如果满了导致操作被覆盖,就意味着要想主从节点之间数据要想保持一致的话,只能选择全量复制。因此之前网上流传一个段子,如果一个人跟你说他们公司业务量多大,技术多么牛,那么你可以问问它 repl-backlog-size 设置的多少,如果是默认值(1M),那么基本可以认为这个人要么在业务量上吹牛皮,要么公司没有技术牛人。

到这里可以再回顾一下增量复制的流程:

 

最后再补充一点,从前面的内容我们可以得知,在第一次主从复制的时候,会先产生一个 RDB 文件,再把 RDB 文件发送给从节点。但如果主节点是非固态硬盘的时候,系统的 I/O 操作是非常高的,为了缓解这个问题,Redis 2.8.18 中新增了无盘复制功能,无盘复制功能不会在本地创建 RDB 文件,而是会派生出一个子进程,然后由子进程通过 Socket 的方式,直接将 RDB 文件写入到从节点,这样主节点就可以在不创建RDB文件的情况下,完成与从节点的数据同步。要使用该功能,只需把配置项 repl-diskless-sync 的值设置为 yes 即可,它默认配置值为 no。

replication buffer 和 repl_backlog_buffer 的区别

关于 replication buffer 和 repl_backlog_buffer 的区别,可能有人不是很清楚,这里解释一下。

总的来说,replication buffer 是主从节点在进行全量复制时,主节点上用于和从节点连接的客户端的 buffer,而 repl_backlog_buffer 是为了支持从节点增量复制,主节点上用于持续保存写操作的一块专用 buffer。

Redis 主从节点在进行复制时,当主节点要把全量复制期间的写操作命令发给从节点,主节点会先创建一个客户端,用来连接从节点,然后通过这个客户端,把写操作命令发给从节点。在内存中,主节点上的客户端就会对应一个 buffer,这个 buffer 就被称为 replication buffer。Redis 通过 client_buffer 配置项来控制这个 buffer 的大小。主节点会为每个从节点建立一个客户端,所以 replication buffer 不是共享的,而是每个从库都有一个对应的客户端。

repl_backlog_buffer 是一块专用 buffer,在 Redis 服务器启动后,开始一直接收写操作命令,这是所有从节点共享的。主节点和从节点会各自记录自己的复制进度,所以不同的从节点在进行恢复时,会把自己的复制进度(slave_repl_offset)发给主节点,主节点就可以和它独立同步。

小结

这次我们一起了解了 Redis 的主从节点同步的基本原理,总结来说有三种模式:全量复制、基于长连接的命令传播,以及增量复制。

全量复制虽然耗时,但是对于从节点来说,如果是第一次同步,全量复制是无法避免的,所以有一个小建议:"一个 Redis 实例的数据库不要太大",一个实例大小在几 GB 左右是比较合适的,这样可以减少 RDB 文件生成、传输和重新加载的开销。另外,为了避免多个从节点同时和主节点进行全量复制,给主节点造成过大的同步压力,我们也可以采用 "主 - 从 - 从" 这一级联模式,来缓解主节点的压力。

长连接复制是主从节点正常运行后的常规同步阶段,在这个阶段中,主从节点之间通过命令传播实现同步。不过,这期间如果遇到了网络断连,增量复制就派上用场了。因此再次建议留意一下 repl-backlog-size 这个配置参数,如果它配置得过小,在增量复制阶段,可能会导致从节点的复制进度赶不上主节点,进而导致从节点重新进行全量复制。所以,通过调大这个参数,可以减少从节点在网络断连时全量复制的风险。

不过,主从节点模式使用读写分离虽然避免了同时写多个实例带来的数据不一致问题,但是还面临主节点故障的潜在风险。主节点故障了从节点该怎么办,数据还能保持一致吗,Redis 还能正常提供服务吗?估计有人猜到了,会通过哨兵机制选出一个新的主节点,那么后续我们就来具体聊聊主节点故障后,保证服务可靠性的解决方案,也就是所谓的哨兵机制。

posted @ 2020-07-19 15:55  古明地盆  阅读(1438)  评论(0编辑  收藏  举报