无题
在大流量、高并发的场景中,我们一般不会只有一个单点作为存储,而是把存储做成一个分布式的系统,例如,我们常见的 MySQL 主从结构、Redis 主从结构、Redis Cluster、Memcache 集群或者 TiKV 这种基于 Raft 协议的集群。
Redis 有多种集群搭建方式,比如,主从模式、哨兵模式、Cluster 模式。本模块,我们就重点来介绍一下 Redis 主从模式的相关内容。
在使用 Redis 主从复制模式的时候,一般会搭建多个从库(Slave),从库只支持读请求的处理,用来分摊主库(Master)的读压力,主库(Master)只有一个,只专注于处理写请求,或者同时支持读写,这样就可以实现读写分离,降低主库的读压力,也实现了读请求在各个 Redis 节点之间的负载均衡。这样,我们就得到了 Redis 主从模式的核心架构,如下图所示:
正如前面所说,Redis 主从模式还解决了单点的问题。Redis 主库在进行修改操作的时候,会把相应的写入命令近乎实时地同步给从库,从库回放这些命令,就可以保证自己的数据与主库保持一致。那么,当主库发生宕机的时候,我们就可以将一个从库升级为主库继续提供服务;当一个从库宕机的时候,主库依旧可以处理写请求,其他从库依旧可以支持读请求,所以并不会影响整个 Redis 服务的读写。所以说,Redis 主从模式解决了 Redis 单点的问题。
主从复制原理概述
Redis 中的主从复制协议有多次升级,所以这里我们从 2.8 版本开始一步步介绍 Redis 主从复制协议的升级和优化过程。
在 Redis 2.8 版本之前,主从复制的核心流程如下:
这里简单描述一下上图的流程。首先,在从库启动之后,会根据配置主动请求主库,建立网络连接。连接完成之后,从库会向主库发送 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 的状态。
通过这个例子,我们可以很明显地看出,通过 Replication ID 和 Replication Offset 的组合,就可以确定一个数据集的版本。
弄清了 Replication ID 和 Replication Offset 这两个概念的本质之后,我们就可以开始来看 Redis 2.8 引入的部分同步功能了,它的核心流程如下。
从库与主库网络连接建立成功之后,从库不会再发送 SYNC 命令,而是发送
PSYNC 命令来发起部分同步请求,在这个请求中,会包含一个数据集的唯一标识,也就是 Replication ID 以及从库数据集的状态,也是 Replication Offset。主库在收到 PSYNC 命令之后,会先检查请求中的 Replication ID 是否与当前主库一致。如果 Replication ID 一致,会继续检查请求中的 Replication Offset 是否还在主从复制缓冲区中。主从复制缓冲区是主库专门为主从复制开辟的一块命令缓冲区,用来缓存主库已经执行、但未发送到从库的命令。
如果这个偏移量对应的命令还在主从复制缓冲区中,说明从库落后的数据并不多,从库通过执行下图蓝色区域的命令就可以追上主库。这个时候,从库只需要从自己的 Replication Offset 位置,也就是下图中的箭头位置,开始同步命令即可,这种同步方式也称为“部分同步”:
如果这个偏移量对应的命令已经不在主从复制缓冲区中了,说明从库落后的数据非常多了,需要进行一次全量同步,也就回落到之前的方案:先进行 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,主要作用体现在两个方面。
在 Redis 服务关闭的过程中,会进行一次 RDB 持久化,此时会将内存中维护的 Replication ID 和 Replication Offset 写入到 AUX 部分中。在 Redis 重启过程中,会加载这个 RDB 文件,也就会从 AUX 部分恢复原来的 Replication ID 和 Replication Offset ,如果恢复的这两个值依旧符合前面介绍的部分同步的条件,就无需进行全量同步了,只需要进行部分同步即可。
为了避免主从切换带来的、不必要的全量同步,在 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,如下图所示:
此时主从复制并不会影响到从库中 “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 主从复制的核心原理和关键实现。
