Redis核心技术与实战笔记4-数据同步以及哨兵机制

目录

我们通常说Redis具有高可靠性,具有两层含义:

  • 一是数据 尽量少丢失
  • 二是服务尽量少中断

AOF 和 RDB 保证了数据尽量少丢失;对于尽量少中断则是增加副本冗余量,将一份数据同时保存在多个实例上。 这里就涉及到了多个实例之间数据如何保持一致。

主从同步

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

  • 读操作:主库、从库都可以接收
  • 写操作:首先到主库执行,然后,主库将写操作同步给从库。

主从库同步如何完成

BsR35D.jpg

主从库同步的过程主要就是上图中的三步:

第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开 始同步了。 具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数 来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。

runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实 例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设 为“?”。 offset,此时设为 -1,表示第一次复制。

主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库 目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。

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

第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个 过程依赖于内存快照生成的 RDB 文件。

具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把 当前数据库清空。

在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件 中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。

第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修 改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

“主 - 从 - 从”

通过“主 - 从 - 从”模式将主库生成 RDB 和传输 RDB 的压力, 以级联的方式分散到从库上。从而解决如下问题:

如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生 成 RDB 文件,进行数据全量同步。fork 这个操作会阻塞主线程处理正常请求,从而导致 主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样 会给主库的资源使用带来压力。

主从库完成了全量复制,它们之间就会一 直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这 个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

主从库网络断开怎么办

在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重 新进行一次全量复制,开销非常大。从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。

主库会把收到的写操作命令,写入 replication buffer,同时也 会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己 已经读到的位置。

主库的所有写命令除了传播给从库之外,都会在这个repl_backlog_buffer中记录一份,缓存起来,只有预先缓存了这些命令,当从库断连后,从库重新发送psync $master_runid $offset,主库才能通过$offsetrepl_backlog_buffer中找到从库断开的位置,只发送$offset之后的增量数据给从库即可。

刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接 收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这 个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新 写操作越多,这个值就会越大。

同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。

Bsf8cd.jpg

主从库的连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的 slave_repl_offset 发给主库,主库会判断自己的 master_repl_offsetslave_repl_offset 之间的差距。

在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,master_repl_offset 会大于 slave_repl_offset。此时,主库只用把 master_repl_offsetslave_repl_offset 之间的命令操作同步给从库就行。

因为 repl_backlog_buffer 是一个环形缓冲区,所以在 缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速 度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库 间的数据不一致。可以可以调整 repl_backlog_size

缓冲空间的计算公式是:缓冲空间大小 = 主库 写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑 到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值。

下面这个简单的例子不错: 如果主库每秒写入 2000 个操作,每个操作的大小为 2KB,网络每秒能传输 1000 个操作,那么,有 1000 个操作需要缓冲起来,这就至少需要 2MB 的缓冲空间。否 则,新写的命令就会覆盖掉旧操作了。为了应对可能的突发压力,我们最终把 repl_backlog_size 设为 4MB。

关于几个概念: 1、repl_backlog_buffer:是为了从库断开之后,如何找到主从差异数据而设计的环形缓冲区,从而避免全量同步带来的性能开销。如果从库断开时间太久,repl_backlog_buffer环形缓冲区被主库的写命令覆盖了,那么从库连上主库后只能乖乖地进行一次全量同步,所以repl_backlog_buffer配置尽量大一些,可以降低主从断开后全量同步的概率。而在repl_backlog_buffer中找主从差异的数据后,如何发给从库呢?这就用到了replication buffer

2、replication buffer:Redis和客户端通信也好,和从库通信也好,Redis都需要给分配一个 内存buffer进行数据交互,客户端是一个client,从库也是一个client,我们每个client连上Redis后,Redis都会分配一个client buffer,所有数据交互都是通过这个buffer进行的:Redis先把数据写到这个buffer中,然后再把buffer中的数据发到client socket中再通过网络发送出去,这样就完成了数据交互。所以主从在增量同步时,从库作为一个client,也会分配一个buffer,只不过这个buffer专门用来传播用户的写命令到从库,保证主从数据一致,我们通常把它叫做replication buffer

哨兵机制

在 Redis 主从集群中,哨兵机制是实现主从库自动切换的关键机 制,它有效地解决了主从复制模式下故障转移的三个问题:

  • 确定主库是否真的挂了?
  • 该选择哪个从库作为主库?
  • 怎么把新主库的相关信息通知给从库和客户端?

哨兵机制的基本流程

哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运 行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。

监控 监控是指哨兵进程在运行时,周期性地给所有的主从库发送 PING命令, 检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就 会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的 PING 命 令,哨兵就会判定主库下线,然后开始自动切换主库的流程。

选主 主库挂了以后,哨兵就需要从很多个从库 里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群 里就有了新主库。

通知 在执行通知任务时,哨兵会把新主库的连接信息 发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时, 哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

哨兵如何判断主库是否处于下线状态

哨兵对主库的下线判断有“主观下线”和“客观下线”两种。

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状 态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记 为“主观下线”。

如果检测的是从库,那么,哨兵简单地把它标记为“主观下线”就行了,因为从库的下线 影响一般不太大,集群的对外服务不会间断。

但是如果是检测到主库超时了,并不能直接标记“主观下线”, 因为这里可能会有误判的情况,毕竟一旦启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。

所以哨兵这里也要引入集群,通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群,避免单个哨兵因为自身网络状况不好,而误判。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误 判率也能降低。

在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已 经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一 个客观事实了。这个判断原则就是:少数服从多数。同时,这会进一步触发哨兵开始主从 切换流程。

如下图所示,Redis 主从集群有一个主库、三个从库,还有三个哨兵实例。在图片的左 边,哨兵 2 判断主库为“主观下线”,但哨兵 1 和 3 却判定主库是上线状态,此时,主库 仍然被判断为处于上线状态。在图片的右边,哨兵 1 和 2 都判断主库为“主观下线”,此 时,即使哨兵 3 仍然判断主库为上线状态,主库也被标记为“客观下线”了。

Bg1sTx.jpg

哨兵选择哪个从库实例作为主库

一般来说,可以把哨兵选择新主库的过程称为“筛选 + 打分”。简单来说,我们在多个从库 中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则, 给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:

Bg1hXd.jpg

筛选条件

在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。 配置项 down-after-milliseconds * 10。其中,down-after- milliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-after- milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连 了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主 库。

打分

打分三步骤:

  1. 从库优先级
  2. 从库复制进度
  3. 从库 ID 号

优先级最高的从库得分高:可以通过 slave-priority 配置项,给不同的从库设置不同优先级。 和旧主库同步程度最接近的从库得分高:选择和旧主库同步最接近的那个从库作为主库,那么,这个新主 库上就有最新的数据。 ID 号小的从库得分高:每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库 时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最 高,会被选为新主库。

哨兵集群

通常情况下,哨兵也是集群部署,即使有哨兵挂掉了,其他哨兵还可以继续协作完整主从库切换工作,以及判定主库是不是处于下线状态,选择新主库,以及 通知从库和客户端。

但是哨兵是如何知道其他哨兵的,毕竟在配置哨兵时:sentinel monitor <master-name> <ip> <redis-port> <quorum> 似乎并没有其他哨兵的信息。

基于 pub/sub 机制的哨兵集群组成

哨兵实例之间可以相互发现,基于 Redis 提供的 pub/sub 机制,也就是发布 / 订阅 机制。 哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信 息(IP 和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当 多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端 口。

在主从集群中,主库上有一个名为__sentinel__:hello的频道,不同哨兵就是通过 它来相互发现,实现互相通信的。

B2btkF.jpg

哨兵除了彼此之间建立起连接形成集群外,还需要和从库建立连接。这是因为,在哨兵的 监控任务中,它需要对主从库都进行心跳判断,而且在主从库切换完成后,它还需要通知 从库,让它们和新主库进行同步。

哨兵向主库发送 INFO 命令来知道从库 IP 地址和端口的

B2bT78.jpg

通过 pub/sub 机制,哨兵之间可以组成集群,同时,哨兵又通过 INFO 命令,获得 了从库连接信息,也能和从库建立连接,并进行监控了。

基于 pub/sub 机制的客户端事件通知

从本质上说,哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操 作,只是完成监控、选主和通知的任务。

每个哨兵实例也提供 pub/sub 机制,客户 端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过 程中的不同关键事件。下图是重要的频道以及事件

B2qmB6.jpg

有了这些事件通知,客户端不仅可以在主从切换后得到新主库的连接信息,还可以监控到 主从库切换过程中发生的各个重要事件。这样,客户端就可以知道主从切换进行到哪一步 了,有助于了解切换进度。

哨兵选举

确定由哪个哨兵执行主从切换的过程, 同样也是一个投票的过程

B2qsvn.jpg

任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相 当于赞成票,N 相当于反对票。

一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。这个所需的赞成票数是通过哨兵配置文件中的 quorum 配置项设定的。例如,现在有 5 个哨兵, quorum 配置的是 3,那么,一个哨兵需要 3 张赞成票,就可以标记主库为“客观下线”了。这 3 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所 有其他哨兵进行投票。这个投票过程称为“Leader 选举”。因为最终执行主从切换的哨兵 称为 Leader,投票过程就是确定 Leader。

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的 赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

需要注意的是,如果哨兵集群只有 2 个实例,此时,一个哨兵要想成为 Leader,必须获得 2 票,而不是 1 票。所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切 换的。因此,通常我们至少会配置 3 个哨兵实例。这一点很重要,你在实际应用时可不能忽略了。

注意:要保证所有哨兵实例的配置是一致的,尤其是主观下线 的判断值 down-after-milliseconds 因为这个值在不同的哨兵实例上配置不一致,导致哨兵集群一直没有对有故障的主库 形成共识,也就没有及时切换主库,最终的结果就是集群服务不稳定

面试题

主从库间的数据复制同步使用的是 RDB 文件,前面我们学习过,AOF 记录的操作命令更全,相比于 RDB 丢失的数据更少。 那么,为什么主从库间的复制不使用 AOF 呢?

一个不错的答案:

  • RDB文件内容是经过压缩的二进制数据(不同数据类型数据做了针对性优化),文件很小。而AOF文件记录的是每一次写操作的命令,写操作越多文件会变得很大,其中还包括很多对同一个key的多次冗余操作。在主从全量数据同步时,传输RDB文件可以尽量降低对主库机器网络带宽的消耗,从库在加载RDB文件时,一是文件小,读取整个文件的速度会很快,二是因为RDB文件存储的都是二进制数据,从库直接按照RDB协议解析还原数据即可,速度会非常快,而AOF需要依次重放每个写命令,这个过程会经历冗长的处理逻辑,恢复速度相比RDB会慢得多,所以使用RDB进行主从全量同步的成本最低。
  • 假设要使用AOF做全量同步,意味着必须打开AOF功能,打开AOF就要选择文件刷盘的策略,选择不当会严重影响Redis性能。而RDB只有在需要定时备份和主从全量同步数据时才会触发生成一次快照。而在很多丢失数据不敏感的业务场景,其实是不需要开启AOF的。

主从库切换是需要一定时间的,在这个切换过程中,客户端能否正常地进行请求操作呢?如果想 要应用程序不感知服务的中断,还需要哨兵或需要客户端再做些什么?

一个不错的答案: 如果客户端使用了读写分离,那么读请求可以在从库上正常执行,不会受到影响。但是由于此时主库已经挂了,而且哨兵还没有选出新的主库,所以在这期间写请求会失败,失败持续的时间 = 哨兵切换主从的时间 + 客户端感知到新主库 的时间。

哨兵提升一个从库为新主库后,哨兵会把新主库的地址写入自己实例的pubsub(switch-master)中。客户端需要订阅这个pubsub,当这个pubsub有数据时,客户端就能感知到主库发生变更,同时可以拿到最新的主库地址,然后把写请求写到这个新主库即可,这种机制属于哨兵主动通知客户端。

如果客户端因为某些原因错过了哨兵的通知,或者哨兵通知后客户端处理失败了,安全起见,客户端也需要支持主动去获取最新主从的地址进行访问。

所以,客户端需要访问主从库时,不能直接写死主从库的地址了,而是需要从哨兵集群中获取最新的地址(sentinel get-master-addr-by-name命令),这样当实例异常时,哨兵切换后或者客户端断开重连,都可以从哨兵集群中拿到最新的实例地址。

一般Redis的SDK都提供了通过哨兵拿到实例地址,再访问实例的方式,我们直接使用即可,不需要自己实现这些逻辑。当然,对于只有主从实例的情况,客户端需要和哨兵配合使用,而在分片集群模式下,这些逻辑都可以做在proxy层,这样客户端也不需要关心这些逻辑了,Codis就是这么做的。

假设有一个 Redis 集群,是“一主四从”,同时配置了包含 5 个哨兵实例的集群, quorum 值设为 2。在运行过程中,如果有 3 个哨兵实例都发生故障了,此时,Redis 主 库如果有故障,还能正确地判断主库“客观下线”吗?如果可以的话,还能进行主从库自 动切换吗?此外,哨兵实例是不是越多越好呢,如果同时调大 down-after-milliseconds 值,对减少误判是不是也有好处呢?

关于这个问题是可以实际搭建环境测试一下:

1、哨兵集群可以判定主库“主观下线”。由于quorum=2,所以当一个哨兵判断主库“主观下线”后,询问另外一个哨兵后也会得到同样的结果,2个哨兵都判定“主观下线”,达到了quorum的值,因此,哨兵集群可以判定主库为“客观下线”。

2、但哨兵不能完成主从切换。哨兵标记主库“客观下线后”,在选举“哨兵领导者”时,一个哨兵必须拿到超过多数的选票(5/2+1=3票)。但目前只有2个哨兵活着,无论怎么投票,一个哨兵最多只能拿到2票,永远无法达到多数选票的结果。

哨兵实例是不是越多越好?

并不是,哨兵在判定“主观下线”和选举“哨兵领导者”时,都需要和其他节点进行通信,交换信息,哨兵实例越多,通信的次数也就越多,而且部署多个哨兵时,会分布在不同机器上,节点越多带来的机器故障风险也会越大,这些问题都会影响到哨兵的通信和选举,出问题时也就意味着选举时间会变长,切换主从的时间变久。

调大down-after-milliseconds值,对减少误判是不是有好处?

是有好处的,适当调大down-after-milliseconds值,当哨兵与主库之间网络存在短时波动时,可以降低误判的概率。但是调大down-after-milliseconds值也意味着主从切换的时间会变长,对业务的影响时间越久,我们需要根据实际场景进行权衡,设置合理的阈值。