
深入Redis系列(八)Redis高可用之主从复制详解

这篇文章我想聊聊redis的高可用,所谓高可用说简单点就是当单个服务节点故障时如何保障整体服务依旧可用。而单节点故障时整体服务依旧可用的前提是你的架构不止单个节点,而是有多个包含相同数据的节点,这样才能保证某个节点故障后请求打到剩余任意一个节点时都能正常提供服务。
主从复制是保持系统多个节点持有相同数据的实现方式,也是redis高可用的基础,看完这篇文章也许你可以掌握以下知识:
_ 为什么Redis需要主从复制; _
_ Redis主从复制的过程; _
_ Redis主从复制的三种方式; _
_ 主从节点通信和联系方式; _
除了上述几个常规知识点,主从复制中还存在几个容易忽略但关键的问题,假如面试官问到了这几个问题大家会如何应对?
-
Redis 为什么采用读写分离的冗余机制 ,或者说为什么不让从节点也接收客户端的写操作;
-
从库 数据 多导致全量复制阻塞主库处理命令怎么办;
-
为什么主从全量复制使用RDB而不使用AOF;
-
如果主库的某些key过期,但由于网络延迟导致从库的这些key没有删除该怎么解决;
-
如果让你设计一个主从复制的系统架构,其中会有什么难点,你会如何实现;
一、Redis主从节点如何实现数据一致?
前面我们说到,Redis保持高可用的 思路是 增 加 冗余副本 ,将一份数据同时保存在多个实例上。这样
即使有一个实例出现了故障,需要过一段时间才能恢复,其他实例也可以对外提供服务,不会影响业务使用。
但是我们必须要考虑一个问题:这么多副本,它们之间的数据如何保持一致呢?数据读写操作可以发给所有的实例吗?实际上,Redis
提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。也就是说,系统可以将读操作发送给从库,但是写操作只会发送给主库处理。

那么Redis为什么不将写操作发送给从库,而是由主库将写操作同步给从库呢?
很简单,因为如果客户 端对 同一个数据 k1 前后修改了三次, 每一次的 修改的值不同,假设 分别是 v1、v2 和 v 3,而且每次
修改请求都发送到不同的实例上,在不同的实例上执行,那么,这个数据在这三个实例上的副本就不一致了。
如果我们非要保持这个数据在三个实例上一致,就要涉及到加锁、实例间协商是否完成修改等一系列操作,但这会带来巨额的开销, 这对于追求极致速度的Redis来说,
当然是不太能接受的。
采用读写分离的好处就是,写操作只会发生在主库上,只要主库再把写操作同步给从库,那么数据的同步就是串行发生的,就可以在不协调三个实
例的情况下自然的保证主从库的数据一致。
至此我们解决了文章开头的第一个问题:为什么Redis采用读写分离的方式 实现主从冗余。
那么,主从库同步是如何完成的呢?主库数据是一次性传给从库,还是分批同步?
先别急,名分很重要,谁当老大谁当小弟很重要。
在同步发生之前,首先需要指定一个Redis实例作为主库,其他Redis实例作为从库。只需要在Redis实 例的客户端上执行“ replicaof
主库ip 端口 ”命令就能让当前Redis实例成为从库,并且让主库和该从库建立联系。
名分确立之后,主从同步就可以开始了。
Redis主库和从库创建之后,从库和主库的数据大相径庭,因此主库第一次同步数据给从库时,会将自己所有的数据同步给从库,并将从库的所有旧数据清空,这是一项大工程,会通过一个三阶段的全量同步来完成。

第一阶段是主从库间建立连接、协商同步的过程。在这一步,从库和主库建立起连接,给主库发送 psync
命令,表示要进行数据同步,主库确认回复后,主从库间就可以开始同步了。
主库根据 psync 命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。
runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将
runID 设为“?”。
offset,此时设为 -1,表示第一次复制。
主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上主库 runID 和主库目前的复制进度 offset 给从库。
从库收到响应后,会记录下这两个参数。
第二阶段,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库在通过 replicaof
命令开始和主库同步前,可能保存了其他数据,因此从库会先清空当前数据库,然后加载 RDB 文件。
在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。
但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication
buffer,记录 RDB 文件生成后收到的所有写操作。
第三阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库,也就是把此时 replication buffer
中的修改操作发给从库,从库再重新执行这些操作。这样主从库就实现初次同步了。
在上述三个阶段中,初次同步的过程中开销最大的操作是什么?
答案是生成RDB和传输RDB的过程。
RDB包含了主库所有键值的内存镜像,主库需要fork子进程生成RDB文件。fork 操作会阻塞主线程处理正常请求,从而导致主库响应 客户端
的请求速度变慢。
传输 RDB
文件也会占用主库的网络带宽,刚刚我们也说了,RDB文件是一个包含主库所有键值对的内存镜像,因此RDB文件可能会很大,传输这么大的RDB同样会给主库的网络带宽使用带来压力。
假如从库不止一两个而是更多个,那么上述的这些开销也会放大,导致主库所在的redis节点不堪负重。那么,有没有好的解决方法可以分担主库压力呢?
当然是有的,我们可以让一部分从库给另一部分从库做全量复制,形成“主-从-从”的复制模式,将主库的复制压力分摊到一部分从库上,就像是老师管学生管不过来,就让设立班干部,让学生管学生。
具体来说,在部署主从集群的时候,可以手动选择一个从库,比如选择内存资源配置较高的从库作为中间层从库。然后再选择一些从库(例如三分之一的从库),在这些从库上执行
“replica of 中间层从库的IP 6379”,让它们和刚才中间层的从库,建立起主从关系。
这样的话,中间层从库就分担了主库的部分复制压力了 。

至此我们解决了文章开头的第二个问题: 从库 数据 多导致全量复制阻塞主库处理命令怎么解决 。
当主从节点完成了第一次同步之后,后续向主库发送的写命令,主库自己执行完这些写命令之后,就会将这个写命令广播给它的直接下级从库。从节点在执行replicaof命令的时候,就与主节点建立了长连接,主节点正式通过这个长连接向从节点发送后续的写命令。
至此,主节点和从节点就实现了首次和后续的数据一致。
不知道大家有没有发现 ,主从节点全量复制的时候,主节点是传递RDB文件给从节点,而不是传递AOF文件给从节点。 **
为什么主从全量复制使用RDB而不使用AOF ** ?
1.
RDB文件内容是经过压缩的二进制数据,文件很小。而AOF文件记录的是每一次写操作的命令,写操作越多文件会变得很大,其中还包括很多对同一个key的多次冗余操作(例如,set
a 1, set a 2, set a 100)。
在主从全量数据同步时,由于RDB文件较小,传输RDB文件可以尽量降低对主库机器网络带宽的消耗。
对于从库而言,因为RDB文件存储的都是二进制数据,从库直接按照RDB协议解析还原数据即可,速度会非常快,而AOF需要依次重放每个写命令,这个过程会经历冗长的处理逻辑,恢复速度相比RDB会慢得多,所以使用RDB进行主从全量复制的开销比AOF低。
2、假设要使用AOF做全量复制,意味着必须打开AOF功能,打开AOF就要选择文件刷盘的策略,选择不当会严重影响Redis性能。而RDB只有在需要定时备份和主从全量复制数据时才会触发生成一次快照。而在很多丢失数据不敏感的业务场景,其实是不需要开启AOF的。
至此我们解决了文章开头的第三个问题: 为什么主从全量复制使用RDB而不使用AOF 。
在95%的情况下,Redis的主从节点保持这个状况
正常的工作下去。但是当网络不稳定的时候,主从节点的网络会断开,主节点接收到的写命令不能向从节点发送,主从节点的数据开始不一样了,这该怎么办?
二、网络断开恢复后怎么让主从节点数据恢复一致?
主从节点恢复连接之后,双方开始捣腾数据使双方数据恢复一致。
需要说明的是,redis是查不到主节点和从节点相差了多少key-value数据内容,只能查得到从节点比主节点少执行了多少写命令。
那么为了让双方的数据恢复一致,需要经历几步呢?
首先,redis需要知道主节点共执行了多少字节的写命令,还要知道从节点 执行 了多少字节的写命令
。这样redis才知道,从节点和主节点相差了多少字节的写命令。
其次,redis需要将从节点和主节点相差的这些写命令从主节点拿出来,发送给从节点执行。因此相差的这些写命令要暂时存在主节点的某个地方才行。
实际上,redis节点会用一个叫做“ 复制偏移量 ”的概念表示本节点执行了多少字节的写命令。
同时,主节点会将这些执行过的写命令暂存在一个叫做“ 复制及压缓冲区
”的地方,当主从节点的写命令字节数有差异时,主节点就可以从这个缓冲区中将相差的那部分写命令发送给从节点。
下面正式介绍这几个概念。
1. 复制偏移量
主节点和从节点会分别维护一个复制偏移量。
对于主节点来说 ,复制偏移量就是自己传输了多少个 字节 给从节点 ,这些字节是写命令的字节数 。
对于从节点来说,复制偏移量就是自己接收了多少个字节的主节点传输过来的写命令。
主节点 每次向从节点 传播N个字节的数据时,就将自己的复制偏移量的值加上N。 从节点 每次收到主节点
传播来的N个字节的数据时,就将自己的复制偏移量的值加上N。
正常来说,主从节点的复制偏移量应该是保持动态一致的,说明主从节点的数据是相同的。

一旦因为网络闪断,而导致主库的命令发送不到从库上的时候,主库和从库的复制偏移量就会不相同。

2. 复制积压缓冲区 repl_backlog_buffer
复制积压缓冲区是由主服务器维护的一个固定长度 先进先出的环形 队列,默认大小为1MB。
当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令写入 到复制积压缓冲区里。

需要注意的是,主节点的复制积压缓冲区是一个固定大小的环形队列,里面只会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量。

有了这些作为基础,主从节点重新连接上以后就能够进行数据恢复了,数据恢复过程如下。
当从服务器重新连上主服务器时,从服务器会通过PSYNC命令将自己的复制偏移量offset
发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作。
假设复制积压缓冲区的首部偏移量为100和尾部偏移量为3000;
a.如果 offset 偏移量的数据仍然存在于复制积压缓冲区里面 (即 100 <= offset <= 3000 ) ,
说明从节点与主节点的写命令相差不会很多了, 主服务器将对从服务器执行部分同步操作。
接下来,主节点会将复制积压缓冲区中 3000-offset 的那部分写命令传输给从节点,让从节点执行,然后主从节点的数据就会恢复一致。
b.相反,如果offset偏移量之后的数据已经不存在于复制积压缓冲区 (即 offset < 100 )
,说明从节点与主节点的写命令相差太多了,多到复制积压缓冲区已经记不住那么多写命令,从而导致有一部分较老的写命令被较新的写命令给覆盖掉了。此时主服务器将对从服务器执行全量同步操作。
不知道大家有没有发现,因为 复制积压缓冲区 是一个环形缓冲区,在缓冲区写满后,主库会继续往这个缓冲区写入。此时,就会覆盖掉缓冲区之前写入的操作。
因此,即使在网络不断开的情况下,如果在业务高峰期,写命令爆发的情况下,写操作由主库传输到从库的速度比写操作从客户端写入到主库的速度慢,就有可能导致缓冲区中从库还未读取的写操作被主库新写的操作覆盖了,导致主从库间的数据不一致,并且引发全量复制,而我们都知道,全量复制的开销很大,可能影响业务读写。
为了避免这一情况,我们可以调整 repl_backlog_size 这个参数来控制复制积压缓冲区的大小。 这个参数和复制积压缓冲区
所需的缓冲空间大小有关。
缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。
在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这就是
repl_backlog_size 参数的最终值。
举个例子,如果主库每秒写入 2000 个操作,每个操作的大小为 2KB,网络每秒能传输 1000 个操作,那么有 1000 个操作需要缓冲起来,这就至少需要
2MB 的缓冲空间。 为了应对可能的突发压力,我们最终把 repl_backlog_size 设为 4MB。
这样一来,增量复制时主从库的数据不一致风险就降低了。
到现在为止,一切好像都很完美。桥豆麻袋,是否还有什么因素会导致主从节点的数据不一致呢?
还真有,记不记得redis是可以为key设置过期时间的。当key过期的时候,主库是不会马上知道这个key过期的。此时主库会有两种删除过期key的策略。
a.惰性删除:服务器不会主动删除数据,只有当客户端查询某个数据时,服务器判断该数据是否过期,如果过期则删除。
b.定期删除:服务器执行定时任务删除过期数据,删除的频率和执行时间可以通过配置文件配置。
在主从复制场景下,从节点不会主动删除数据,而是由主节点控制从节点中过期数据的删除。如此一来,主从节点就会因为数据过期而导致主从节点数据不一致。由于主节点的惰性删除和定期删除策略,都不能保证主节点及时对过期数据执行删除操作。因此,当客户端通过Redis从节点读取数据时,很容易读取到已经过期的数据。
不过好在Redis
3.2中,从节点在读取数据时,增加了对数据是否过期的判断:如果该数据已过期,则不返回给客户端;将Redis升级到3.2可以解决因数据过期而导致的主从节点数据不一致的问题。
** 至此 我们解决了文章开头的第四 个问题: 如果主库的某些key过期,但由于网络延迟导致从库的这些key没有删除该怎么解决
。不用解决,redis已经帮我们解决好了! **
如果面试官问到任何设计一个主从复制的架构以及其中可能会遇到的问题,redis的主从架构就是一个很好的借鉴。
主从节点初次通信确定谁是主谁是从;
主从节点通过长连接通信,通过心跳检测判断网络是否闪断以及从节点是否发生故障;
初次同步使用全量同步,后续同步通过长连接传输写命令;
全量同步过程中新写的数据怎么存储和发送;
主从节点连接断开导致的数据不一致的解决方案;
复制偏移量 和 复制积压缓冲区的设计 等等;
至此我们解决了文章开头的第五个问题:如果让你设计一个主从复制的系统架构,其中会有什么难点,你会如何实现。遇到这个问题,用学到的redis主从同步的知识放心大胆的侃侃而言就行。
至此,一切貌似又又又很完美了,然而并没有。
回到我们的初衷,Redis主从同步的目的是高可用,通过主从冗余相同的数据保证单节点故障不会发生服务不可用的尴尬情况。
但是,仅仅通过主从复制,是无法完全保证高可用的—— 从 节点 发生宕机 , 在从节点恢复期间, 主节点 依旧可以提供读写服务
。但是如果是主节点发生宕机,在主节点恢复期间,从节点是不能提供写服务的。
难道就只能任由写服务瘫痪吗,怕不是想被老板炒鱿鱼哦。
这个时候,redis提供了Sentinel哨兵协助剩余的从节点进行选举晋升为主节点提供写服务。 不过 相信大家坚持看到这里应该也快看吐了,
限于篇幅问题 , 下一节再介绍 哨兵 机制 。
如果本文对大家有帮助,麻烦大家动动小手点个免费的“赞”或“在看”,大家的鼓励就是阿沛持续更新的动力~

