redis cluster 集群,终极方案?

前言

本文参考源码版本为 redis6.2

前面系列文章,我们聊了 redis 主从模式、哨兵模式,这些都是单节点的高可用保障,受限于单机内存,另外,由于 redis 持久化的特性,单个 redis 实例的内存不宜过大。

分布式存储的终极解决方案是什么?加机器。一台不够加两台、三台,直到足够撑起你的业务。

因此,redis 也提出了集群版的解决方案。本质来说就是将数据尽可能均分到多个节点服务,这些节点可以同时对外提供服务,这样一来,集群模式既提高了整体存储容量,又提高了整体吞吐量。

那这些数据如何进行切片分配呢?按关键字区间还是 关键字hash?redis 采用 hash 的方式进行数据分片,一方面可以尽可能避免数据倾斜,同时有能很快定位 key 对应的分片节点。

我们知道,hash 取模的难点在于,难以定义模的长度。如果定义小了,可能要频繁进行 rehash 扩容;定义大了,会导致元数据过大,导致难以管理。这里 redis 定义了模长等于 16384,也就是说总共有 16384 个分片(也叫槽)。

这里算是取了一个折中,既不算小,也不算大。后续操作都是以分片为单位,比如,一个 key 通过哈希取模之后得到的就是分片号、数据迁移也是以分片为单位进行整片迁移

redis 官方建议,cluster 集群规模不要超过 1000 个,这样每个集群节点可能会有 16-7 个分片数据。

每个节点都只保留了一部分分片数据,那该节点如何知道其他分片属于哪些节点?这便是集群元数据管理了,每个节点都会记下这 16384 个分片对应的节点映射,后面的集群节点通信都是在全力维护这份元数据的一致性。

值得注意的是,redis cluster 客户端也会记录这份映射关系,方便直接将请求发送到对应的节点。当然,集群节点也可能发生变化,因此,会通过一些通知让客户端感知到并做相应更新。

redis cluster 如何保证高可用呢?当然还是主从模式,值得注意的是,集群里不需要通过哨兵来进行故障切换了,为啥?

你想想,redis 单节点的时候,是没办法进行故障切换的,所以需要哨兵来处理;当处于集群中,存在多个节点的时候,一旦某个节点故障,集群其他节点可以相互通信,执行哨兵集群的能力,从而达到自动进行故障转移


集群:

关于分布式集群的设计,我们一般要考虑以下几个方面:

  • 元数据存储,比如 分片与存储节点的映射等
  • 节点间通信,包括信息互通、健康状态等
  • 扩缩容,比如 考虑数据迁移情况
  • 高可用,当节点出现故障时,能及时自动的进行故障转移

对于分布式服务的元数据信息管理,我们要么采用中心化的方式,请求直达中间层,然后采用常见的中间组件来存储元数据,比如 zk、etcd 等等;在用户端看来,就像是单节点一样,我画了张图,你可以参考下:

另外,也可以考虑使用去中心化的方式,让每个节点都维护一份元数据信息,集群间可以采用特定的一些通信协议进行信息交换,然后客户端请求直连集群任意节点,我也画了张图,大概是这样:

redis cluster 便是一种去中心化的模式,每个节点都会完整的维护一份元数据信息;集群节点间会持续进行的信息交换,以保证集群数据整体一致性。

然后效果就是,当你访问任意一个节点,该节点总能寻到正确的节点去处理(即使该请求不归属于它处理,但它知道谁能处理)。

当然,如果每个节点都要存储一份元数据信息(分片与节点的映射关系),在数据更新时,必然可能存在一定的数据一致性的延迟,这就要求更高的节点间通信效率。

另外,采用固定模长的哈希算法,可以更加有效的减少集群扩缩容过程中的需要迁移的数据量。

对于高可用的保障也采用传统的主从模式,不过,这个故障转移不再通过哨兵来完成,而是通过集群节点间来协商完成,本质也是做了类似哨兵的工作。

整体来看,redis cluster 集群长这样:

一、信息互通

我们知道,当 cluster 集群中新增或减少节点时,会引发以分片(槽)为单位的数据迁移,也就是说,分片与部分节点的映射关系会发生改变,由于每个节点都会存在这样一份映射关系,因此,就需要一种方式,来向所有节点传递这种变更。

槽(slots)数据结构实际上是一个二进制数组,数组长度为 2048 个字节,16384 个二进制位,也就是 2k 大小。

集群节点通过 PING,PONG 方式来传递集群的元数据信息,PING、PONG 都采用相同的数据结构携带信息,一来一回便知晓了双方的元数据信息,多个来回,整个集群元数据信息就一致了,这便是 Gossip 协议

1. Gossip 协议

redis 采用流言蜚语协议,顾名思义,就像流言、八卦一样,一传十、十传百这样传递下去,直到所有节点的元数据信息达成一致。

redis cluster 如何实现 Gossip 协议的?我们知道,每个集群节点都维护了集群其他节点的信息,其通信名单就是根据该列表来的。

首先,这个工作也是由周期性的时间事件来负责处理,每次从通信名单中随机选择 5 个节点,然后从这批名单中选择最久未通信的节点。

然后构造 PING 请求,尝试与其进行通信,请求报文中会携带自己负责的那些哈希槽以及部分掌握的其他节点负责的哈希槽信息。

最后是接收 PONG 响应报文,该报文和 PING 请求报文基本一致,包含的信息是对方节点处理的哈希槽以及掌握的部分其他节点信息,至于要发送多少其他节点的信息,这个可以通过一些参数来控制。

这样一来一回,双方的信息算是打通了,顺便还打通了双方掌握的集群其他节点的信息。然后多几个这样的来回,集群信息就基本一致了。

你也注意到了,上面的通信节点是随机选择的,如果某个节点一直未进行通信,节点就无法打通?

没错,redis cluster 也是考虑了这种情况,所以会定期的选择那些长时间没有通信的节点,然后进行上面的流程进行通信。

2. 槽位迁移感知

我们知道,正因为 cluster 采用去中心化的模式,为了更加高效的精准定位具体的节点,通常在客户端也需要缓存元数据信息(哈希槽与节点的对应关系),因此,也很容易发生客户端缓存更新不及时的情况。

假如,客户端端请求到了一个不包含 key 对应的哈希槽,集群将做何响应?

为了保证客户端不受此类元数据变更带来的影响,cluster 提供了对应的一些指令来处理,比如 MOVED、ASK 等指令。当客户端收到这些指令后,会做出比如重定向、更新客户端缓存等操作,我们具体来看看:

1)MOVED

当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个 MOVED 错误,指引客户端转向至正在负责槽的节点。

MOVED 错误的格式为:

MOVED <slot> <ip>:<port>

其中 slot 为键所在的槽,而 ip 和 port 则是负责处理槽 slot 的节点的 IP 地址和端口号。

当客户端接收到节点返回的 MOVED 错误时,客户端会根据 MOVED 错误中提供的 IP 地址和端口号,转向至负责处理槽 slot 的节点,并向该节点重新发送之前想要执行的命令。

一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是换一个套接字来发送命令。

如果客户端尚未与想要转向的节点创建套接字连接,那么客户端会先根据 MOVED 错误提供的 IP 地址和端口号来连接节点,然后再进行转向。

2)ASK

在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。

当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:

  • 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
  • 反之,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。

如果请求正好遇到哈希槽正在迁移,且请求节点无法找到数据,那么客户端将收到如下响应:

ASK <slot> <ip>:<port>

我同样也画了张图,你可以参考下:

ASK 和 MOVED 都会导致客户端转向,它们有哪些区别?

MOVED 代表槽的负责权已经完成从一个节点转移到了另一个节点,在客户端收到关于槽 i 的MOVED 之后,客户端缓存关系也会刷新,后面节点关于槽 i 的请求可以直接发往 MOVED 所指向的节点。

与此相反,ASK 只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽 i 的 ASK 之后,客户端只会在接下来的一次命令请求中将关于槽 i 的命令请求发送至 ASK 所指示的节点;

这个过程不会刷新客户端缓存,因此,流程上还是会走「原节点 -> ASK 重定向目标节点」这一流程。

二、数据迁移

还记得我们搞集群的目的是啥?单机容量不足,需要扩容成多机组成的集群,然后将数据尽可能的均分到各个节点。那怎样在满足动态扩缩容的情况下,尽可能的减少数据迁移呢?

1. 一致性哈希?

首先说明,redis cluster 采用的哈希算法并不是一致性哈希,只不过看起来有些相似性,这里拎出来单独提下。

一致性哈希,从名称上来看,多少有些误导,这里的一致性,并不是我们常见的主从副本的数据一致性。

而是指,在一个集群中,即使增加或者减少节点,哈希的模并不会因此而发生改变,这样一来,同一个 key 经过 哈希计算之后,前后得到都是同一个 hash 值,从而达到最大可能减少数据迁移的目的。怎么做到的?

要想 hash 值 (hash(key) % d)不变,只有一个办法,维持模 d 不变。一致性哈希采用定长模 = 2^31 -1,然后我们将这些点(槽)映射到一张环形网中,每一个槽对应一类哈希值:

当然,实际情况下,我们服务节点数有限,所以,需要将这些槽映射到具体的节点上来。规则是,每个槽按照顺时针方向找到最近的一个节点便是对应所属的存储服务器,如下图:

这样一来,当我们新增或者删除节点,只会影响两个服务节点间的数据,而其他服务则不受影响,比如我们删除服务节点 3:

可以看到,原节点 2 与 节点 3 之间的数据都被迁移至节点 4,而节点 1 和 节点 2 则不受影响。

接着,你可能会问,hash 分区可能会存在部分热点 key,从而导致数据倾斜问题,如何解决?

要解决数据倾斜问题,本质就是将那些热点 key 分布到多个服务节点,也就是将这些热点 key 进一步打散。

一致性哈希采用的方法是采用更多的虚拟服务节点,这样一来,热点 key 将会更均匀分配到不同的虚拟节点,然后将虚拟节点映射到实际节点,进而让实际节点的数据分布的更加均匀。

2. 哈希槽(分片):

redis cluster 采用 crc16 哈希算法,并使用固定长度的模 16384,其中,这 16484 个哈希分片也称之为 哈希槽,然后将这些哈希槽尽可能均匀的分配给不同的服务节点。

选取固定长度的模,是不是和一致性哈希很像?别急,我们继续往下看。

假定我们有三个服务节点,尽可能均匀分配之后,分配关系如下:

  • 节点 A 包含哈希槽从 0 到 5500.
  • 节点 B 包含哈希槽从 5501 到 11000.
  • 节点 C 包含哈希槽从 11001 到 16383.

我们可以很容易的增加或者删除节点,当我们新增一个节点 D 时,节点 A、B、C 的数据会迁移一部分到节点 D;当我们删除节点 A 时,节点 A 的数据会迁移到节点 B、节点 C。

有没有发现什么不一样?没错,和一致性哈希不同的是,redis cluster 集群节点全员参与,且仅部分数据再分配,从而达到集群节点间数据尽可能均匀的效果。

值得注意的是,数据迁移是以哈希槽位单位,也就是说,同一个槽的数据只会迁移到一个目的节点。

我们通过几张图来看看,假设我们有三个主节点构成 cluster 集群(从节点忽略):

当我们新增节点 4 时,会从节点 1、节点 2、节点 3 迁移部分哈希槽数据到节点 4:

然后,当我们移除节点 1 时,会将节点1的数据分别迁移至节点 2、节点 3、节点 4,然后再删除节点 1:

好,我们再来对比下 redis cluster 哈希槽(分片)与 一致性哈希的的区别:

首先,相同点在于,两者都采用固定长度的模取余,模不变,也就意味着哈希槽位是固定的。这样一来,不管是扩容还是缩容,大部分数据对应的槽位都不会变动,也就不会涉及到大规模数据迁移。

不同点在于:

  • 模长度:一致性哈希的模采用 2^32-1,而 redis cluster 采用 16384 (2^14)
  • 数据迁移范围:扩缩容的时候,一致性哈希只会影响相关节点前后的数据迁移;而 redis cluster 则是全员参与,尽可能达到数据均分。

从效果上看,redis cluster 集群数据就更加均分,也就是数据倾斜的几率更小。

另外,值得注意的是,redis cluster 为什么采用 16384 这个模长?作者给了两个原因:

  • 首先,集群间节点交流的时候,会携带当前节点的槽位配置信息,而这个信息正是以二进制位携带,刚好 2k 大小。如果过大,集群节点间的通信将会占用更多带宽。
  • 其次,一般情况下,集群不会超过 1000 个主节点,因此,16384 个槽位已经足够了。

三、高可用

老生常谈,我们先来看看高可用的两个基本条件:

  • 首先,要从节点副本
  • 其次,要有多个参与投票的节点

然后来看,怎么执行高可用:

  • 首先,要定期检查集群节点状态
  • 然后投票表决(共识)
  • 如果达成一致认为该节点故障了,则执行故障转移

1. 故障检测

集群中的每个节点都会定期地向集群中的其他节点发送 PING 消息,以此来检测对方是否在线。

如果接收 PING 消息的节点没有在规定的时间内,向发送 PING 消息的节点返回 PONG 消息,那么发送 PING 消息的节点就会将接收 PING 消息的节点标记为疑似下线(probable fail,PFAIL)。

集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如某个节点是处于在线状态、疑似下线状态(PFAIL),还是已下线状态(FAIL)。

当一个主节点 A 通过消息得知主节点 B 认为主节点 C 进入了疑似下线状态时,主节点 A 会在自己的 clusterState.nodes 字典中找到主节点 C 所对应的 clusterNode 结构,并将主节点 B 的下线报告(failure report)添加到 clusterNode 结构的 fail_reports 链表里面。

如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点 x 报告为疑似下线,那么这个主节点 x 将被标记为已下线(FAIL),将主节点 x 标记为已下线的节点会向集群广播一条关于主节点 x 的 FAIL 消息,所有收到这条FAIL消息的节点都会立即将主节点 x 标记为已下线。

2. 选举新的主节点

当主节点 x 被标记为已下线,一小段时间后,整个集群就都会知晓。这个时候,从节点就开始忙着竞选为主节点了。

集群内设有一个配置纪元,初始值为 0,每执行一次故障转移,就自增 1。就像你开发软件一样,类似于版本号。

在当前纪元内,每个主节点都有一次投票机会,而且是投给第一个要求主节点投票的从节点。

好,接下来从节点要开始竞选了,我们看看规则:

  • 当从节点发现自己的主节点已经被标记为下线后,从节点会向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。
  • 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点。
  • 每个参与选举的从节点都会接收 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。
  • 如果集群里有 N 个具有投票权的主节点,那么当一个从节点收集到大于等于 N / 2 + 1 张支持票时,这个从节点就会当选为新的主节点。
  • 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有 N 个主节点进行投票,那么具有大于等于 N / 2 + 1 张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
  • 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。

值得注意的是,这里通过 Raft 算法来选举的从节点 leader,并由该从节点 leader 来执行故障转移工作。

而在前面文章我们提到,哨兵选举 leader 的方法也是类似,不过在哨兵集群中,人家选举的是哨兵 leader,然后由哨兵 leader 来执行故障转移工作。

所以,现在你明白为什么 redis cluster 中不需要哨兵集群了吗?因为,cluster 集群中的主节点就已经承担了哨兵集群的职责(投票选举权)。

3. 故障转移

当从节点有资格被选为新的主节点时,后续将由该从节点来执行真正的故障转移,我们开看看,完成故障转移要做哪些工作:

  • 被选中的从节点会执行 SLAVEOF no one 命令,成为新的主节点。
  • 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
  • 新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
  • 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

4. 手动切换

cluster 集群除了自动完成故障转移外,还支持手动进行切换。当一个从节点接收到 cluster failover 命令之后,执行手动切换,我们来看看具体要做哪些工作:

首先,该从节点首先向对应的主节点发送一个 mfstart 包。通知主节点从节点要开始进行手动切换。

然后,主节点会阻塞所有客户端命令的执行。之后主节点在周期性函数 clusterCron 中发送 ping 包时会在包头部分做特殊标记。

当从节点收到主节点的 ping 包并且检测到特殊标记之后,会从包头中获取主节点的复制偏移量。

从节点在周期性函数 clusterCron 中检测当前处理的偏移量与主节点复制偏移量是否相等,当相等时开始执行切换流程。

最后,切换完成后,主节点会将阻塞的所有客户端命令通过发送 +MOVED 指令重定向到新的主节点。通过该过程可以看到,手动执行主从切换时不会丢失任何数据,也不会丢失任何执行命令,只在切换过程中会有暂时的停顿。


总结

cluster 是官方提供的分布式集群解决方案,相对于单机版 redis 拥有更高的存储容量和更高的吞吐量。换句话说,海量存储的终极方案,就是搞集群,多机器

cluster 集群采用去中心化的方式,集群中的每个节点以及客户端都会存储(或者说缓存)一份相同的元数据信息。

由于元数据可能会更新,因此整个集群采用 Gossip 协议进行数据交换,该协议的特点是 一传十、十传百的类似于流言的方式,集群节点越多,完成一次全同步需要的时间越长

所以,你会看到这种去中心化的方案,本质还是受限于集群节点总体大小,比如,很难支撑超超大集群规模(1w+节点、10w+节点),官方建议是不超过 1000 节点。基于此,在业务上做拆分,使用不同的 redis 集群也是不错的方案。

接着是集群的扩缩容,此操作会涉及到哈希槽的迁移,为减少数据哈希值的变更,使用了固定模长为 16384;

这样一来节点数量的变更,也就不会影响具体 key 对应的哈希值,然后,我们只需要迁移部分节点的哈希槽数据即可达到集群整体数据均衡性,也能有效避免数据倾斜。

最后就是高可用,集群内部的所有主节点成为最终的裁判,拥有故障判决权和新主节点的投票选举权,基于此,也就不再需要额外的哨兵来参与故障转移(其实这部分工作就相当于哨兵的工作)

对于高可用,我们首先要有足够的副本(从节点),然后是主节点(拥有选举权)以及要合适的故障检测机制、共识选举算法、执行故障转移等。




相关参考:

猜你喜欢

转载自juejin.im/post/7126887124883734558