主从库模式

Redis 提供了 2 种不同的持久化方式:AOF 和 RDB,它们可以分别通过回放日志和重新读入 RDB 文件的方式来恢复数据,从而保证尽量少丢失数据,提升可靠性。

但即使使用了这两种方法,也依然存在服务不可用的问题:单点故障问题;比如我们在实际使用时只运行了一个 Redis 示例,那么如果该实例宕机了,它必然在恢复期间是无法服务心来的数据存取请求的。

那我们总说的 Redis 具有高可靠性,又是什么意思呢?其实,这里有两层含义:一是数据尽量少丢失,二是服务尽量少中断

AOF 和 RDB 保证了前者,而对于后者,Redis 的做法就是增加副本冗余量,将一份数据同时保存在多个实例上。即使有一个实例出现了故障,需要过一段时间才能恢复,其他实例也可以对外提供服务,不会影响业务使用。

多实例保存同一份数据,听起来好像很不错,但是,我们必须要考虑一个问题:这么多副本,它们之间的数据如何保持一致呢?数据读写操作可以发给所有的实例吗?

对此,Redis 提供了主从库模式,保证数据的一致,主从库之间采用的是读写分离的方式。

  • 读操作:主库、从库都可以接收;
  • 写操作:首先到主库执行,然后将写操作同步给从库。

那为什么要采用读写分离的方式呢?

Redis 的读写分离

在上图中可以看出,如果不管是主库还是从库都能接收到客户端的写操作,一个直接问题就是:如果客户端对同一个数据(例如图中的 key)前后修改了三次,每一次的修改请求都发送到不同的实例上执行,那么这个数据在三个实例上的副本就不一致了(分别是 value1、value2、value3)。那客户端在读取数据的时候,就有可能会读取到旧的数据(比如 value1、value2)。

如果非要保持这个数据在三个实例上一致,就要涉及到加锁、实例间协商是否完成修改等一系列操作,但这会带来巨额的开销,当然是不太能接受的。

从这个角度看待就可以理解保持数据一致的方法选择了主从库模式采用读写分离了,所有数据的修改只会在主库上进行,不用协调三个实例。主库有了最新的数据后,会同步给从库,这样主从库的数据就是一致的。

既然知道了 Redis 的高可靠方案之一是主从库模式,那么 Redis 的主从库是如何完成数据同步的呢?

数据同步

Redis 的数据同步分为三个阶段:

  • 主从实例之间建立连接,协商同步,同步元数据
  • 主库同步数据给从库
  • 主库发送新写命令给从库

当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replication 命令形成主库和从库的关系,之后会按照以上三个阶段完成数据的第一次同步,如下图:

第一阶段

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

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

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

主库收到命令后,会用 FULLRESYNC 响应命令带上两个参数:主库的 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后会记录下这 2 个参数。

这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

第二阶段

在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。

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

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

第三阶段

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

示例

在这里我也启用了一台实例去连接主库,并把日志输出贴了出来,如下图:

在上面的日志中也可以看出,重点关注第三个框;

在第三个框的第一行中,从库就收到了来自主库的 runID 和 offset 参数。

主从级联模式分担全量复制时所带来的主库压力

通过分析主从库间第一次数据同步的过程,可以看到,一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件。

如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量同步。

fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。那么,有没有好的解决方法可以分担主库压力呢?

其实是有的,就是 “主 - 从 - 从” 模式:

在上文的主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以通过“主 - 从 - 从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。

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

1
replicaof 与主库直连的从库ip 6379

这样子的话,主库在同步写操作时,就只会与主库直连的 2 个从库同步,然后从库再与自己的从库进行同步写操作。

但如果没有采用“主 - 从 - 从” 模式时,主库就需要和 4 个从库进行同步,这期间所产生的消耗(fork 操作、传输 RDB 所产生的网络贷款)就会比采用了“主 - 从 - 从” 模式的方法多了一倍。

主从库之间的网络通信

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

Redis 的所有写命令除了用于这个用于命令传播的长连接,也还会写入到 repl_backlog_buffer。

但这个过程也存在着风险,最常见的也就是网络锻炼或者阻塞。如果网络锻炼、主从库之间就无法进行命令传播了,从库的数据自然也就没办法和主库保持一致,客户端就可能从从库读取到旧数据。

Redis 在网络断开之后,主从库会采用增量复制的方式继续同步。听名字大概就可以猜到它和全量复制的不同:全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。

那么,增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于 repl_backlog_buffer(回复备份缓冲区,默认大小 1MB) 这个缓冲区。

主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。

repl_backlog_buffer 是一个环形缓冲区,主从库都能访问到这一片缓冲区,主库会记录自己写到的位置,每个从库则会记录自己已经读到的位置。

repl_backlog_buffer 是为了从库断开之后,如何找到主从差异数据而设计的环形缓冲区,从而避免全量同步带来的性能开销。
如果从库断开时间太久,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。

刚开始的时候,主库和从库的写读位置在一起,是它们的起始位置。随着主库不断接收新的写操作,在缓冲区中的写位置会逐步偏离起始位置,这个偏移的大小通常用偏移量去衡量,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大。

同样的,从库在复制完写操作命令后,它在 repl_backlog_buffer 缓冲区中的读位置也开始逐步偏移,从库的已复制或者已读的偏移量就是 slave_repl_offset,这个偏移量也会随着从主库处收到的新写命令的增加而增加。正常情况下,主库的 master_repl_offset 和从库的 slave_repl_offset 基本相等。

repl_backlog_buffer 用于主从间的增量同步。主库只有一个 repl_backlog_buffer 缓冲区,各个从库的 offset 偏移量都是相对该缓冲区而言的。

在 Redis 中也可以查看这 2 个偏移量,使用 redis-cli 这个命令行连接工具,连接到其中一个实例,执行以下命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
## 这是从主库中执行命令所获取到的结果
127.0.0.1:6379> info Replication

## 输出
# Replication
role:master
connected_slaves:2
## 下面的offset就是slave_repl_offset
slave0:ip=127.0.0.1,port=6381,state=online,offset=14042,lag=1
slave1:ip=127.0.0.1,port=6380,state=online,offset=14042,lag=1
master_failover_state:no-failover
master_replid:02433ed156eaa70f93938f57d2657387f9c1b422
master_replid2:0000000000000000000000000000000000000000
## 该偏移量和slave_repl_offset是相等的
master_repl_offset:14042
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:14042

######################

## 从库执行 info Replication 命令
## 输出
127.0.0.1:6380> info Replication
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:3
master_sync_in_progress:0
slave_read_repl_offset:14056
## 从库复制偏移量,与主库写偏移量相等
slave_repl_offset:14056
slave_priority:100
slave_read_only:1
replica_announced:1
connected_slaves:0
master_failover_state:no-failover
master_replid:02433ed156eaa70f93938f57d2657387f9c1b422
master_replid2:0000000000000000000000000000000000000000
## 主库写偏移量,与从库复制偏移量相等
master_repl_offset:14056
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1218
repl_backlog_histlen:12839

以上步骤是在主从库间网络断联的情况下执行的,那如果网络恢复之后呢?

主从库的连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的 slave_repl_offset 发给主库,主库会判断自己的 master_repl_offset 和从库所发送过来的 slave_repl_offset 之间的差距,在 repl_backlog_buffer 中通过 offset 差异,找到差异数据,然后将这部分的差异数据同步到 replication buffer。

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

之前提到 repl_backlog_buffer 是一个环形缓冲区,如果断联时间过长的话,repl_backlog_buffer 缓冲区上 slave_reploffset 位置已经被新数据覆盖了,这时候就不再做增量复制了,而是进行全量复制。

整个过程如下图所示:

但有一点需要注意,因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致,从而导致从库会进行全量复制。

因此,我们要想办法避免这一情况,一般而言,我们可以调整 repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算有一个公式:

1
缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小

在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值。

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

这样一来,增量复制时主从库的数据不一致风险就降低了。不过,如果并发请求量非常大,连两倍的缓冲空间都存不下新操作请求的话,此时,主从库数据仍然可能不一致。

如何给主库添加从库

想要一个实例设置为另外一个实例的从库,有 2 种方法:

(1)、使用 replicaof 命令

现有实例 1(192.168.0.1:6379)和实例 2(192.168.0.2:6379),这 2 个实例都启动之后,在实例 2 中执行以下命令后,实例 2 就变成了实例 1 的从库:

1
replicaof 192.168.0.1 6379

(2)、修改配置文件

修改 redis.conf 配置文件,将配置项 replicaof 取消注释并且填写主库的地址以及端口号;

另外,如果主库设置有密码的话,需要将配置文件的配置项 masterauth 取消注释并且填写主库密码。

查看主从库信息

在使用 redis-cli 命令行连接工具连接到 redis 后,使用以下命令就可以查看信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
127.0.0.1:6379> info Replication
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=6381,state=online,offset=10402,lag=0
slave1:ip=127.0.0.1,port=6380,state=online,offset=10402,lag=0
master_failover_state:no-failover
master_replid:02433ed156eaa70f93938f57d2657387f9c1b422
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:10402
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:10402

在以上信息中就可以看到当前节点的角色、所连接的从库数量、从库 ip、端口号、缓冲区偏移量等信息。

Q&A

1
当一个从库与主库断联时间过长,造成它在主库 repl_backlog_buffer 的 slave_repl_offset 位置上的数据已经被覆盖掉了,这时从库是会丢失掉那部分被覆盖掉的数据,还是会进行全量复制呢?

每个从库会记录自己的 slave_repl_offset,每个从库的复制进度也不一定相同。在和主库重连进行恢复时,从库会通过 psync 命令把自己记录的 slave_repl_offset 发给主库,主库会根据从库各自的复制进度,来决定这个从库可以进行增量复制,还是全量复制。

但出现以上状况的话,从库会进行全量复制。

1
主从库间的数据复制同步使用的是 RDB 文件,为什么主从库间的复制不是用AOF呢?

RDB 文件内容是经过压缩的二进制数据(不同数据类型数据做了针对性优化),文件很小。而 AOF 文件记录的是每一次写操作的命令,写操作越多文件会变得很大,其中还包括很多对同一个 key 的多次冗余操作。

在主从全量数据同步时,传输 RDB 文件可以尽量降低对主库机器网络带宽的消耗,从库在加载 RDB 文件时,一是文件小,读取整个文件的速度会很快,二是因为 RDB 文件存储的都是二进制数据,从库直接按照 RDB 协议解析还原数据即可,速度会非常快,而 AOF 需要依次重放每个写命令,这个过程会经历冗长的处理逻辑,恢复速度相比 RDB 会慢得多,所以使用 RDB 进行主从全量同步的成本最低。