Redis 高可用

一、主从复制

1.1 主从复制概述

主从复制是 Redis 高可用服务最基础的保证,实现方案就选择一个服务器作为 master,其他的服务器作为 slaver,一主多从,读写分离。这里注意一下,主从服务器之间的命令复制是异步进行的,不会等到从服务器都同步完才向客户端返回结果。所以,无法实现强一致性(数据时时刻刻都是一致的),只能达到最终一致性。

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

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

1.2 主从库第一次同步

先来看看主从库间的第一次同步是如何进行的,这也是 Redis 实例建立主从库模式后的规定动作。当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。

例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令:

1
replicaof 172.16.19.3 6379

实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据。接下来,我们就要学习主从库间数据第一次同步的三个阶段了。

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

第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。

具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数:

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

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

这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

1.2.2 第二阶段:主库同步数据给从库

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

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

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

1.2.3 第三阶段:发送新写命令

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


1.3 主从库间网络断了怎么办?

1.3.1 增量复制机制

在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。听名字大概就可以猜到它和全量复制的不同:全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。

1.3.2 repl_backlog_buffer 缓冲区

那么,增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于 repl_backlog_buffer 这个缓冲区。

当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。

repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

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

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

1.3.3 增量复制流程

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

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

1.3.4 缓冲区大小设置

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

因此,我们要想办法避免这一情况,一般而言,我们可以调整 repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。

缓冲空间的计算公式:

1
缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小

在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即:

1
repl_backlog_size = 缓冲空间大小 * 2

举个例子:

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

这样一来,增量复制时主从库的数据不一致风险就降低了。不过,如果并发请求量非常大,连两倍的缓冲空间都存不下新操作请求的话,此时,主从库数据仍然可能不一致。

针对这种情况,一方面,你可以根据 Redis 所在服务器的内存资源再适当增加 repl_backlog_size 值,比如说设置成缓冲空间大小的 4 倍,另一方面,你可以考虑使用切片集群来分担单个主库的请求压力。

1.3.5 重要说明

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

两个缓冲区的区别:

  • repl_backlog_buffer:用来存最近写操作以支持断线从库增量同步
  • replication buffer:用来把数据真正发送给客户端或从库

二、哨兵集群

2.1 哨兵机制概述

主从库集群模式,在这个模式下,如果从库发生故障了,客户端可以继续向主库或其他从库发送请求,进行相关的操作,但是如果主库发生故障了,那就直接会影响到从库的同步,因为从库没有相应的主库可以进行数据复制操作了。这就要提到哨兵机制了。

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

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

2.2 哨兵的三大任务

2.2.1 监控

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

2.2.2 选主

这个流程首先是执行哨兵的第二个任务,选主。主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。

2.2.3 通知

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

在这三个任务中,通知任务相对来说比较简单,哨兵只需要把新主库信息发给从库和客户端,让它们和新主库建立连接就行,并不涉及决策的逻辑。但是,在监控和选主这两个任务中,哨兵需要做出两个决策:

  • 在监控任务中,哨兵需要判断主库是否处于下线状态
  • 在选主任务中,哨兵也要决定选择哪个从库实例作为主库

2.3 判断下线

2.3.1 主观下线

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

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

但是,如果检测的是主库,那么,哨兵还不能简单地把它标记为”主观下线”,开启主从切换。因为很有可能存在这么一个情况:那就是哨兵误判了,其实主库并没有故障。可是,一旦启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。

2.3.2 误判问题

为了避免这些不必要的开销,我们要特别注意误判的情况。首先,我们要知道啥叫误判。很简单,就是主库实际并没有下线,但是哨兵误以为它下线了。

误判一般会发生在集群网络压力较大、网络拥塞,或者是主库本身压力较大的情况下。一旦哨兵判断主库下线了,就会开始选择新主库,并让从库和新主库进行数据同步,这个过程本身就会有开销,例如,哨兵要花时间选出新主库,从库也需要花时间和新主库同步。而在误判的情况下,主库本身根本就不需要进行切换的,所以这个过程的开销是没有价值的。正因为这样,我们需要判断是否有误判,以及减少误判。

2.3.3 哨兵集群

那怎么减少误判呢?在日常生活中,当我们要对一些重要的事情做判断的时候,经常会和家人或朋友一起商量一下,然后再做决定。哨兵机制也是类似的,它通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。

引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

2.3.4 客观下线

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

Redis 主从集群有一个主库、三个从库,还有三个哨兵实例:

  • 在图片的左边,哨兵 2 判断主库为”主观下线”,但哨兵 1 和 3 却判定主库是上线状态,此时,主库仍然被判断为处于上线状态
  • 在图片的右边,哨兵 1 和 2 都判断主库为”主观下线”,此时,即使哨兵 3 仍然判断主库为上线状态,主库也被标记为”客观下线”了

简单来说,”客观下线”的标准就是,当有 N 个哨兵实例时,最好要有 N/2 + 1 个实例判断主库为”主观下线”,才能最终判定主库为”客观下线”。这样一来,就可以减少误判的概率,也能避免误判带来的无谓的主从库切换。(当然,有多少个实例做出”主观下线”的判断才可以,可以由 Redis 管理员自行设定)。

好了,到这里,你可以看到,借助于多个哨兵实例的共同判断机制,我们就可以更准确地判断出主库是否处于下线状态。如果主库的确下线了,哨兵就要开始下一个决策过程了,即从许多从库中,选出一个从库来做新主库。


2.4 主库选举

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

2.4.1 筛选阶段

首先来看筛选的条件。一般情况下,我们肯定要先保证所选的从库仍然在线运行。不过,在选主时从库正常在线,这只能表示从库的现状良好,并不代表它就是最适合做主库的。

设想一下,如果在选主时,一个从库正常运行,我们把它选为新主库开始使用了。可是,很快它的网络出了故障,此时,我们就得重新选主了。这显然不是我们期望的结果。

所以,在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。

具体判断标准:

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

好了,这样我们就过滤掉了不适合做主库的从库,完成了筛选工作。

2.4.2 打分阶段

接下来就要给剩余的从库打分了。我们可以分别按照三个规则依次进行三轮打分,这三个规则分别是从库优先级、从库复制进度以及从库 ID 号。只要在某一轮中,有从库得分最高,那么它就是主库了,选主过程到此结束。如果没有出现得分最高的从库,那么就继续进行下一轮。

第一轮:优先级最高的从库得分高

用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。比如,你有两个从库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。在选主时,哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。如果从库的优先级都一样,那么哨兵开始第二轮打分。

第二轮:和旧主库同步程度最接近的从库得分高

这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数据。

如何判断从库和旧主库间的同步进度呢?主从库同步时有个命令传播的过程。在这个过程中,主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 这个值记录当前的复制进度。

此时,我们想要找的从库,它的 slave_repl_offset 需要最接近 master_repl_offset。如果在所有从库中,有从库的 slave_repl_offset 最接近 master_repl_offset,那么它的得分就最高,可以作为新主库。

例如,旧主库的 master_repl_offset 是 1000,从库 1、2 和 3 的 slave_repl_offset 分别是 950、990 和 900,那么,从库 2 就应该被选为新主库。

当然,如果有两个从库的 slave_repl_offset 值大小是一样的(例如,从库 1 和从库 2 的 slave_repl_offset 值都是 990),我们就需要给它们进行第三轮打分了。

第三轮:ID 号小的从库得分高

每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。

到这里,新主库就被选出来了,”选主”这个过程就完成了。

2.4.3 选主流程总结

我们再回顾下这个流程。首先,哨兵会按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库,然后,依次按照优先级、复制进度、ID 号大小再对剩余的从库进行打分,只要有得分最高的从库出现,就把它选为新主库。


2.5 由哪个哨兵执行主从切换?

2.5.1 判断客观下线的仲裁过程

确定由哪个哨兵执行主从切换的过程,和主库”客观下线”的判断过程类似,也是一个”投票仲裁”的过程。

哨兵集群要判定主库”客观下线”,需要有一定数量的实例都认为该主库已经”主观下线”了。任何一个实例只要自身判断主库”主观下线”后,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。

一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为”客观下线”。这个所需的赞成票数是通过哨兵配置文件中的 quorum 配置项设定的。

例如,现在有 5 个哨兵,quorum 配置的是 3,那么,一个哨兵需要 3 张赞成票,就可以标记主库为”客观下线”了。这 3 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

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

2.5.2 Leader 选举条件

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:

  • 第一:拿到半数以上的赞成票
  • 第二:拿到的票数同时还需要大于等于哨兵配置文件中的 quorum

以 3 个哨兵为例,假设此时的 quorum 设置为 2,那么,任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以了。

2.5.3 Leader 选举示例

我们来看一个 3 个哨兵、quorum 为 2 的选举过程:

  • T1 时刻:S1 判断主库为”客观下线”,它想成为 Leader,就先给自己投一张赞成票,然后分别向 S2 和 S3 发送命令,表示要成为 Leader
  • T2 时刻:S3 也判断主库为”客观下线”,它也想成为 Leader,所以也先给自己投一张赞成票,再分别向 S1 和 S2 发送命令,表示要成为 Leader
  • T3 时刻:S1 收到了 S3 的 Leader 投票请求。因为 S1 已经给自己投了一票 Y,所以它不能再给其他哨兵投赞成票了,所以 S1 回复 N 表示不同意。同时,S2 收到了 T2 时 S3 发送的 Leader 投票请求。因为 S2 之前没有投过票,它会给第一个向它发送投票请求的哨兵回复 Y,给后续再发送投票请求的哨兵回复 N,所以,在 T3 时,S2 回复 S3,同意 S3 成为 Leader
  • T4 时刻:S2 才收到 T1 时 S1 发送的投票命令。因为 S2 已经在 T3 时同意了 S3 的投票请求,此时,S2 给 S1 回复 N,表示不同意 S1 成为 Leader。发生这种情况,是因为 S3 和 S2 之间的网络传输正常,而 S1 和 S2 之间的网络传输可能正好拥塞了,导致投票请求传输慢了
  • T5 时刻:S1 得到的票数是来自它自己的一票 Y 和来自 S2 的一票 N。而 S3 除了自己的赞成票 Y 以外,还收到了来自 S2 的一票 Y。此时,S3 不仅获得了半数以上的 Leader 赞成票,也达到预设的 quorum 值(quorum 为 2),所以它最终成为了 Leader

接着,S3 会开始执行选主操作,而且在选定新主库后,会给其他从库和客户端通知新主库的信息。

2.5.4 选举失败的处理

如果 S3 没有拿到 2 票 Y,那么这轮投票就不会产生 Leader。哨兵集群会等待一段时间(也就是哨兵故障转移超时时间的 2 倍),再重新选举。这是因为,哨兵集群能够进行成功投票,很大程度上依赖于选举命令的正常网络传播。如果网络压力较大或有短时堵塞,就可能导致没有一个哨兵能拿到半数以上的赞成票。所以,等到网络拥塞好转之后,再进行投票选举,成功的概率就会增加。

2.5.5 哨兵数量建议

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

2.5.6 共识算法

这里哨兵集群采用的类似于 Raft 的共识算法。简单来说就是每个哨兵设置一个随机超时时间,超时后每个哨兵会请求其他哨兵为自己投票,其他哨兵节点对收到的第一个请求进行投票确认,一轮投票下来后,首先达到多数选票的哨兵节点成为”哨兵领导者”,如果没有达到多数选票的哨兵节点,那么会重新选举,直到能够成功选出”哨兵领导者”。


2.6 分布式容错问题

2.6.1 哨兵集群中有实例挂了,怎么办?

这个属于分布式系统领域的问题了,指的是在分布式系统中,如果存在故障节点,整个集群是否还可以提供服务?而且提供的服务是正确的?这是一个分布式系统容错问题,这方面最著名的就是分布式领域中的”拜占庭将军”问题了。

“拜占庭将军问题”不仅解决了容错问题,还可以解决错误节点的问题,虽然比较复杂,但还是值得研究的,有兴趣的同学可以去了解下。

简单说结论: 存在故障节点时,只要集群中大多数节点状态正常,集群依旧可以对外提供服务。

2.6.2 容错公式

如果系统中有 n 个节点,其中最多 f 个节点可能出错(叛徒),要保证一致性,通常需要:

1
总节点数 n ≥ 3f + 1

换句话说,只要大多数节点(超过 2/3)是正常的,集群仍能达成一致决策。(Lamport 在论文中有采用归纳法证明)

在 Redis 哨兵中:

如果某个哨兵挂了,只要大多数哨兵存活,主库状态判断和选主流程不会受到影响。也就是哨兵系统依赖多数投票机制来容错。


三、切片集群

3.1 切片集群概述

当 Redis 的数据量大到一台服务器无法缓存的时候,就需要使用 Redis 切片集群(Redis Cluster,也叫分片集群)。采用多个实例保存数据切片后,我们既能保存大量数据,又避免了 fork 子进程阻塞主线程而导致的响应突然变慢。它主要是将数据分布到不同的服务器上,以此来降低对单主节点的依赖。

3.2 哈希槽机制

Redis Cluster 方案采用的是哈希槽 Hash Slot,一般一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对会根据它的 key,被映射到一个哈希槽中。

具体执行的过程分为两大步:

  1. 根据对应的 key,按照 CRC16 算法计算一个 16bit 的值
  2. 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数对应一个相应编号的哈希槽

接下来的问题就是如何将这些哈希槽映射到具体的 Redis 节点上,我们可以选择平均分配也可以选择手动分配。

3.3 动态槽分配

手动分配 slot 只是集群初始化时的分配策略,并不意味着 slot 关系永久固定。Redis Cluster 中 slot 和节点的映射是动态的,后续可以通过 resharding 调整(如果这时访问会返回 ASK 报错信息),以应对负载变化或集群扩缩容。

正因为 slot 可能发生迁移,Redis Cluster 才需要通过 MOVED 响应来纠正客户端路由,保证无中心化架构下的正确访问。

3.4 高可用保证

分片集群中,节点宕机并不等于数据全没,Redis 通过主从副本和自动故障转移保证高可用,但由于异步复制,仍可能丢失极少量数据,这本质上是 Redis 在 CAP 中选择 AP 的结果。


3.5 客户端分片

3.5.1 客户端分片概述

Redis 本身不知道自己是集群,每个 Redis 实例都是普通单机,分片逻辑完全在客户端。客户端自己做分片是为了在节点增减时,避免大量 key 重新映射。

1
2
3
4
Client 
├─ hash(key) → Node A
├─ hash(key) → Node B
└─ hash(key) → Node C

一致性 Hash 通常用于客户端分片模式,因为 Redis 本身不感知集群,只能靠客户端决定 key 的路由;而 Redis Cluster 是服务端自治的分片集群,通过固定的 Hash Slot 把 key 映射问题提升到 slot 层,迁移更可控,也支持服务端纠错和多 key 约束,所以没有采用一致性 Hash。


3.6 一致性 Hash

3.6.1 为什么需要一致性 Hash

在 hash 取模的过程中,我们虽然不需要对所有 Redis 服务器进行遍历而提升了性能。但是,使用 Hash 算法缓存时会出现一些问题,Redis 服务器变动时,所有缓存的位置都会发生改变。

比如,现在我们的 Redis 缓存服务器增加到了 8 台,我们计算的公式从 hash(product.png) % 6 = 5 变成了 hash(product.png) % 8 = ? 结果肯定不是原来的 5 了。

再者,6 台的服务器集群中,当某个主从群出现故障时,无法进行缓存,那我们需要把故障机器移除,所以取模数又会从 6 变成了 5。我们计算的公式也会变化。

由于上面 hash 算法是使用取模来进行缓存的,为了规避上述情况,Hash 一致性算法就诞生了。

3.6.2 一致性 Hash 原理

一致性 Hash 算法也是使用取模的方法,不过,上述的取模方法是对服务器的数量进行取模,而一致性的 Hash 算法是对 2 的 32 方取模。

即,一致性 Hash 算法将整个 Hash 空间组织成一个虚拟的圆环,Hash 函数的值空间为 0 ~ 2^32 - 1(一个 32 位无符号整型)整个圆环以顺时针方向组织,圆环正上方的点代表 0,0 点右侧的第一个点代表 1,以此类推。

我们将各个服务器使用 Hash 进行一个哈希,具体可以选择服务器的 IP 或主机名作为关键字进行哈希,这样每台服务器就确定在了哈希环的一个位置上。

数据定位算法:

将数据 Key 使用相同的函数 Hash 计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针查找,遇到的服务器就是其应该定位到的服务器。

3.6.3 容错性

假设我们的 Node C 宕机了,我们从图中可以看到,A、B 不会受到影响,只有 Object C 对象被重新定位到 Node A。所以我们发现,在一致性 Hash 算法中,如果一台服务器不可用,受影响的数据仅仅是此服务器到其环空间前一台服务器之间的数据。

一致性 Hash 算法对于节点的增减都只需重定位环空间中的一小部分数据,有很好的容错性。

3.6.4 数据倾斜问题

这时我们发现有大量数据集中在节点 A 上,而节点 B 只有少量数据。为了解决数据倾斜问题,一致性 Hash 算法引入了虚拟节点机制,即对每一个服务器节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。

数据定位算法不变,只需要增加一步:虚拟节点到实际点的映射。所以加入虚拟节点之后,即使在服务节点很少的情况下,也能做到数据的均匀分布。


四、脑裂处理

4.1 脑裂问题

在 Redis 主从架构中,部署方式一般是一主多从,如果这时主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题。

又由于哨兵发现主节点挂了,经过选票之后,确认这个主节点已经挂了,于是哨兵就会在从节点中选举出一个 leader 作为主节点。

这时这个集群就有两个主节点了,也就是出现脑裂了。

然后,旧主节点的网络突然又好了,又因为已经有了一个新主节点了,所以哨兵就会将旧主节点降级为从节点,接下来会做数据同步,但是又由于第一次同步是全量更新,所以旧主节点会清空本地的数据,然后再做全量同步。

之前客户端写入的数据就相当于丢失了,也就是说集群产生脑裂数据丢失的问题。

4.2 解决方案

当主节点发现从节点下线或者通信超时的总数量大于阈值的时候,那么就禁止主节点写入数据,直接返回错误。

同样的,在 Redis 中,我们也可以通过调节 min-slaves-to-writemin-slaves-max-lag 参数来达成目的。

这样一来,如果主节点这边出现网络故障,长时间不响应哨兵心跳,也不能和从库进行同步,无法进行 ACK 确认,自然就无法满足上面的两个参数。

客户端也就无法往原主库中写入新数据了,也就是脑裂之前自杀。

4.3 CAP 理论视角

4.3.1 CAP 理论

在发生网络分区(P,Partition tolerance)时,分布式系统不可能同时保证一致性(C,Consistency)和可用性(A,Availability),只能二选一。

  • C(Consistency):所有节点在同一时刻看到的数据是一致的。强调:”对”,而不是”快”或”不断服务”
  • A(Availability):每个请求都能得到响应,不保证返回的是最新数据,强调:”有回应”
  • P(Partition tolerance):节点之间网络断开/延迟,系统仍能继续运行,在现实分布式系统中:P 是不可避免的

4.3.2 从 AP 向 CP 迁移

CAP 不是让系统”选两个”,而是要求系统在网络分区发生时,明确知道自己是牺牲一致性还是牺牲可用性。

Redis 默认偏向 AP,但可以通过 min-slaves-to-write 等机制在关键场景下向 CP 偏移,以避免脑裂导致的数据不一致。


五、分布式一致性相关

5.1 一致性级别

5.1.1 弱一致性

弱一致性是一种比较宽松的数据一致性模型,它不保证系统对数据的读操作一定能立刻看到最新写入的结果。系统只保证最终可能达到一致,但不保证实时。比如异步复制,主节点写入后,副本节点延迟同步,数据都可能丢失,完全达不成一致。

5.1.2 强一致性

强一致性保证所有节点对某个数据的读操作都能看到最新写入的结果,也叫线性一致性(Linearizability)。系统行为就像是单台机器,所有操作按顺序执行。

5.1.3 最终一致性

最终一致性是弱一致性的一种特例,强调在没有新的更新操作的情况下,系统最终会达到一致状态。允许短暂的不一致,但最终所有节点的数据会一致。

5.1.4 分布式一致性的 3 种级别总结

  • 强一致性:系统写入了什么,读出来的就是什么
  • 弱一致性:不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态
  • 最终一致性:弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态

业界比较推崇是最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。


5.2 强一致性算法

5.2.1 Raft 算法

Raft 实现了和 Paxos 相同的功能,它将一致性分解为多个子问题:Leader 选举(Leader election)、日志同步(Log replication)、安全性(Safety)、日志压缩(Log compaction)、成员变更(Membership change)等。同时,Raft 算法使用了更强的假设来减少了需要考虑的状态,使之变的易于理解和实现。

角色划分:

Raft 将系统中的角色分为领导者(Leader)、跟从者(Follower)和候选人(Candidate):

  • Leader:接受客户端请求,并向 Follower 同步请求日志,当日志同步到大多数节点上后告诉 Follower 提交日志
  • Follower:接受并持久化 Leader 同步的日志,在 Leader 告之日志可以提交之后,提交日志
  • Candidate:Leader 选举过程中的临时角色

Follower 只响应其他服务器的请求。如果 Follower 超时没有收到 Leader 的消息,它会成为一个 Candidate 并且开始一次 Leader 选举。收到大多数服务器投票的 Candidate 会成为新的 Leader。Leader 在宕机之前会一直保持 Leader 的状态。

任期(term):

Raft 算法将时间分为一个个的任期(term),每一个 term 的开始都是 Leader 选举。在成功选举 Leader 之后,Leader 会在整个 term 内管理整个集群。如果 Leader 选举失败,该 term 就会因为没有 Leader 而结束。

Leader 选举:

Raft 使用心跳(heartbeat)触发 Leader 选举。当服务器启动时,初始化为 Follower。Leader 向所有 Followers 周期性发送 heartbeat。如果 Follower 在选举超时时间内没有收到 Leader 的 heartbeat,就会等待一段随机的时间后发起一次 Leader 选举。

Follower 将其当前 term 加一然后转换为 Candidate。它首先给自己投票并且给集群中的其他服务器发送 RequestVote RPC。结果有以下三种情况:

  1. 赢得了多数的选票,成功选举为 Leader
  2. 收到了 Leader 的消息,表示有其它服务器已经抢先当选了 Leader
  3. 没有服务器赢得多数的选票,Leader 选举失败,等待选举时间超时后发起下一次选举

选举出 Leader 后,Leader 通过定期向所有 Followers 发送心跳信息维持其统治。若 Follower 一段时间未收到 Leader 的心跳则认为 Leader 可能已经挂了,再次发起 Leader 选举过程。

Raft 保证选举出的 Leader 上一定具有最新的已提交的日志。

5.2.2 Paxos 算法

Paxos 算法解决的问题正是分布式一致性问题,即一个分布式系统中的各个进程如何就某个值(决议)达成一致。

算法组成:

Paxos 算法主要包含 2 个部分:

  • Basic Paxos 算法:描述的是多节点之间如何就某个值(提案 Value)达成共识
  • Multi-Paxos 思想:描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。Multi-Paxos 说白了就是执行多次 Basic Paxos,核心还是 Basic Paxos

Paxos 算法运行在允许宕机故障的异步系统中,不要求可靠的消息传递,可容忍消息丢失、延迟、乱序以及重复。它利用大多数(Majority)机制保证了 2F+1 的容错能力,即 2F+1 个节点的系统最多允许 F 个节点同时出现故障。

角色划分:

Paxos 将系统中的角色分为提议者(Proposer),决策者(Acceptor),和最终决策学习者(Learner):

  • Proposer:提出提案(Proposal)。Proposal 信息包括提案编号(Proposal ID)和提议的值(Value)
  • Acceptor:参与决策,回应 Proposers 的提案。收到 Proposal 后可以接受提案,若 Proposal 获得多数 Acceptors 的接受,则称该 Proposal 被批准
  • Learner:不参与决策,从 Proposers/Acceptors 学习最新达成一致的提案(Value)

在多副本状态机中,每个副本同时具有 Proposer、Acceptor、Learner 三种角色。

具体流程:

Paxos 算法通过一个决议分为两个阶段(Learn 阶段之前决议已经形成):

第一阶段:Prepare 阶段

Proposer 向 Acceptors 发出 Prepare 请求,Acceptors 针对收到的 Prepare 请求进行 Promise 承诺。

第二阶段:Accept 阶段

Proposer 收到多数 Acceptors 承诺的 Promise 后,向 Acceptors 发出 Propose 请求,Acceptors 针对收到的 Propose 请求进行 Accept 处理。

第三阶段:Learn 阶段

Proposer 在收到多数 Acceptors 的 Accept 之后,标志着本次 Accept 成功,决议形成,将形成的决议发送给所有 Learners。

Multi-Paxos 算法:

原始的 Paxos 算法(Basic Paxos)只能对一个值形成决议,决议的形成至少需要两次网络来回,在高并发情况下可能需要更多的网络来回,极端情况下甚至可能形成活锁。如果想连续确定多个值,Basic Paxos 搞不定了。因此 Basic Paxos 几乎只是用来做理论研究,并不直接应用在实际工程中。

实际应用中几乎都需要连续确定多个值,而且希望能有更高的效率。Multi-Paxos 正是为解决此问题而提出。Multi-Paxos 基于 Basic Paxos 做了两点改进:

  1. 针对每一个要确定的值,运行一次 Paxos 算法实例(Instance),形成决议。每一个 Paxos 实例使用唯一的 Instance ID 标识
  2. 在所有 Proposers 中选举一个 Leader,由 Leader 唯一地提交 Proposal 给 Acceptors 进行表决。这样没有 Proposer 竞争,解决了活锁问题。在系统中仅有一个 Leader 进行 Value 提交的情况下,Prepare 阶段就可以跳过,从而将两阶段变为一阶段,提高效率

Multi-Paxos 首先需要选举 Leader,Leader 的确定也是一次决议的形成,所以可执行一次 Basic Paxos 实例来选举出一个 Leader。选出 Leader 之后只能由 Leader 提交 Proposal,在 Leader 宕机之后服务临时不可用,需要重新选举 Leader 继续服务。

Multi-Paxos 通过改变 Prepare 阶段的作用范围至后面 Leader 提交的所有实例,从而使得 Leader 的连续提交只需要执行一次 Prepare 阶段,后续只需要执行 Accept 阶段,将两阶段变为一阶段,提高了效率。为了区分连续提交的多个实例,每个实例使用一个 Instance ID 标识,Instance ID 由 Leader 本地递增生成即可。

Multi-Paxos 允许有多个自认为是 Leader 的节点并发提交 Proposal 而不影响其安全性,这样的场景即退化为 Basic Paxos。Chubby 和 Boxwood 均使用 Multi-Paxos。ZooKeeper 使用的 Zab 也是 Multi-Paxos 的变形。


5.3 最终一致性算法

5.3.1 Gossip 协议

Gossip 协议也叫 Epidemic 协议(流行病协议)或者 Epidemic propagation 算法(疫情传播算法),别名很多。不过,这些名字的特点都具有随机传播特性(联想一下病毒传播、癌细胞扩散等生活中常见的情景),这也正是 Gossip 协议最主要的特点。

正如 Gossip 协议其名一样,这是一种随机且带有传染性的方式将信息传播到整个网络中,并在一定时间内,使得系统内的所有节点数据一致。在 Gossip 协议下,没有所谓的中心节点,每个节点周期性地随机找一个节点互相同步彼此的信息,理论上来说,各个节点的状态最终会保持一致。

定义:

Gossip 协议是一种允许在分布式系统中共享状态的去中心化通信协议,通过这种通信协议,我们可以将信息传播给网络或集群中的所有成员。

消息传播模式:

Gossip 设计了两种可能的消息传播模式:反熵(Anti-Entropy)和传谣(Rumor-Mongering)。

反熵(Anti-entropy):

反熵就是指消除不同节点中数据的差异,提升节点间数据的相似度,从而降低熵值。具体是如何反熵的呢?集群中的节点,每隔段时间就随机选择某个其他节点,然后通过互相交换自己的所有数据来消除两者之间的差异,实现数据的最终一致性。

在实现反熵的时候,主要有推、拉和推拉三种方式:

  • 推方式:就是将自己的所有副本数据,推给对方,修复对方副本中的熵
  • 拉方式:就是拉取对方的所有副本数据,修复自己副本中的熵
  • 推拉:就是同时修复自己副本和对方副本中的熵

谣言传播(Rumor-Mongering):

谣言传播指的是分布式系统中的一个节点一旦有了新数据之后,就会变为活跃节点,活跃节点会周期性地联系其他节点向其发送新数据,直到所有的节点都存储了该新数据。

谣言传播比较适合节点数量比较多的情况,不过,这种模式下要尽量避免传播的信息包不能太大,避免网络消耗太大。

反熵(Anti-Entropy)会传播节点的所有数据,而谣言传播(Rumor-Mongering)只会传播节点新增的数据。我们一般会给反熵设计一个闭环。谣言传播(Rumor-Mongering)比较适合节点数量比较多或者节点动态变化的场景。

Gossip 在 Redis Cluster 中的应用:

Redis Cluster 是一个典型的分布式系统,分布式系统中的各个节点需要互相通信。既然要相互通信就要遵循一致的通信协议,Redis Cluster 中的各个节点基于 Gossip 协议来进行通信共享信息,每个 Redis 节点都维护了一份集群的状态信息。

Redis Cluster 的节点之间会相互发送多种 Gossip 消息:

  • MEET:在 Redis Cluster 中的某个 Redis 节点上执行 CLUSTER MEET ip port 命令,可以向指定的 Redis 节点发送一条 MEET 信息,用于将其添加进 Redis Cluster 成为新的 Redis 节点
  • PING/PONG:Redis Cluster 中的节点都会定时地向其他节点发送 PING 消息,来交换各个节点状态信息,检查各个节点状态,包括在线状态、疑似下线状态 PFAIL 和已下线状态 FAIL
  • FAIL:Redis Cluster 中的节点 A 发现 B 节点 PFAIL,并且在下线报告的有效期限内集群中半数以上的节点将 B 节点标记为 PFAIL,节点 A 就会向集群广播一条 FAIL 消息,通知其他节点将故障节点 B 标记为 FAIL

图中的虚线代表的就是各个节点之间使用 Gossip 进行通信,实线表示主从复制。最终每个节点都能收敛到一致的集群视图。

Redis Cluster 使用 Gossip 只需要知道哪些节点活着、哪些节点宕机、节点之间的主从关系。并不需要通过 Gossip 来同步具体的数据内容。


5.4 BASE 理论

5.4.1 BASE 理论概述

BASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充。AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。这一点其实就是 BASE 理论延伸的地方。

5.4.2 BASE 理论的三要素

基本可用(Basically Available):

基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。

软状态(Soft State):

软状态指允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性。

最终一致性(Eventually Consistent):

系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。

5.4.3 实现最终一致性的具体方式

读时修复:

在读取数据时,检测数据的不一致,进行修复。比如 Cassandra(一款开源的分布式 NoSQL 数据库)的 Read Repair 实现,具体来说,在向 Cassandra 系统查询数据的时候,如果检测到不同节点的副本数据不一致,系统就自动修复数据。

写时修复:

在写入数据,检测数据的不一致时,进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说,Cassandra 集群的节点之间远程写数据的时候,如果写失败就将数据缓存下来,然后定时重传,修复数据的不一致性。

异步修复:

这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。


六、总结

6.1 Redis 高可用方案对比

方案 优点 缺点 适用场景
主从复制 实现简单,读写分离 主库故障需手动切换 读多写少场景
哨兵集群 自动故障转移,高可用 配置复杂,存在脑裂风险 需要自动故障转移的场景
切片集群 数据分片,支持海量数据 运维复杂,跨槽操作受限 数据量大,需要水平扩展

6.2 一致性方案对比

一致性级别 特点 典型算法 适用场景
强一致性 实时一致,性能较低 Raft、Paxos 金融交易等强一致场景
最终一致性 允许短暂不一致,性能高 Gossip 社交网络、缓存等场景
弱一致性 不保证一致性,性能最高 异步复制 对一致性要求不高的场景

6.3 实践建议

  1. 主从复制:适合读多写少的场景,配合哨兵实现自动故障转移
  2. 哨兵集群:至少部署 3 个哨兵实例,避免脑裂问题需配置 min-slaves-to-writemin-slaves-max-lag
  3. 切片集群:数据量大时使用,注意合理分配哈希槽,避免数据倾斜
  4. 一致性选择:根据业务需求选择合适的一致性级别,Redis 默认最终一致性
  5. 监控告警:建立完善的监控体系,及时发现和处理故障

Redis 高可用是一个系统工程,需要根据实际业务场景选择合适的方案,并做好监控和运维工作。