Redis主从复制

为什么要有主从复制

如果数据都存在同一台服务器上,假设遇到以下两种情况的任意一种:

1.服务器宕机。在恢复期间,无法处理客户端请求;

2.服务器硬盘损坏,数据丢失;

都会造成一定的损失。因此,我们需要将数据备份到其它服务器上,这些服务器同样能够为客户端提供服务。这样一来,哪怕任意一台服务器出现了故障,还有其它服务器作为后备力量。

使用多台服务器后,又有新的问题:

1.如何同步数据,即保证数据一致性?

一台服务器接收到数据后,其它服务器是不知道的。如果不进行数据的同步,就会有两个问题:一是这台服务器出现故障后,数据就丢失了;二是客户端如果将请求打在了其它服务器上,那么客户端就无法获得它期望的数据。

2.任务怎么分配?

是每台服务器都既负责读又负责写,还是一部分负责读,另一部分负责写?

Redis提供了主从复制模式来解决上述问题。主从复制模式能保证数据的一致性,并且采用读写分离的方式完成了任务的分配。

主服务器 既负责读又负责写,当它处理写操作时会将写操作同步给 从服务器,就实现了数据的同步。从服务器一般只负责执行来自客户端的读操作,因为对于Redis来说,读操作发生的频率一般是比写操作更高的,另外还要执行从 主服务器 同步过来的写操作命令。

在上文中,我将"同步"一次加粗了,因为这个词说起来简单,但实际上并不简单。主服务器是如何将写操作同步给 从服务器的呢?在这个过程中,会不会遇到什么新问题呢?

建立主从关系

在同步之前,要处理一个最基本的问题:如何让两台互不关联的服务器相连?相连之后如何确定谁是主服务器,谁是从服务器?

我们可以使用replicaof命令建立两台服务器之间的主从关系。在Redis 5.0之前,使用slaveof命令可以达到相同效果。这个命令非常直观,我们知道slave在英语中表示"奴隶",replica在英语中表示"复制品",都有主从关系的含义在里面。

例如,我们现在希望让服务器B充当从服务器,让服务器A充当主服务器,那么我们可以在服务器B中执行如下命令:

复制代码
replicaof <服务器A的IP地址><服务器A的Redis端口号>

执行完毕后,服务器B就成为了服务器A的从服务器,接下来进行第一次同步。

第一次同步

第一次同步可以分为三个阶段:

1.建立连接,协商同步

2.主服务器 同步数据给 从服务器

3.主服务器 发送新的写操作命令给从服务器

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

执行完replicaof命令后,从服务器会给主服务器发送psync命令,告诉主服务器,自己希望进行数据同步。

psync命令包含两个参数,分别是 runID 和 offset :

  • runID:每个Redis服务器启动时都会自动生产一个随机的ID来标识自己。第一次同步时,从服务器不知道主服务器的 runID,所以它将第一个参数设置为问号。

  • offset:这个单词在MySQL中的分页查询比较常见,表示偏移量。在这里表示复制进度,实际上也是偏移量。由于此时还没有进行复制(同步),因此从服务器将第二个参数设置为-1.

主服务器收到命令后,会响应 FULLRESYNC 给从服务器。并在响应命令中带上 从服务器 请求的两个参数:自己的runID 和当前的复制进度 offset. 从服务器 接收到响应后,会记录这两个参数的值。FULLRESYNC 表示采用全量复制的模式,即 主服务器会把所有的数据复制给从服务器。

第一阶段的工作到这里就完成了,总结起来就是为接下来的全量复制做准备。

第二阶段:主服务器 同步数据给 从服务器

主服务器执行 BGSAVE 命令生成 RDB 文件,然后把 RDB 文件发送给 从服务器。从服务器 接收到 RDB 文件之后,会清空当前的数据,接着载入 RDB 文件中的数据。

在上次讲解 RDB 与 AOF 的时候,我们知道 BGSAVE 命令是父进程fork出了一个子进程来异步执行生成 RDB 文件的工作,并不会阻塞 Redis 主线程与处理客户端的命令。但在这期间,写操作的命令并没有记录到通过 BGSAVE 命令生成的 RDB 文件当中,也就是说又出现了数据不一致的问题。

为了解决因为生成 RDB 文件期间又有新加入的写操作命令导致的数据不一致问题,主服务器会在下面三个时期将收到的 写操作命令写入到 repl_backlog_buffer 环形缓冲区中,再复制到 replication buffer 缓冲区中:

  • 主服务器 生成 RDB 文件期间

  • 主服务器 发送 RDB 文件期间

  • 从服务器 加载 RDB 文件期间

这并不难理解,因为在这三个阶段当中,主服务器对数据进行的写操作,从服务器 是无法感知的,因此必须要将这三个阶段的数据单独保存起来。

第三阶段:主服务器发送新的写操作命令给从服务器

从服务器 成功接收并加载 RDB 文件后,意味着主要工作就做完了,它会给主服务器返回一个确认消息,类似于TCP三次握手中的ACK报文。主服务器收到确认消息后,就将最后的工作交给从服务器,也就是把新增加的写操作命令给从服务器。主服务器会将replication buffer缓冲区里所记录的写操作命令发送给从服务器,从服务器 接收并执行这些新增加的写操作命令,这样数据就同步了。第一次同步也就完成了。

真的百分之百同步了吗?

❓ 如果在发送缓冲区的内容时,主服务器又执行了新的写操作,那不就又出现了数据不一致问题吗?

命令传播

从服务器 发送 psync 命令,主服务器响应后,凭什么二者就能传输 RDB 文件了呢?

实际上是因为二者建立了 TCP 连接。完成第一次同步后,双方会维护这个 TCP 长连接。之所以是长连接,目的是规避频繁建立和释放 TCP 连接所带来的开销。后续主服务器可以通过这个 TCP 连接将 RDB 文件以及replication buffer缓冲区中的写操作命令发送给从服务器,使得双方的数据库状态相同。这个过程称之为基于长连接的命令传播

增量复制

在上面我们抛出了一个问题,如果在传输 replication buffer 缓冲区的新的写操作命令时,主服务器又执行了新的写操作命令,岂不是又有数据不一致的问题?

此外,网络不可避免会有延迟,甚至有断开的情况。那如果主从服务器之间的网络断开了,即无法进行命令传播了,那不也会导致数据不一致的问题?

网络恢复之后,又要如何保证主从服务器的数据一致性呢?

其实不难想到,我们只要进行一次 全量复制,即把主服务器内存中的所有数据再次打包发送给从服务器。这就是 Redis 2.8之前的解决方案。

但是这并不是我们希望的解决方案,因为数据量可能非常庞大。我们希望主服务器知道哪些是新增加的写操作命令,也就是希望主服务器知道哪些是增量,然后把增量复制给从服务器即可。在 Redis 2.8 之后,网络断开又恢复之后,主从服务器会采用增量复制的方式继续同步。

增量复制可以分为三步:

  • 恢复网络后,从服务器 发送 psync 命令给主服务器,此时由于二者之间已经有过数据的复制,因此 offset 参数不是-1;

  • 主服务器收到命令后,响应 CONTINUE 命令,告诉从服务器,接下来采用增量复制的方式进行数据的同步;

  • 主服务器将增量部分发送给从服务器,后者接收并执行这些命令

那么,主服务器怎么知道哪些是增量呢?主要依靠两个关键:

  • repl_backlog_buffer:一个环形缓冲区,用于主服务器找到未同步的数据;

  • replication offset:标记上面那个缓冲区的同步进度。主服务器使用 master_repl_offset 标记自己写到的位置,从服务器通过 slave_repl_offset 标记自己读到的位置。主服务器根据二者之间的差值进行判断。

repl_backlog_buffer 是什么时候被填充的?

实际上,在主服务器进行命令传播时,不仅会将写命令发给从服务器,也会将其写入到 repl_backlog_buffer 缓冲区中,这也意味着该缓冲区存放着最近的写命令。

网络恢复后,从服务器通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 与 slave_repl_offset 之间的差距。

  • 如果主服务器发现,从服务器 要读取的数据还在环形缓冲区内,就采用增量同步的方式;

  • 反之,采用全量同步的方式。因为此时,从服务器 想要的数据已经不在环形缓冲区内了,必须要复制内存中的所有数据。

  • 主服务器在环形缓冲区内找到增量后,就会将其复制到 replication buffer 缓冲区中。实际上在第一次同步的第一阶段中我们也可以发现,replication buffer 的作用就是缓存将要传播给从服务器的命令,只是一个临时中转站,数据不会久留在其中。环形缓冲区类似于你家的冰箱,replication buffer 缓冲区类似于出门购物的购物袋。

为什么会有 从服务器 想要的数据不在环形缓冲区内的情况?实际上并不难理解,环形队列是有容量限制的,达到上限后就会进行数据的覆盖。所以为了避免频繁使用全量同步的方式,我们应该让 repl_backlog_buffer 缓冲区的容量尽可能大一点,其理想值size为:

  • second:断连后重新连接所需要的平均时间(单位:秒)

  • WriteSizePerSecond:主服务器平均每秒产生的写命令数据量

解释完两个参数的含义后,就很好理解了。例如主服务器每秒产生2MB的写命令,从断连到重新连接平均需要10秒钟,那么 repl_backlog_buffer 的大小就不能低于20MB,另外为了保险起见,会将其设置为2倍,也就是40MB.

在配置文件中,修改 repl_backlog_size 参数即可修改环形缓冲区大小。

上面我们说的都是网络断连的情况,回到之前的问题:

如果在发送缓冲区的内容时,主服务器又执行了新的写操作,那不就又出现了数据不一致问题吗?

实际上,这些新的命令会写入到 repl_backlog_buffer 这个环形缓冲区内,然后跟着 replication buffer 中的命令一起通过 TCP 连接发送给从服务器。

那么新的问题来了,为什么我们要将新的命令先写入到repl_backlog_buffer 缓冲区内,再复制到 replication buffer 中呢?不能直接将新的命令放到 replication buffer 中吗?进一步再想想,为什么我们非得需要一个repl_backlog_buffer呢?

我们假设现在只有 replication buffer,假设它是一个长度固定的数组,因为如果长度可以无限长,那么已经被同步的数据就没办法移除了,就会占用很多内存。在生成、发送、载入 RDB 文件期间,新的写操作来了,主服务器将其保存在replication buffer中,然后发送给从服务器。发送,不能是直接将数组中的数据移出来,而是要复制一份再发送。因为如果在传输过程中丢包了,这个数据就再也找不到了。也就是说,在这个过程中,你必须要有一个地方存放复制出来的这个数据。那就又相当于你还是需要两个地方来存储,一个用来长期存储,一个用来存放临时的副本。这也是为什么我们需要一个repl_backlog_buffer用来长期存储,replication buffer用来临时存储。

但是又有新问题了,凭什么复制出来之后非得要保存到一个地方呢?复制出来不能直接发送吗?那不就不需要中间这个存放副本的地方了吗?

因为Redis是单线程的,所以 从服务器 加载RDB会阻塞,不能处理新命令。新命令到达 从服务器 之后也只会阻塞,实际上也就是换了个地方缓存。

那为什么不能直接缓存到从服务器呢?

由于阻塞,从服务器 Redis 不去读 socket ,导致 socket 接收缓冲区满了,TCP 窗口收缩为0,从服务器 TCP 协议栈向主服务器发送 窗口大小=0 的 ACK 报文,告诉他:哥们别发了,我要忙不过来了。而主服务器还想继续发送,但它发现对面窗口大小已经为0了,send()系统调用会阻塞,或者返回EAGAIN进行重试。但Redis是单线程的,线程就会卡在write或者send上,就无法处理客户端命令了。

分摊主服务器的压力

如果从服务器数量太多,每次主服务器都要与从服务器进行全量同步的话,就会有两个问题:

  • BGSAVE是父进程通过fork出子进程来生成 RDB 文件的,fork是阻塞操作,虽然阻塞时间不长。但如果主服务器数据非常庞大,加上从服务器数量也很庞大,就会导致主服务器花费大量时间阻塞在fork上,进而导致Redis无法处理正常请求;

  • 传输 RDB 文件会占用主服务器的网络带宽,对主服务器的响应造成影响;

因此,主服务器作为老板,在员工数量倍增时,应当优化组织架构,设置经理、主管等职位,减轻管理压力。从服务器也可以有自己的从服务器,它不仅接收来自主服务器同步过来的数据,也可以作为主服务器将自己的数据同步给自己的从服务器。

操作依然和建立连接时一样:

复制代码
replicaof <目标服务器的IP地址><目标服务器的Redis端口号>

这样一来,就可以指定目标服务器充当自己的主服务器了。

总结

主从复制的三种模式:全量复制、基于长连接的命令传播、增量复制。

建立连接后,由于双方完全不了解彼此,因此要进行全量复制。在全量复制中,生成 RDB 文件和发送 RDB 文件都是耗时的操作。当从服务器数量较多,应当让一些从服务器也拥有自己的从服务器,分摊主服务器的压力,让其能够专注于处理客户端的命令。在全量复制的过程中,如果有新的写操作命令,会先放到 repl_backlog_buffer 缓冲区中,再复制一份到 replicate buffer 缓冲区中,然后再发送给从服务器。

第一次同步完成后,双方通过基于 TCP 长连接的命令传播,实现数据的同步。数据不仅会发送给从服务器,也会存放在 repl_backlog_buffer 环形缓冲区中。

如果期间出现了网络断连,就需要增量复制。主服务器通过自己的写偏移量与从服务器通过 psync 命令传递的读偏移量,判断 从服务器请求的内容,即增量是否在环形缓冲区中。如果不在,就进行全量复制;如果在,就将增量复制给replication buffer,再发送给从服务器。

为了避免断连后频繁使用全量复制,应当调大环形缓冲区的容量,即 repl_backlog_size 参数。

相关推荐
进击的女IT3 小时前
Java使用poi-tl实现word模版渲染文本/图片
java·数据库·word
小江的记录本3 小时前
【Logback】Logback 日志框架 与 SLF4J绑定、三层模块、MDC链路追踪、异步日志、滚动策略
java·spring boot·后端·spring·log4j·maven·logback
随风,奔跑3 小时前
Spring Boot笔记
java·spring boot·笔记·后端
studyForMokey3 小时前
【Android面试】Handler专题
android·java·面试
阿奇__3 小时前
微信 H5 缓存控制:后端重定向 & 前端强制刷新
前端·缓存
huabiangaozhi3 小时前
SpringBoot + vue 管理系统
vue.js·spring boot·后端
大鹏说大话3 小时前
构建铜墙铁壁:Laravel 中间件实现基于 Redis 滑动窗口的速率限制
数据库
楼田莉子3 小时前
CMake学习:CMake在静态库工程场景上应用
开发语言·c++·后端·学习·软件构建
Holen&&Beer3 小时前
mysql-bind-mount-to-named-volume-migration
数据库·mysql·adb