跳至主要內容

主从模式

AruNi_Lu数据库Redis约 2996 字大约 10 分钟

本文内容

1. 为什么需要主从模式?

Redis 提供了 AOF 日志、RDB 快照,以及混合持久化的持久化方式,可以保障 Redis 的可用性。但是只有持久化是不够的,如果只运行了一个 Redis 实例,当这个实例宕机后,该 Redis 就无法向外提供服务了。

其实,Redis 的高可用性主要体现在两个方面,一是 数据尽量少丢失,二是 服务尽量少中断。所以只有持久化方式是不够的,还需要保证 一台 Redis 实例宕机后,还有其他的实例来继续提供服务,也就是 增加副本冗余量,将一份数据同时保存到多个实例上。

要将一份数据保存到多个实例上,就会涉及实例之间的数据一致性,还需要考虑读写操作都可以发给所有实例吗?

2. 主从库模式

Redis 提供了 主从库模式,以保证数据副本的一致,主从库之间采用的是 读写分离 的方式:

  • 读操作:主从库都可以接受;
  • 写操作:先在主库执行,然后主库再 将写操作同步给从库

image-20240103192043513

采用读写分离的好处在于,能很方便的保证主从库之间的数据一致性。如果主从库都可以进行写操作,那就要涉及到加锁来避免多实例间的并发修改问题,这会带来巨大的开销。而 只让写操作在主库上进行,就不用协调多个实例了,主库操作后会将最新的数据同步给从库,从而保证主从库之间的数据一致性。

这个写操作同步过程又会涉及到很多问题了,比如如何同步,是将数据一次性传给从库,还是分批?如果主从库之间的网络断开了,数据如何保持一致?

3. 主从库间的数据同步

主从库之间的数据同步分为第一次同步和后续同步。

3.1 第一次同步

启动多个 Redis 实例后,可以通过 replicaof 命令(Redis 5.0 之前使用 slaveof)形成主库和从库的关系,执行该命令后就会进行第一次同步,该同步过程分为 三个阶段

例如,现在有实例 1(ip: 172.16.19.3)和实例 2(ip: 172.16.19.5),在实例 2 上执行 replicaof 172.16.19.3 6379 后,实例 2 就变成了实例 1 的从库,接下来就会从实例 1 上同步数据。

这个同步过程有三个阶段,分别是 建立连接协商同步、主库发送 RDB 文件给从库以同步数据、主库发送增量命令给从库,大致过程如下图所示:

img

第一阶段:主从库建立连接,协商同步

该阶段主要是为 全量复制 做准备,从库通过 replicaof 命令与主库建立连接,通知主库要进行数据同步,主库回复后就可以开始同步了

从库会先发送 psync 命令,表示要进行数据同步,其中包含两个参数:

  • runID:唯一实例 ID,因为第一次不知道主库的实例 ID 是多少,所以 runID 为 "?";
  • offset:复制进度,第一次复制为 -1。

接着 主库会回复 FULLRESYNC {runID} {offset} 命令,告诉从库自己的实例 ID 和当前的复制进度 offset,从库收到后,就会记录下这两个参数。

FULLRESYNC 表示第一次复制采用全量复制,也就是会把主库的所有数据都复制给从库。

第二阶段:主库发送 RDB 文件给从库以同步数据

主从库之间的数据同步依赖 RDB 文件,具体来说,主库会执行 bgsave 命令,以生成 RDB 文件,然后发送给从库。从库收到后,会先清空当前数据库,再加载 RDB 文件

从库需要先清空当前数据库,是因为 可能之前从库已经有数据了,所以同步之前需要清空以 保证主从库之间的数据一致

主库执行 bgsave 期间,也是可以接受写操作的,这些增量写操作是没有记录在之前的 RDB 文件中的。为了保证主从库的数据一致性,主库会用专门的 replication buffer 记录这期间的增量写操作,在第三阶段发送给从库。

第三阶段:主库发送增量命令给从库

主库发送完 RDB 文件后,会把 replication buffer 中的增量写操作发送给从库,从库收到后执行这些操作

这样一来就实现了主从库的数据同步了。

3.2 后续同步

主从库完成了第一次同步后,后续的同步是通过维护一个 TCP 长连接来完成的

具体来说,主库和从库之间会保持一个 长连接,主库后续执行完写操作后,就会通过该连接将写命令传播给从库,从库也执行同样的命令,以保证主从库的数据一致性。

这个过程也被称为 基于长连接的命令传播,使用长连接就是为了避免频繁连接/断开带来的额外消耗,

4. 主从级联模式分担全量复制时的主库压力

通过分析第一次数据同步的过程会发现,主库在这次全量复制中有两个耗时操作:生成 RDB 文件和传输 RDB 文件

如果 从库数量很多,都和主库进行全量复制,这就会导致 主库忙于 fork 子进程生成 RDB 文件,而 fork 操作是会阻塞主线程的。此外,传输 RDB 文件也会占用主库的网络带宽。那如何分担主库的压力呢?

一个很好的方式就是 主从级联模式,即 “主 - 从 - 从” 模式,以这种级联的方式 将压力分散到从库上

实现起来也很简单,在部署主从模式时,可以选择一个内存资源、网络带宽较高的从库 A,用来级联其他从库,比如可以让三分之一的从库选择从库 A 作为主库,即执行 replicaof 从库A的IP 6379 命令。

这样就有三分之一的从库不直接与主库建立连接了,如下所示:

img

当然,这种方式可能会导致 后续基于长连接的命令传播耗时过长,级联的从库需要通过两次传播才能同步,导致数据不一致的时间变长。因此级联的层级不要太高,一般级联一层就可以了。

5. 主从库间的网络断了怎么办?

除了上面的正常情况,我们还需要考虑如果 主从库之间的网络连接断开了怎么办,这会导致数据的一致性得不到保证。

Redis 2.8 之前,如果主从库在后续同步的过程中出现了网络中断,从库就会重新和主库进行 全量复制,开销很大

Redis 2.8 开始,网络断开后,主从库会采用 增量复制 的方式继续同步,增量复制只需要同步网络断开期间的命令

我在 RDB 日志open in new window 文章中讲过,要实现增量快照,就需要额外的元数据去记录哪些记录被修改过,会带来不小的开销。主从库的增量复制同理,肯定也需要额外的开销,那 Redis 是怎么权衡的呢?

这里面的奥妙就在于 repl_backlog_buffer 这个缓冲区,这是一个 环形缓冲区,在主从集群中,主从库都会有这个缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置

可以发现,repl_backlog_buffer 是一个环形缓冲区,写完了就覆盖写,不会带来太大的额外开销。而 RDB 日志中的增量日志不能用环形缓冲区,这涉及到数据的丢失。

主库的 repl_backlog_buffer 随着不断的接收写操作,写的位置可能会比从库的 repl_backlog_buffer 远,类似与下图这样:

img

在命令传播过程中,主库的所有写命令除了传播给从库外,还会在 repl_backlog_buffer 中记录一份,缓存起来。当从库断连后,就可以 根据这个缓冲区中的位置,来判断哪些是增量数据

具体来说,主从库的 连接恢复后从库会先给主库发送 psync 命令,也会附带 runID 和 offset 参数,这个 offset 就是从库 repl_backlog_buffer 读取到的位置

主库收到命令后,就可以 根据从库 repl_backlog_buffer 读取到的位置,和自己 repl_backlog_buffer 写到的位置,判断出中间的增量数据是哪些,知道了增量数据是哪些后,还需要 靠 replication_buffer 将增量命令发送给从库

注意:这个 replication_buffer 是主库对于从库来说,为从库分配的一个内存 buffer,用于将写命令传播给从库的 buffer。因为 Redis 不管是和客户端通信,还是和从库通信,都会给 client 分配一个 buffer(从库也算一个 client),所有的数据交互都通过这个 buffer 进行:Redis 先把数据写到 buffer,然后再将 buffer 中的数据发到 socket 中,最后通过网络传输出去。主从模式下,这个 buffer 是专门为从库分配的 buffer,用于在后续同步中传播写命令,所以叫做 replication_buffer

整体过程如下图所示:

img

增量数据如下图所示(来源小林 coding):

图片

由于 repl_backlog_buffer 是一个环形缓冲区,所以 写满后会覆盖之前的写操作,如果 从库的读取速度比较慢或者断开时间太久,从库重新连接主库后就只能乖乖地进行一次 全量同步 了。

所以 repl_backlog_buffer 尽量要配置大一些,具体的计算公式是:环形缓冲区大小 = 主库写入命令速度 * 操作大小 - 主从库之间网络传输速度 * 操作大小。在实际情况下,考虑到可能会有 请求压力突增 的情况,通常需要把这个 缓冲空间扩大一倍,也就是要将 repl_backlog_buffer 设置为 环形缓冲区大小 * 2

例如,主库每秒写入 2000 个操作,每个操作大小为 2KB,网络每秒传输 1000 个操作。环形缓冲区大小 = 2000 * 2KB - 1000 * 2KB = 2000KB ≈ 2MB,则需要将 repl_backlog_buffer 设置为 4MB。

扩展:replication_buffer 有限制吗?

其实也是有的,毕竟主库不可能无限给 client 分配内存 buffer。

如果主库在向从库传播增量命令的过程中,从库处理的非常慢,就会导致 主库的 replication_buffer 堆积,导致消耗大量内存,此时主库会强制断开从库的连接

6. 参考文章

  • 《Redis 核心技术与实战》
上次编辑于: