Redis 系列 集群

Posted by lichao modified on February 8, 2014

RedisCluster 集群版本下,将不再依赖哨兵集群,故障检测和转移将由集群本身保证。Redis Cluster 中的每个节点都维护一份自己视角下的当前整个集群的状态,主要包括:

  1. 当前集群状态
  2. 集群中各节点所负责的 slots信息,及其migrate状态
  3. 集群中各节点的master-slave状态
  4. 集群中各节点的存活状态及怀疑Fail状态

基于Gossip协议,当集群状态变化时,如新节点加入、slot迁移、节点宕机、slave提升为新Master,我们希望这些变化尽快的被发现,传播到整个集群的所有节点并达成一致。节点之间相互的心跳(PING,PONG,MEET)及其携带的数据是集群状态传播最主要的途径。Redis Cluster 中使用 Gossip 主要有两大作用:

  • 去中心化,以实现分布式和弹性扩展;
  • 失败检测,以实现高可用;

集群

在分布式系统中,需要提供维护节点元数据信息的机制,所谓元数据是指节点负责哪些数据、主从属性、是否出现故障等状态信息。常见的元数据维护方式分为集中式和无中心式。Redis Cluster采用Gossip协议实现了去中心化的集群化方案。

分槽策略

Redis集群分槽策略

如图所示,该集群有三个 Redis 节点组成,每个节点负责整个集群的一部分数据,每个节点负责的数据多少可能不一样。这三个节点相互连接组成一个对等的集群,它们之间通过一种特殊的二进制协议(Gossip 协议)相互交互集群信息

RedisCluster 将所有数据划分为 16384 的 slots。每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。它不需要另外的分布式存储来存储节点槽位信息(元信息)。

当 RedisCluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息。这样当客户端要查找某个 key 时,可以直接定位到目标节点。

RedisCluster 是直接定位。客户端为了可以直接定位某个具体的 key 所在的节点,它就需要缓存槽位相关信息,这样才可以准确快速地定位到相应的节点。同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整。

另外,RedisCluster 的每个节点会将集群的配置信息持久化到配置文件中,所以必须确保配置文件是可写的,而且尽量不要依靠人工修改配置文件。

槽位定位算法

Cluster 默认会对 key 值使用 crc32 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。

Cluster 还允许用户强制某个 key 挂在特定槽位上,通过在 key 字符串里面嵌入 tag 标记,这就可以强制 key 所挂在的槽位等于 tag 所在的槽位。

跳转

当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令,告诉客户端去连某个节点去获取数据。

GET x
-MOVED 3999 127.0.0.1:6381

MOVED 指令的第一个参数 3999 是 key 对应的槽位编号,后面是目标节点地址。MOVED 指令前面有一个减号,表示该指令是一个错误消息。 客户端收到 MOVED 指令后,要立即纠正本地的槽位映射表。 后续所有 key 将使用新的槽位映射表。

迁移

RedisCluster 提供了工具 redis-trib 可以让运维人员手动调整槽位的分配情况,它使用 Ruby 语言进行开发,通过组合各种原生的 RedisCluster 指令来实现。

迁移过程

Redis 迁移的单位是槽,Redis 一个槽一个槽进行迁移,当一个槽正在迁移时,这个槽就处于中间过渡状态。这个槽在原节点的状态为 migrating,在目标节点的状态为 importing,表示数据正在从源流向目标。

迁移工具 redis-trib 首先会在源和目标节点设置好中间过渡状态,然后一次性获取源节点槽位的所有 key 列表(keysinslot 指令,可以部分获取),再挨个 key 执行migrate指令进行迁移。

migrate指令将 key 原子性地从当前实例传送到目标实例,一旦传送成功, key 保证会出现在目标实例上,而当前实例上的 key 会被删除。原节点对当前的 key 执行 dump 指令得到序列化内容,然后向目标节点发送指令 restore 携带序列化的内容作为参数,目标节点再进行反序列化就可以将内容恢复到目标节点的内存中,然后返回 OK,原节点收到后再把当前节点的 key 删除掉就完成了单个 key 迁移的整个过程。

从源节点获取内容 => 存到目标节点 => 从源节点删除内容。

注意这里的迁移过程是同步的,在目标节点执行 restore 指令到原节点删除 key 之间,原节点的主线程会处于阻塞状态,直到 key 被成功删除。

如果 迁移过程中突然出现网络故障,整个 slot 的迁移只进行了一半。这时两个节点依旧处于中间过渡状态。待下次迁移工具重新连上时,会提示用户继续进行迁移。

在迁移过程中,如果每个 key 的内容都很小,migrate 指令执行会很快,它就并不会影响客户端的正常访问。如果 key 的内容很大,因为 migrate 指令是阻塞指令会同时导致原节点和目标节点卡顿,影响集群的稳定型。所以在集群环境下业务逻辑要尽可能避免大 key 的产生。

在迁移过程中,客户端访问的流程会有很大的变化。

迁移中客户端访问流程

首先新旧两个节点对应的槽位都存在部分 key 数据。客户端先尝试访问旧节点,如果对应的数据还在旧节点里面,那么旧节点正常处理。如果对应的数据不在旧节点里面,那么有两种可能,要么该数据在新节点里,要么根本就不存在。旧节点不知道是哪种情况,所以它会向客户端返回一个 -ASK targetNodeAddr 的重定向指令。客户端收到这个重定向指令后,先去目标节点执行一个不带任何参数的 asking 指令,然后在目标节点再重新执行原先的 get 操作指令。

为什么需要执行一个不带参数的 asking 指令呢?

因为在迁移没有完成之前,按理说这个槽位还是不归新节点管理的,如果这个时候向目标节点发送该槽位的 get 指令,节点是不认的,它会向客户端返回一个 -MOVED 重定向指令告诉它去原节点去执行。如此就会形成重定向循环。 asking 指令的目标就是打开目标节点的选项,告诉它下一条指令不能不理,而要当成自己的槽位来处理。

从以上过程可以看出,迁移是会影响服务效率的,同样的指令在正常情况下一个 ttl 就能完成,而在迁移中得 3 个 ttl 才能搞定。

槽位迁移感知

如果 Cluster 中某个槽位正在迁移或者已经迁移完了,client 如何能感知到槽位的变化呢?客户端保存了槽位和节点的映射关系表,它需要即时得到更新,才可以正常地将某条指令发到正确的节点中。

前面提到 Cluster 有两个特殊的 error 指令,一个是 moved,一个是 asking。

  • 第一个 moved 是用来纠正槽位的。如果将指令发送到了错误的节点,该节点发现对应的指令槽位不归自己管理,就会将目标节点的地址随同 moved 指令回复给客户端通知客户端去目标节点去访问。这个时候客户端就会刷新自己的槽位关系表,然后重试指令,后续所有打在该槽位的指令都会转到目标节点。

  • 第二个 asking 指令和 moved 不一样,它是用来临时纠正槽位的。如果当前槽位正处于迁移中,指令会先被发送到槽位所在的旧节点,如果旧节点存在数据,那就直接返回结果了,如果不存在,那么它可能真的不存在也可能在迁移目标节点上。所以旧节点会通知客户端去新节点尝试一下拿数据,看看新节点有没有。这时候就会给客户端返回一个 asking error 携带上目标节点的地址。客户端收到这个 asking error 后,就会去目标节点去尝试。客户端不会刷新槽位映射关系表,因为它只是临时纠正该指令的槽位信息,不影响后续指令。

重试 2 次 moved 和 asking 指令都是重试指令,客户端会因为这两个指令多重试一次。有没有想过会不会存在一种情况,客户端有可能重试 2 次呢?这种情况是存在的,比如一条指令被发送到错误的节点,这个节点会先给你一个 moved 错误告知你去另外一g个节点重试。所以客户端就去另外一个节点重试了,结果刚好这个时候运维人员要对这个槽位进行迁移操作,于是给客户端回复了一个 asking 指令告知客户端去目标节点去重试指令。所以这里客户端重试了 2 次。

重试多次 在某些特殊情况下,客户端甚至会重试多次,可以开发一下自己的脑洞想一想什么情况下会重试多次。 正是因为存在多次重试的情况,所以客户端的源码里在执行指令时都会有一个循环,然后会设置一个最大重试次数,Java 和 Python 都有这个参数,只是设置的值不一样。当重试次数超过这个值时,客户端会直接向业务层抛出异常。

集群变更感知

当服务器节点变更时,客户端应该即时得到通知以实时刷新自己的节点关系表。那客户端是如何得到通知的呢?这里要分 2 种情况:

  • 目标节点挂掉了,客户端会抛出一个 ConnectionError,紧接着会随机挑一个节点来重试,这时被重试的节点会通过 moved error 告知目标槽位被分配到的新的节点地址。
  • 运维手动修改了集群信息,将 master 切换到其它节点,并将旧的 master 移除集群。这时打在旧节点上的指令会收到一个 ClusterDown 的错误,告知当前节点所在集群不可用 (当前节点已经被孤立了,它不再属于之前的集群)。这时客户端就会关闭所有的连接,清空槽位映射关系表,然后向上层抛错。待下一条指令过来时,就会重新尝试初始化节点信息。

集群容错

Redis Cluster 为每个主节点设置若干个从节点,单主节点故障时,集群会自动将其中某个从节点提升为主节点。如果某个主节点没有从节点,那么当它发生故障时,集群将完全处于不可用状态。不过 Redis 也提供了一个参数。 cluster-require-full-coverage 可以允许部分节点故障,其它节点还可以继续提供对外访问。

Gossip协议的使用

Redis 集群是去中心化的,彼此之间状态同步靠 gossip 协议通信,集群的消息有以下几种类型:

  • Meet 通过「cluster meet ip port」命令,已有集群的节点会向新的节点发送邀请,加入现有集群。
  • Ping 节点每秒会向集群中其他节点发送 ping 消息,消息中带有自己已知的节点地址、槽、状态信息、最后一次通信时间等。
  • Pong 节点收到 ping 消息后会回复 pong 消息,消息中同样带有自己已知的两个节点信息。
  • PFail
  • Fail 节点 ping 不通某节点后,会向集群所有节点广播该节点挂掉的消息。其他节点收到消息后标记已下线。 由于去中心化和通信机制,Redis Cluster 选择了最终一致性和基本可用。例如当加入新节点时(meet),只有邀请节点和被邀请节点知道这件事,其余节点要等待 ping 消息一层一层扩散。除了 Fail 是立即全网通知的,其他诸如新节点、节点重上线、从节点选举成为主节点、槽变化等,都需要等待被通知到,也就是Gossip协议是最终一致性的协议。

新节点上线

RedisCluster 加入新节点时,客户端需要执行 CLUSTER MEET 命令,如下图所示。

meed

  • 节点一在执行 CLUSTER MEET 命令时会首先为新节点创建一个 clusterNode 数据,并将其添加到自己维护的 clusterState 的 nodes 字典中。
  • 然后节点一会根据据 CLUSTER MEET 命令中的 IP 地址和端口号,向新节点发送一条 MEET 消息。新节点接收到节点一发送的MEET消息后,新节点也会为节点一创建一个 clusterNode 结构,并将该结构添加到自己维护的 clusterState 的 nodes 字典中。
  • 接着,新节点向节点一返回一条PONG消息。节点一接收到节点B返回的PONG消息后,得知新节点已经成功的接收了自己发送的MEET消息。
  • 最后,节点一还会向新节点发送一条 PING 消息。新节点接收到该条 PING 消息后,可以知道节点A已经成功的接收到了自己返回的PONG消息,从而完成了新节点接入的握手操作。
  • MEET 操作成功之后,节点一会通过定时 PING 机制将新节点的信息发送给集群中的其他节点,让其他节点也与新节点进行握手,最终,经过一段时间后,新节点会被集群中的所有节点认识。

故障检测:可能下线 (PFAIL-Possibly Fail) 与确定下线 (Fail)

Redis Cluster 中的节点会定期检查已经发送 PING 消息的接收方节点是否在规定时间 ( cluster-node-timeout ) 内返回了 PONG 消息,如果没有则会将其标记为疑似下线状态,也就是 PFAIL 状态,如下图所示。 ping

然后,节点一会通过 PING 消息,将节点二处于疑似下线状态的信息传递给其他节点,例如节点三。节点三接收到节点一的 PING 消息得知节点二进入 PFAIL 状态后,会在自己维护的 clusterState 的 nodes 字典中找到节点二所对应的 clusterNode 结构,并将主节点一的下线报告添加到 clusterNode 结构的 fail_reports 链表中。 pong

随着时间的推移,如果节点十 (举个例子) 也因为 PONG 超时而认为节点二疑似下线了,并且发现自己维护的节点二的 clusterNode 的 fail_reports 中有半数以上的主节点数量的未过时的将节点二标记为 PFAIL 状态报告日志,那么节点十将会把节点二将被标记为已下线 FAIL 状态,并且节点十会立刻向集群其他节点广播主节点二已经下线的 FAIL 消息,所有收到 FAIL 消息的节点都会立即将节点二状态标记为已下线。如下图所示。 fail

需要注意的是,报告疑似下线记录是有时效性的,如果超过 cluster-node-timeout *2 的时间,这个报告就会被忽略掉,让节点二又恢复成正常状态。

故障检测

Redis 集群节点采用 Gossip 协议 来广播自己的状态以及自己对整个集群认知的改变。比如一个节点发现某个节点失联了 (PFail),它会将这条信息向整个集群广播,其它节点也就可以收到这点失联信息。如果一个节点收到了某个节点失联的数量 (PFail Count) 已经达到了集群的大多数,就可以标记该节点为确定下线状态 (Fail),然后向整个集群广播,强迫其它节点也接收该节点已经下线的事实,并立即对该失联节点进行主从切换。

故障转移

  1. 从节点 发现 自己复制的主节点下线后(收到Fail消息),会给其他主节点发送消息,要求其进行投票; 这个和哨兵的选举Leader类似;
  2. 当从节点收到过半的主节点投票后,将将自己晋升为Master(执行slaveof no one),然后进行广播,告诉其他节点自己晋升为Master了。

网络抖动: 真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。

为解决这种问题,RedisCluster 提供了一种选项 cluster-node-timeout,表示当某个节点持续 timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动会导致主从频繁切换 (数据的重新复制)。

还有另外一个选项 cluster-slave-validity-factor 作为倍乘系数来放大这个超时时间来宽松容错的紧急程度。如果这个系数为零,那么主从切换是不会抗拒网络抖动的。如果这个系数大于 1,它就成了主从切换的松弛系数。

Redis Cluster 架构痛点

Redis Cluster 集群规模限制(300个实例 150分片):

  • 大概集群内超过 300 个实例的时候,gossip 风暴问题严重,严重影响带宽。

同城多机房之间同步:

  • 在 Redis cluster 架构下,同地域内的多个机房的 Redis 集群是相互独立的,机房之间的同步是由另一个单独的组件来做的,这样的一致性和主从切换之后的增量复制都支持的不太好
  • 对于一些灾难场景,比如两个机房之间网络断掉,或者是全量把 Master 切机房等场景,这样的切换在 Redis Cluster 模式下会需要做全量同步,这时候很容易把带宽给打的很满,影响线上服务

解决 gossip 风暴问题的思路是:使用中心化架构,对元信息进行中心化管理;


去中心化架构: 在 Redis Cluster 中,拓扑信息是保存在每个 Redis 实例中的,有拓扑变更时,会通过 gossip 协议扩散到其他所有 Redis 实例里。在实践中我们发现,去中心化的架构在实例数很多的情况下,会产生 gossip 风暴的问题,即用于同步 topom 数据会占用了很大一部分带宽,同时,在线上有非常多的 Redis 集群,对于整体的管理和调度,也很不方便。


中心化架构: 中心化管理的方式需要有一个组件,用来统一管理所有 Redis 集群的拓扑信息。并且暴露出一些方便的运维接口,响应平台的创建/扩容/线下请求,并将 Redis 的元信息持久化,对 Redis进行探活,主从切换。