在分布式系统中为了解决单点问题,通常会把数据复制为多个副本并部署到其它机器,满足故障恢复和负载均衡等需求。Redis 为我们提供了主从实例之间的复制功能,实现了相同数据的多个 Redis 副本。用户可以通过执行 REPLICAOF 命令或设置 replicaof 选项(Redis 5.0 之前使用 SLAVEOF 命令和 slaveof 选项)让一个服务器去复制(replicate)另一个服务器。

例如现在有两个实例,分别为实例 1(172.16.19.3)和实例 2(172.16.19.5),当我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据:

  1. REPLICAOF 172.16.19.3 6379

REPLICAOF 本身是异步命令,从节点只保存主节点信息后就会返回,后续复制流程在从节点内异步执行。默认情况下,从节点配置为只读模式,可通过 replica-read-only 选项修改。因此,主从节点之间默认采用的是读写分离的方式。
image.png
为什么要采用读写分离的方式呢?

因为如果主库和从库都能接收客户端的写操作,当客户端对同一个数据进行多次修改时,每一次的修改请求都可能发送到不同的实例上,在不同的实例上执行。那我们就要保证这个数据在这三个实例上的一致性,就要涉及到加锁、实例间协商是否完成修改等一系列操作,这会带来巨额的开销,是不太能接受的。而主从库模式一旦采用了读写分离,所有数据修改只会在主库进行,主库有了最新的数据后会同步给从库,这样,主从库之间的数据就是一致的了。

主从实例同步流程

1. 数据同步

数据同步过程用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。当我们启动多个 Redis 实例并通过 REPLICAOF 命令形成主库和从库的关系之后,会按照总共三个阶段完成数据的第一次同步。整体过程如下图所示:
63d18fd41efc9635e7e9105ce1c33da1.webp
第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。

具体来说,从库给主库发送 PSYNC 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。PSYNC
命令具有完整重同步部分重同步两种模式。其中,完整重同步用于处理初次复制的情况,而部分重同步则用于处理断线后重复制的情况。PSYNC 命令包含了主库的 runID 和复制进度 offset 两个参数。

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

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

在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。具体来说,主库执行 BGSAVE 命令,生成 RDB 文件,生成后会接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 REPLICAOF 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。

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

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

2. 命令传播

命令传播过程则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态。

当主从库完成了全量复制后,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的写命令操作再同步给从库执行。当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态。这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

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

3. 网络断连

在 Redis 2.8 版本之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制(底层采用了 SYNC 命令),全量复制是重新同步所有数据,性能开销很大。从 Redis 2.8 版本开始,网络断开后,主从库会采用增量复制的方式(底层采用了 PSYNC 命令)继续同步。增量复制只会把主从库网络断连期间主库收到的命令同步给从库。

实际上,增量复制的奥妙就在于 repl_backlog_buffer 这个缓冲区。当 Redis 开启主从模式后,主库在执行写操作命令时,不仅会写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。而repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写的位置,从库则会记录自己已经读到的位置。这个缓冲区是专门为了网络断连而创建的,目的是避免全量同步带来的性能开销。

刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大。同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。在正常情况下,这两个偏移量是基本相等的。
13f26570a1b90549e6171ea24554b737.webp
主从库的连接恢复之后,从库首先会给主库发送 PSYNC 命令,并把自己当前的 slave_repl_offset 复制偏移量发给主库,主库会判断自己的 master_repl_offset 和 slave_repl_offset 之间的差距。在网络断连期间,主库可能会收到新的写操作命令,所以,通常 master_repl_offset 会大于 slave_repl_offset。此时,主库只用把 master_repl_offset 和 slave_repl_offset 之间的命令操作同步给从库就行。

下面,我们通过一张图回顾下增量复制的整个流程。
20e233bd30c3dacb0221yy0c77780b16.webp
不过,有个地方要强调一下,因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致,进而导致从库重新进行全量复制。

对于这种情况,我们可以调整 repl_backlog_size 这个参数来调整 backlog 缓冲区的大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值。
image.png

replication buffer 和 repl_backlog_buffer 的区别:

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

Redis 主从库在进行复制时,当主库要把全量复制期间的写操作命令发给从库时,主库会先创建一个客户端用来连接从库,然后通过这个客户端把写操作命令发给从库。在内存中,主库上的客户端就会对应一个 buffer,这个 buffer 就被称为 replication buffer。主库会给每个从库建立一个客户端,所以 replication buffer 不是共享的,而是每个从库都有一个对应的客户端。而 repl_backlog_buffer 是一块专用 buffer,在 Redis 服务器启动后,开始一直接收写操作命令,这是所有从库共享的缓冲区。
7a1795yy4f6dc064f0d34ef1231203a8.webp

4. 心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:

  1. REPLCONE ACK <replication_offset>

其中的 replication_offset 是从服务器当前的复制偏移量。发送 REPLCONE ACK 命令主要有三个作用:

1)检测主从服务器的网络连接状态。主从服务器可以通过发送和接收 REPLCONF ACK 命令来检查两者之间的网络连接是否正常。如果距离最后一次 REPLCONE ACK 命令收到的时间已经超过了 repl_timeout 选项配置的时间(默认 60 秒)就会和从库断开连接了。
image.png

2)辅助实现 min-replicas 选项。Redis 提供了 min-replicas-to-writemin-replicas-max-lag 两个配置项用来防止主服务器在不安全的情况下执行写命令。如下配置表示在从服务器的数量少于 3 个,或者三个从服务器的延迟(lag)值都大于或等于 10 秒时,主服务器将拒绝执行写命令。
image.png

3)检测命令丢失。如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送 REPLCONF ACK 命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。

主从同步注意事项

1. 主从数据不一致

主从数据不一致,就是指客户端从从库中读取到的值和主库中的最新值并不一致。假设主从库之前保存的用户年龄值是 19,当主库接收到修改命令,已经把这个数据更新为 20 后,此时,从库中的值仍然是 19。那么,如果客户端从从库中读取用户年龄值,就会读到旧值。

出现这个问题的根本原因是主从库间的命令复制是异步进行的。在主从库命令传播阶段,主库收到新的写命令后会发送给从库。但主库并不会等到从库实际执行完命令后,再把结果返回给客户端,而是主库自己在本地执行完命令后,就会向客户端返回结果了。如果从库还没有执行主库同步过来的命令,主从数据就不一致了。

从库滞后执行同步命令的原因:

一方面,主从库间的网络可能会有传输延迟,所以从库不能及时地收到主库发送的命令,从库上执行同步命令的时间就会被延后。

另一方面,即使从库及时收到了主库的命令,但是,也可能会因为正在处理其它复杂度高的命令(例如集合操作命令)而阻塞。此时,从库需要处理完当前的命令,才能执行主库发送的命令操作,这就会造成主从数据不一致。而在主库命令被滞后处理的这段时间内,主库本身可能又执行了新的写操作。这样一来,主从库间的数据不一致程度就会进一步加剧。

如何应对主从数据不一致:

首先,在硬件环境配置方面,我们要尽量保证主从库间的网络连接状况良好。例如,我们要避免把主从库部署在不同的机房,或者是避免把网络通信密集的应用和 Redis 主从库部署在一起。另外,我们还可以开发一个外部程序来监控主从库间的复制进度。

因为 Redis 的 INFO replication 命令可以查看主库接收写命令的进度信息(master_repl_offset)和从库复制写命令的进度信息(slave_repl_offset)。所以,我们可以先用 INFO replication 命令查到主、从库的进度,然后再用 master_repl_offset 减去 slave_repl_offset,这样就能得到从库和主库间的复制进度差值了。

我们在应用 Redis 时,可以周期性地运行这个流程来监测主从库间的不一致情况。如果某个从库的进度差值大于我们预设的阈值,我们可以让客户端不再和这个从库连接进行数据读取,这样可以减少读到不一致数据的情况。不过,为了避免出现客户端和所有从库都不能连接的情况,我们需要把复制进度差值的阈值设置得大一些。
3a89935297fb5b76bfc4808128aaf905.webp
当然,监控程序可以一直监控着从库的复制进度,当从库的复制进度又赶上主库时,我们就允许客户端再次跟这些从库连接。

2. 读取过期数据

如果你使用的是 Redis 3.2 之前的版本,那么,从库在服务读请求时,并不会判断数据是否过期,而是会返回过期数据。在 3.2 版本后,Redis 做了改进,如果读取的数据已经过期了,从库虽然不会删除,但会返回空值,这就避免了客户端读到过期数据。所以,在应用主从集群时,尽量使用 Redis 3.2 及以上版本。

但并非使用了 Redis 3.2 后的版本,就不会读到过期数据了。因为这还跟 Redis 用于设置过期时间的命令有关系,有些命令给数据设置的过期时间在从库上可能会被延后,导致应该过期的数据又在从库上被读取到了。

当主从库全量同步时,如果主库接收到了一条 EXPIRE 命令,那么,主库会直接执行这条命令。这条命令会在全量同步完成后,发给从库执行。而从库在执行时,就会在当前时间的基础上加上数据的存活时间,这样一来,从库上数据的过期时间就会比主库上延后了。

假设当前时间是 2020 年 10 月 24 日上午 9 点,主从库正在同步,主库收到了一条命令:EXPIRE testkey 60,这就表示,testkey 的过期时间就是 24 日上午 9 点 1 分,主库直接执行了这条命令。

但是,主从库全量同步花费了 2 分钟才完成。等从库开始执行这条命令时,时间已经是 9 点 2 分了。而 EXPIRE 命令是把 testkey 的过期时间设置为当前时间的 60s 后,也就是 9 点 3 分。如果客户端在 9 点 2 分 30 秒时在从库上读取 testkey,仍然可以读到 testkey 的值。但是,testkey 实际上已经过期了。

为了避免这种情况,我给你的建议是,在业务应用中使用 EXPIREAT/PEXPIREAT 命令,把数据的过期时间设置为具体的时间点,避免读到过期数据。

3. 不合理配置项导致的服务挂掉

这里涉及到的配置项有两个,分别是 protected-modecluster-node-timeout

其中 protected-mode 配置项的作用是限定哨兵实例能否被其他服务器访问。当这个配置项设置为 yes 时,哨兵实例只能在部署的服务器本地进行访问。当设置为 no 时,其他服务器也可以访问这个哨兵实例。
image.png
如果 protected-mode 被设为 yes 而其余哨兵实例部署在其它服务器,那么,这些哨兵实例间就无法通信。当主库故障时,哨兵无法判断主库下线,也无法进行主从切换,最终 Redis 服务不可用。所以,我们在应用主从集群时,要注意将 protected-mode 配置项设置为 no,并且将 bind 配置项设置为其它哨兵实例的 IP 地址。这样一来,只有在 bind 中设置了 IP 地址的哨兵,才可以访问当前实例,既保证了实例间能够通信进行主从切换,也保证了哨兵的安全性。

我们来看一个简单的小例子。如果设置了下面的配置项,那么,部署在 192.168.10.3/4/5 这三台服务器上的哨兵实例就可以相互通信,执行主从切换。

  1. protected-mode no
  2. bind 192.168.10.3 192.168.10.4 192.168.10.5

而 cluster-node-timeout 配置项设置了 Redis Cluster 中实例响应心跳消息的超时时间。
image.png
当我们在 Redis Cluster 集群中为每个实例配置了“一主一从”模式时,如果主实例发生故障,从实例会切换为主实例,受网络延迟和切换操作执行的影响,切换时间可能较长,就会导致实例的心跳超时(超出 cluster-node-timeout)。实例超时后,就会被 Redis Cluster 判断为异常。而 Redis Cluster 正常运行的条件就是,有半数以上的实例都能正常运行。

所以,如果执行主从切换的实例超过半数,而主从切换时间又过长的话,就可能有半数以上的实例心跳超时,从而可能导致整个集群挂掉。所以建议你将 cluster-node-timeout 调大些(例如 10 到 20 秒)。