在大流量、高并发的场景中,我们一般不会只有一个单点作为存储,而是把存储做成一个分布式的系统,例如,我们常见的 MySQL 主从结构、Redis 主从结构、Redis Cluster、Memcache 集群或者 TiKV 这种基于 Raft 协议的集群。

Redis 有多种集群搭建方式,比如,主从模式、哨兵模式、Cluster 模式。本模块,我们就重点来介绍一下 Redis 主从模式的相关内容。

在使用 Redis 主从复制模式的时候,一般会搭建多个从库(Slave),从库只支持读请求的处理,用来分摊主库(Master)的读压力,主库(Master)只有一个,只专注于处理写请求,或者同时支持读写,这样就可以实现读写分离,降低主库的读压力,也实现了读请求在各个 Redis 节点之间的负载均衡。这样,我们就得到了 Redis 主从模式的核心架构,如下图所示:

image.png

正如前面所说,Redis 主从模式还解决了单点的问题。Redis 主库在进行修改操作的时候,会把相应的写入命令近乎实时地同步给从库,从库回放这些命令,就可以保证自己的数据与主库保持一致。那么,当主库发生宕机的时候,我们就可以将一个从库升级为主库继续提供服务;当一个从库宕机的时候,主库依旧可以处理写请求,其他从库依旧可以支持读请求,所以并不会影响整个 Redis 服务的读写。所以说,Redis 主从模式解决了 Redis 单点的问题

主从复制原理概述

Redis 中的主从复制协议有多次升级,所以这里我们从 2.8 版本开始一步步介绍 Redis 主从复制协议的升级和优化过程。

在 Redis 2.8 版本之前,主从复制的核心流程如下:

image.png

这里简单描述一下上图的流程。首先,在从库启动之后,会根据配置主动请求主库,建立网络连接。连接完成之后,从库会向主库发送 SYNC 命令,发起数据同步的请求。主库这一侧,在接收到 SYNC 命令之后,会执行 BGSAVE 命令进行 RDB 持久化,在 RDB 持久化执行的这段时间内,主库执行的所有写入命令,都会暂存到主从复制的缓冲区中。

等到 RDB 文件生成好之后,主库会将整个 RDB 文件通过前面建立好的连接,发送给从库。从库这一侧在完整接收到 RDB文件之后,会加载这个 RDB 文件,这样,从库就有了主库进行 RDB 持久化时的全量数据了。主库在发送完 RDB 文件之后,还会将主从复制缓存区中的全部写入命令发送给从库,从库在收到这些命令之后,会将其应用到自己的内存数据中。这样,从库与主库的数据就一致了,之后主库再收到客户端发送来的写入命令,除了应用到自身的内存数据之外,还会异步发送给从库,从库也会应用这条写入命令,这样就会保证主从的数据始终一致。

这个功能看起来没有什么问题,但是主库进行 RDB 持久化操作是一个全量扫描 Redis 数据的操作,该操作比较消耗资源和时间。如果在主从库之间网络状况不佳的情况下,主从连接经常出现闪开之后,马上恢复,这样每次都触发主库 RDB 持久化以及 RDB 文件的传输,显然不是我们希望的效果。

部分同步与 PSYNC 命令

为了解决上述问题,Redis 2.8 引入了 PSYNC 命令,来支持“部分同步”的能力

这里引入了两个概念,一个是 Replication ID,一个是 Replication Offset。

  • Replication ID 用于唯一标识一个数据集,主从复制的场景中,从库始终复制主库的复制,可以认为它们是一份数据集,所以两者的 Replication ID 一致。

  • Replication Offset 表示的一个数据集的状态或者版本,说白了,就是有多少条命令应用到了这个数据集上。在主从复制的场景中,在从库的数据集上执行的命令是始终落后于主库的,所以主库的 Replication Offset 一直大于从库,也就是说从库数据集的版本落后于主库;但是主库上执行的命令,在未来的某个时间点,也会在从库的数据集上执行,也就是说,从库数据集的版本不断在追赶主库,随着从库上执行的命令越来越多,Replication Offset 会不断增加,它的值在未来某个时间点,会变成现在主库的 Replication Offset 值。

这个定义可能有些抽象,我们可以下图这个例子,来说明一下,如下图 T1 时刻所示,主从之间的关系刚刚建立,主库会生成一个 Replication ID,并且同步给从库,从库也会复制这个 Replication ID,后面主从交互的时候,就靠这个 Replication ID 进行互认。另外,T1 时刻主从节点的内存中都没有任何数据,它们的 Replication Offset 也是 0。

到了 T2 时刻,主库收到了一条 SET K1 V1 命令,此时主库的数据集就变成了 K1 → V1,Replication Offset = 1,而此时这条命令还未复制到从库上,从库还处于 Replication Offset = 0 的状态。到了 T3 时刻,SET K1 V1 这条命令复制到了从库,从库就追上了主库,变成了 Replication Offset =1 的状态。

image.png

通过这个例子,我们可以很明显地看出,通过 Replication ID 和 Replication Offset 的组合,就可以确定一个数据集的版本。

弄清了 Replication ID 和 Replication Offset 这两个概念的本质之后,我们就可以开始来看 Redis 2.8 引入的部分同步功能了,它的核心流程如下。

  1. 从库与主库网络连接建立成功之后,从库不会再发送 SYNC 命令,而是发送 PSYNC 命令来发起部分同步请求,在这个请求中,会包含一个数据集的唯一标识,也就是 Replication ID 以及从库数据集的状态,也是 Replication Offset。

  2. 主库在收到 PSYNC 命令之后,会先检查请求中的 Replication ID 是否与当前主库一致。如果 Replication ID 一致,会继续检查请求中的 Replication Offset 是否还在主从复制缓冲区中。主从复制缓冲区是主库专门为主从复制开辟的一块命令缓冲区,用来缓存主库已经执行、但未发送到从库的命令。

如果这个偏移量对应的命令还在主从复制缓冲区中,说明从库落后的数据并不多,从库通过执行下图蓝色区域的命令就可以追上主库。这个时候,从库只需要从自己的 Replication Offset 位置,也就是下图中的箭头位置,开始同步命令即可,这种同步方式也称为“部分同步”:

image.png

如果这个偏移量对应的命令已经不在主从复制缓冲区中了,说明从库落后的数据非常多了,需要进行一次全量同步,也就回落到之前的方案:先进行 RDB 持久化,然后同步主从复制缓冲区中的命令。

如果 PSYNC 请求中携带的从库 Replication ID 与当前主库的 Replication ID 不一致,需要触发一次全量同步。主从 Replication ID 不一致有两种可能:一种可能是主库可能已经发生了更换,比如,原来的主库宕机了,一个从库提升为新主库,这个时候,新主库会生成一个新的 Replication ID;还有一种可能是从库是重启的 Redis 节点,这个时候,会重新生成 Replication ID,也就是数据集和当前主库的不一致。

此次全量同步结束之后,主库会将自己的 Replication ID 同步给从库,主从的 Replication ID 也就一致了,之后如果从库再次触发 PSYNC 命令,带的就是此次返回的 Replication ID。

PSYNC2 优化

虽然 PSYNC 命令可以解决主从库之间闪断后恢复的问题,但是在下面两个场景中,依旧会触发全量同步:一个是从库出现重启之后,即使主从库的数据依旧保持一致,但由于从库存储的 Master Replication ID 丢失了,按照上述 PSYNC 的机制,还是会触发一次全量;二是主从切换、从库提升为主库的场景,因为新主库会重新生成一个 Replication ID,与上一任主库的 Replication ID 不一致,所以会导致剩余全部从库进行一次全量同步。

这两个场景下,即使主从库的数据集完全一致,也要进行全量同步,显然是一种浪费资源的行为。在 Redis 4.0 版本中,将 PSYNC 升级到了 PSYNC 2,主要作用体现在两个方面。

  1. 在 Redis 服务关闭的过程中,会进行一次 RDB 持久化,此时会将内存中维护的 Replication ID 和 Replication Offset 写入到 AUX 部分中。在 Redis 重启过程中,会加载这个 RDB 文件,也就会从 AUX 部分恢复原来的 Replication ID 和 Replication Offset ,如果恢复的这两个值依旧符合前面介绍的部分同步的条件,就无需进行全量同步了,只需要进行部分同步即可。

  2. 为了避免主从切换带来的、不必要的全量同步,在 Redis 4.0 中,从库会存储上次同步的主库的 Replication ID(也被称为 Secondary ID)以及 Replication Offset ,同时,从库也会像主库那样,开辟一个主从复制缓冲区,里面存的数据就是近期从主库复制过来的命令。

当主库发生宕机的时候,会有一个从库升级为主库,新升级上来的主库就会重新生成一个 Replication ID(也被称为 Main ID),同时保存 Secondary ID。这样,其他从库发送 PSYNC2 命令时,新主库会拿 PSYNC2 请求中的 Replication ID 同时与 Main ID 和 Secondary ID 进行比较,它与两者中任意一个相同,就算检查通过。同时,新主库已经同步了上一任主库的主从复制缓冲区,只要其他从库的 Replication Offset 落到主从复制缓冲区内,就可以进行部分同步。部分同步都完成之后,新升级的主库会将自己的 Main ID 同步给各个从库,各个从库将 Replication ID 修改为新主库的 Main ID。

有小伙伴可能会问,为什么从库升级成新主库之后,要重新生成 Replication ID 呢?我们一直沿用 Secondary ID 不就可以了吗?这主要是因为旧主库下线,并不一定是因为宕机,也可能是因为网络隔离,如果新主库不自己生成新 Replication ID,而是沿用 Secondary ID,那在网络隔离恢复之后,就会有两个主库同时存在,我们也无法判断当前的从库复制的是哪个主库,毕竟 Replication ID 值都一样。

主从复制实战注意事项

在网上我们会看到一些文章说:用了 Redis 的主从复制之后,可以考虑关闭 Redis 的持久化来提升性能,原因是一主多从的场景中,全部 Redis 节点同时宕机的几率非常小。

这个观点比较理想化,实施起来会有各种各样的问题。首先,如果关闭了从库的持久化,就会导致从库重启之后,Replication ID 丢失,那 PSYCN2 的第一点优化就失效了,需要进行一次全量同步;其次就是关闭了主库的持久化之后,如果主库出现了重启的情况,就会导致主库中的数据全部丢失,这个时候从库依旧会同步主库,这就导致全部从库的数据都被清空的情况,如果关闭了主库的持久化,在主库宕机之后,一定不能自动拉起主库,而是要进行主从切;最后,如果 Redis 主从节点都部署在一个物理机房里面,一旦物理机房断电,也会出现数据全部丢失的问题。

从更高的维度看,主从复制和持久化解决的是不同维度的事情,主从复制解决的是横向扩展问题,而持久化解决的是内存数据意外丢失的问题。所以说,这个观点虽然看起来有一定道理,但是将两个维度的事情混为一谈了,想用一个方案解决两个维度的问题,落地就非常困难了。

下面我们再来看另一个问题,主从模式下,Redis 从库可以写入数据吗?

Redis 官方的建议是将从库配置成 Read-Only 的,也就是只读不写。如果我们把从库配置成可写的,就可能会导致主从数据不一致,即使说只是在从库存放一些与主库数据集完全没有交集的数据,还是会有一些意想不到的后果。比如,一主多从的架构中,主库的 Key 都是 “M_” 开头的 Key,我们把一个从库配置成可写的,然后通过客户端写入一些 “S_” 开头的 Key,如下图所示:

image.png

此时主从复制并不会影响到从库中 “S_” 开头的这些私有 Key,但是一旦出现主从切换,问题就来了。如果 Slave1 升级为主库,而且此时 Slave2 进行了一次全量同步,Slave2 这个从库里面的这些私有 Key 会被全部清理掉;如果 Slave2 升级为主库,那它里面的私有 Key 就会被所有客户端访问到,如果我们要操纵 “S_” 开头的 Key,就会导致这些 Key 传播到 Slave2 这个从库上,私有 Key 传播到了整个集群。

在作为缓存的时候,一般会给从库添加 replica-ignore-maxmemory no 配置,让从库不进行内存淘汰之类的操作,而是由主库感知内存使用情况,决定是否进行内存淘汰。内存淘汰产生的 DEL 命令会通过主从复制,发送给从库,进而控制从库的内存使用情况。如果从库可写,我们就需要给这个从库设置更大的内存来存放私有 Key,否则主库内存没满,从库内存先满了。

列举了上述各种坑之后,还是建议遵循 Redis 官方的指导,将 Redis 从库设置成 Read-Only 模式

总结

在这一节中,我们重点介绍了 Redis 主从同步的演进过程以及主从同步的核心逻辑。

  • 首先,我们介绍了 Redis 2.8 版本之前的全量同步逻辑。
  • 然后,介绍了 Redis 2.8 版本之后引入的部分同步,以及 PSYNC 命令,它可以帮助我们避免因网络闪断而触发全量同步。
  • 最后,我们介绍了 Redis 4.0 版本之后引入的 PSYNC2 优化,它主要是避免从库重启以及主从切换场景下触发不必要的全量同步。

下一节开始,我们将先以从库视角,深入分析 Redis 主从复制的核心原理和关键实现。