为什么要有主从复制
如果数据都存在同一台服务器上,假设遇到以下两种情况的任意一种:
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缓冲区里所记录的写操作命令发送给从服务器,从服务器 接收并执行这些新增加的写操作命令,这样数据就同步了。第一次同步也就完成了。
真的百分之百同步了吗?
:question: 如果在发送缓冲区的内容时,主服务器又执行了新的写操作,那不就又出现了数据不一致问题吗?
命令传播
从服务器 发送 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为:
$$ size=second \times WriteSizePerSecond $$
- second:断连后重新连接所需要的平均时间(单位:秒)
- WriteSizePerSecond:主服务器平均每秒产生的写命令数据量
解释完两个参数的含义后,就很好理解了。例如主服务器每秒产生2MB的写命令,从断连到重新连接平均需要10秒钟,那么 repl_backlog_buffer 的大小就不能低于20MB,另外为了保险起见,会将其设置为2倍,也就是40MB.
在配置文件中,修改 repl_backlog_size 参数即可修改环形缓冲区大小。
上面我们说的都是网络断连的情况,回到之前的问题:
:question: 如果在发送缓冲区的内容时,主服务器又执行了新的写操作,那不就又出现了数据不一致问题吗?
实际上,这些新的命令会写入到 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 参数。