深入解析 Redis Cluster 架构与实现(二)

发布于:2025-05-31 ⋅ 阅读:(18) ⋅ 点赞:(0)

#作者:stackofumbrella

集群运行时重新配置(live reconfiguration)

在Cluster运行时增加、删除node,这两种操作都会导致slots在node之间的迁移,当然这种机制也可用来集群数据的rebalance等等。

1)集群中新增一个node,需要将其他node上的部分slots迁移到此node上,以实现数据负载的均衡分配。

2)集群中移除一个node,那么在移除节点之前,必须将此节点上(如果此节点没有任何slaves)的slots迁移到其他node。

3)如果数据负载不均衡,比如某些slots数据集较大、负载较大时,需要把它们迁移到负载较小的nodes上(即手动resharding),以实现集群的负载平衡。

Cluster支持slots在node间移动,从实际的角度来看,一个slot只是一序列keys的逻辑标识,所以Cluster中slots的迁移,其实就是一序列keys的迁移,不过resharding操作只能以slot为单位(而不能仅仅迁移某些keys)。Redis提供了如下几个操作:
1)CLUSTER ADDSLOTS [slot]

2)CLUSTER DELSLOTS [slot]

3)CLUSTER SETSLOT [slot] NODE [node]

4)CLUSTER SETSLOT [slot] MIGRATING [destination-node]

5)CLUSTER SETSLOT [slot] IMPORTING [source-node]

前两个指令ADDSLOTS和DELSLOTS,用于向当前node分配或移除slots,指令可以接受多个slot值。分配slots的意思是告知指定的master(即此指令需要在某个master节点执行)此后由它接管相应slots的服务。slots分配后,这些信息将会通过gossip发给集群的其他node。

ADDSLOTS指令通常在创建一个新的Cluster时使用,一个新的Cluster有多个空的Masters构成,此后管理员需要手动为每个master分配slots,并将16384个slots分配完毕,集群才能正常服务。简而言之,ADDSLOTS只能操作那些尚未分配的(即不被任何nodes持有)slots,通常在创建新的集群或者修复一个broken的集群(集群中某些slots因为nodes的永久失效而丢失)时使用。为了避免出错,Redis Cluster提供了一个redis-trib辅助工具。

DELSLOTS就是将指定的slots删除,前提是这些slots必须在当前node上,被删除的slots处于“未分配”状态(当然其对应的keys数据也被clear),即尚未被任何nodes覆盖,这种情况可能导致集群处于不可用状态,此指令通常用于debug,在实际环境中很少使用。那些被删除的slots,可以通过ADDSLOTS重新分配。

SETSLOT是个很重要的指令,对集群slots进行reshard的最重要手段,它用来将单个slot在两个node间迁移。
根据slot的操作方式,它有两种状态“MIGRATING”、“IMPORTING”(或者说迁移的方式)。

1)MIGRATING:将slot的状态设置为“MIGRATING”,并迁移到destination-node上,需要注意当前node必须是slot的持有者。在迁移期间,Client的查询操作仍在当前node上执行,如果key不存在,则会向Client反馈“-ASK”重定向信息,此后Client将会把请求重新提交给迁移的目标node。

2)IMPORTING:将slot的状态设置为“IMPORTING”,并将其从source-node迁移到当前node上,前提是source-node必须是slot的持有者。Client交互机制同上。

假如有两个节点A、B,其中slot 8在A上,要将slot 8从A迁移到B,可以使用如下方式:

1)在B上:CLUSTER SETSLOT 8 IMPORTING A

2)在A上:CLUSTER SETSLOT 8 MIGRATING B

在迁移期间,集群中其他node的集群信息不会改变,即slot 8仍对应A,即此期间,Client查询仍在A上:

1)如果key在A上存在,则由A执行;

2)否则,将向客户端返回ASK,客户端将请求重定向到B。

这种方式下,新key的创建就不会在A上执行,而是在B上执行,这也就是ASK重定向的原因(迁移之前的keys在A,迁移期间created的keys在B上)。当上述SETSLOT执行完毕后,slot的状态也会被自动清除,同时将slot迁移信息传播给其他node,至此集群中slot的映射关系将会变更,此后slot 8的数据请求将会直接提交到B上。

slave扩展read请求

通常情况下,read、write请求都将由持有slots的master节点处理。redis的slaves同样可以支持read操作(前提是application能够容忍stale数据),所以客户端可以使用“READONLY”指令来扩展read请求。

“READONLY”表明其可以访问集群的slaves节点,能够容忍stale数据,而且此次链接不会执行write操作。当链接设定为readonly模式后,Cluster只有当keys不被slave的master节点持有时才会发送重定向消息(即Client的read请求总是发给slave,只有当此slave的master不持有slots时才会重定向),此时Client更新本地的slot配置信息,同上文所述(目前很多Client实现均基于连接池,所以不能非常便捷的设置READLONLY选项,非常遗憾)。

心跳与gossip消息

集群中的node持续的交换ping、pong数据,这两种数据包的结构一样,同样都能携带集群的配置信息,唯一不同的就是message中的type字段。通常一个node发送ping消息,那么接收者将会反馈pong消息,不过有时候并非如此,或许接收者将pong信息发给其他的node,而不是直接反馈给发送者,比如当集群中添加新的node时。通常一个node每秒都会随机向几个node发送ping,所以无论集群规模多大,每个nodes发送的ping数据包的总量是恒定的。每个node都确保尽可能的向那些在半个NODE_TIMEOUT时间内,尚未发送过ping或接收到它们的pong消息的node发送ping。在NODE_TIMEOUT逾期之前,node也会尝试与那些通讯异常的node重新建立TCP链接,确保不能仅仅因为当前链接异常而认为它们就是不可达的。

当NODE_TIMEOUT值较小、集群中node规模较大时,那么全局交换的信息量也会非常庞大,因为每个node都尽力在半个NODE_TIMEOUT时间内,向其他nodes发送ping。比如有100个node,NODE_TIMEOUT为60秒,那么每个node在30秒内向其他99个node发送ping,平均每秒3.3个消息,那么整个集群全局就是每秒330个消息。这些消息量,并不会对集群的带宽带来不良问题。ping和pong数据包中也包含gossip部分,这部分信息包含sender持有的集群视图,不过它只包含sender已知的随机几个node,node的数量根据集群规模的大小按比例计算。gossip部分包含了nodes的ID、ip+port、flags,那么接收者将根据sender的视图,来判定节点的状态,这对故障检测、节点自动发现非常有用。

心跳数据包的内容

1)node ID;

2)currentEpoch和configEpoch;

3)node flags:比如表示此node是master、slave等;

4)hash slots:发送者持有的slots;

5)如果发送者是slave,那么其master的ID;

6)其他…

失效检测

集群失效检测就是当某个master或slave不能被大多数node可达时,用于故障迁移并将合适的slave提升为master。当slave提升未能有效实施时,集群将处于error状态且停止接收Client端查询。如上所述,每个node持有其已知node的列表包括flags,有2个flag状态:PFAIL和FAIL。PFAIL表示“可能失效”,是一种尚未完全确认的失效状态(即某个节点或者少数masters认为其不可达)。FAIL表示此node已经被集群大多数master判定为失效(大多数master已认定为不可达,且不可达时间已达到设定值,需要failover)。

PFAIL

一个被标记为PFAIL的节点,表示此node不可达的时间超过NODE_TIMEOUT,master和slave有可能被标记为PFAIL。所谓不可达,就是当“active ping”(发送ping且能收到pong)尚未成功的时间超过NODE_TIMEOUT,因此设定的NODE_TIMEOUT的值应该比网络交互往返的时间延迟要大一些(通常要大的多,以至于交互往返时间可以忽略)。为了避免误判,当一个node在半个NODE_TIMEOUT时间内仍未能接收pong,那么当前node将会尽力尝试重新建立连接进行重试,以排除pong未能接收是因为当前链接故障的问题。

FAIL

PFAIL只是当前node有关于其他node的本地视图,可能每个node对其他nodes的本地视图都不一样,所以PFAIL还不足以触发Failover。处于PFAIL状态下的node可能会被提升到FAIL状态。如上所述,每个node在向其他node发送gossip消息时,都会包含本地视图中几个随机node的状态信息,每个node最终都会从其他node发送的消息中获得一组node的flags。因此,每个node都可以通过这种机制来通知其他node它检测到的故障情况。

PFAIL被上升为FAIL的集中情况:

1)比如A节点,认为B为PFAIL;

2)那么A通过gossip信息,收集集群中大多数master关于B的状态视图;

3)多数master都认为B为PFAIL,或PFAIL情况持续时间为NODE_TIMEOUT * FAIL_REPORT_VALIDITY_MULT(此值当前为2)。

如果上述条件成立,那么A将会:

1)将B节点设定为FAIL;

2)将FAIL信息发送给其所有能到达的所有节点。

每个接收到FAIL消息的节点都会强制将此node标记为FAIL状态,不管此节点在本地视图中是否为PFAIL。FAIL状态是单向的,即PFAIL可以转换为FAIL,但是FAIL状态只能清除,不能回转为PFAIL:

1)当此node已经变的可达,且为slave,这种情况下FAIL状态将会被清除,因为没有发生failover。

2)此node已经可达,且是一个没有服务任何slots的master(空的master)。这种情况下,FAIL将会被清除,因为master没有持有slots,所以它并没有真正参与到集群中,需要等到重新配置以便它加入集群。

3)此node已经可达,且是master,且在较长时间内(N倍的NODE_TIMEOUT)没有检测到slave的提升,即没有slave发生failover(比如此master下没有slave),那么它只能重新加入集群且仍为master。需要注意的是PFAIL->FAIL的转变,使用了“协议”(agreement)的形式:

1)node会间歇性的收集其他node的视图,即使大多数masters都“agree”,事实上这个状态仅仅是从不同的node、不同的时间收集到的,无法确认(也不需要)在特定时刻大多数masters是否“agree”。丢弃较旧的故障报告,所以此故障(FAIL)是有大多数masters在一段时间内的信号。

2)虽然每个node在检测到FAIL情况时,都会通过FAIL消息发送给其他node,但是无法保证消息一定会到达所有的node,比如可能当前节点(发送消息的node)因为网络分区与其他部分node隔离了,如果只有少数master认为某个node为FAIL,并不会触发相应的slave提升,即failover。FAIL标记只是用来触发slave提升,在原理上当master不可达时将会触发slave提升,不过当master仍然被大多数可达时,它会拒绝提供相应的确认。

Failover相关的配置

集群currentEpoch

Redis Cluster使用了类似于Raft算法term(任期)的概念,那么在redis Cluster中term称为epoch,用来给events增量版本号。当多个node提供的信息有冲突时,它可以作为node知道哪个状态是最新的。currentEpoch为一个64位无签名数字。

在集群node创建时,master和slave都会将各自的currentEpoch设置为0,每次从其他node接收到数据包时,如果发现发送者的epoch值比自己的大,那么当前node将自己的currentEpoch设置为发送者的epoch。由此,最终所有的node都会认同集群中最大的epoch值。当集群状态变更或为了执行某个行为需求agreement时,都将需要epoch传递或者比较。当前来说,只有在slave提升期间发生。

configEpoch

每个master总会在ping、pong数据包中携带自己的configEpoch以及它持有的slots列表。新创建的node,其configEpoch为0,slave通过递增它们的configEpoch来替代失效的master,并尝试获得其他大多数master的授权。当slave被授权,一个新的configEpoch被生成,slave提升为master且使用此configEpoch。接下来介绍configEpoch帮助解决冲突,当不同的node宣称有分歧的配置时,slaves在ping、pong数据包中也会携带自己的configEpoch信息,不过这个epoch为它与master在最近一次数据交换时master的configEpoch。每当节点发现configEpoch值变更时,都会将新值写入nodes.conf文件,当然currentEpoch也是如此。这两个变量在写入文件后会伴随磁盘的fsync持久写入。严格来说,集群中所有的master都持有唯一的configEpoch值。同一组master-slaves持有相同的configEpoch。

slave选举与提升

在slave节点中进行选举,在其他master的帮助下进行投票,选举出一个slave并提升为master。当master处于FAIL状态时,将会触发slave的选举。slave都希望将自己提升为master,此master的所有slave都可以开启选举,不过最终只有一个slave获胜。当如下情况满足时,slave将会开始选举:

1)当此slave的master处于FAIL状态;

2)此master持有非零个slots;

3)此slave的replication链接与master断开时间没有超过设定值,为了确保此被提升的slave的数据是新鲜的,这个时间用户可以配置。

为了选举,第一步就是slave自增currentEpoch值,然后向其他master请求投票(需求支持votes)。slave通过向其他master传播“FAILOVER_AUTH_REQUEST”数据包,然后最长等待2倍的NODE_TIMEOUT时间来接收反馈。一旦一个master向此slave投票,将会响应“FAILOVER_AUTH_ACK”,此后在2 * NODE_TIMOUT时间内,它将不会向同一个master的slave投票。虽然这对保证安全上没有必要,但是对避免多个slave同时选举时有帮助。slave将会丢弃那些epoch值小于自己的currentEpoch的AUTH_ACK反馈,即不会对上一次选举的投票计数。一旦此slave获取了大多数master的ACKs,它将在此次选举中获胜。否则如果大多数master不可达(在2 * NODE_TIMEOUT)或投票额不足,那么它的选举将会被中断,那么其他的slave将会继续尝试。

slave rank(次序)

当master处于FAIL状态时,slave将会随机等待一段时间,然后才尝试选举,等待的时间:

DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms。一定的延迟确保等待FAIL状态在集群中传播,否则slave立即尝试选举(不进行等待的话),不顾此时其他master尚未意识到FAIL状态,可能会拒绝投票。

延迟的时间是随机的,这用来“去同步”(desynchronize),避免slave同时开始选举。SLAVE_RANK表示此slave已经从master复制数据的总量的rank。当master失效时,slave之间交换消息以尽可能的构建rank,持有replication offset最新的rank为0,第二最新的为1,依次轮推。这种方式下,持有最新数据的slave将会首先发起选举(理论上)。当然rank顺序也不是严格执行的,如果一个持有较小rank的slave选举失败,其他slaves将会稍后继续。一旦slave选举成功,它将获取一个新的、唯一的、自增的configEpoch值,此值比集群中任何masters持有的都要大,它开始宣称自己是master,并通过ping、pong数据包传播,并提供自己的新configEpoch以及持有的
slots列表。为了加快其他nodes的重新配置,pong数据包将会在集群中广播。当前node不可达的那些节点,它们可以从其他节点的ping或pong中获知信息(gossip)并重新配置。其他节点也会检测到这个新的master和旧master持有相同的slots,且持有更高的configEpoch,此时也会更新自己的配置(epoch以及master),旧master的slave不仅仅更新配置信息,也会重新配置并与新的master跟进(slave of)。

Masters响应slave的投票请求

当Master接收到slave的“FAILOVER_AUTH_REQUEST”请求后,开始投票,不过需要满足如下条件:

1)此master只会对指定的epoch投票一次,并拒绝对旧的epoch投票。每个master都持有一个
lastVoteEpoch,将会拒绝AUTH_REQUEST中currentEpoch比lastVoteEpoch小的请求。当master响应投票
时,将会把lastVoteEpoch保存在磁盘中;

2)此slave的master处于FAIL状态时,master才会投票;

3)如果slave的currentEpoch比此master的currentEpoch小,AUTH_REQUEST将被忽略。因为master只会响应那些与自己的currentEpoch相等的请求。如果同一个slave再次请求投票,持有已经增加的
currentEpoch,它(slave)将保证旧的投票响应不能参与计票。

比如master的currentEpoch为5,lastVoteEpoch为1:

1)slave的currentEpoch为3;

2)slave在选举开始时,使用epoch为4(先自增),因为小于master的epoch,所以投票响应被延缓;

3)slave在一段时间后将重新选举,使用epoch为5(4 + 1,再次自增),此时master上延缓的响应发给
slave,接收后视为有效。

Hash Slots配置传播

Redis Cluster中重要的一部分就是传播集群中哪些节点上持有哪些hash slots信息。无论是启动一个新的集群,还是当master失效其slave提升后更新配置,这对它们都至关重要。有2种方式用于hash slot配置的传播:

1)heartbeat 消息:发送者的ping、pong消息中,总是携带自己目前持有的slots信息,不管自己是master还是slave。

2)UPDATE 消息:因为每个心跳消息中会包含发送者的configEpoch和其持有的slots,如果接收者发现发送者的信息已经stale(比如发送者的configEpoch值小于持有相同slots的master的值),它会向发送者反馈新的配置信息(UPDATE),强制stale节点更新它。

当一个新的节点加入集群,其本地的hash slots映射表将初始为NULL,即每个hash slot都没有与该节点绑定。如果此node本地视图中一个hash slot尚未分配(设置为NULL),并且有一个已知的node声明持有它,那么此node将会修改本地hash slot的映射表,将此slot与那个node关联。slave的failover操作、reshard操作都会导致hash slots映射的变更,新的配置信息将会通过心跳在集群中传播。如果此node的本地视图中一个hash slot已经分配,并且一个已知的node也声明持有它,且此node的configEpoch比当前slot关联的master的configEpoch值更大,那么此node将会把slot重新绑定到新的node上。根据此规则,最终集群中所有的nodes都赞同那个持有声明持有slot、且configEpoch最大值的node为slot的持有者。

nodes如何重新加入集群

node A被告知slot 1、2现在由node B接管,假如这两个slots目前由A持有,且A只持有这两个slots,如果A将放弃这2个slots,成为空的节点。如果A被重新配置,成为其他新master的slave。这个规则可能有些复杂,A离群一段时间后重新加入集群,此时A发现此前自己持有的slots已经被其他多个nodes接管,比如slot 1被B接管,slot 2被C接管。在重新配置时,最终此节点上的slots将会被清空,那个窃取A最后一个slot的node,将成为它的新master。节点重新加入集群,通常发生在failover之后,旧的master(也可以为slave)离群,然后重新加入集群。

Replica迁移

Redis Cluster实现了一个成为“Replica migration”的概念,用来提升集群的可用性。比如集群中每个master都有一个slave,当集群中有一个master或者slave失效时,而不是master与它的slave同时失效,集群仍然可以继续提供服务。

1)master A,有一个slave A1;

2)master A失效,A1被提升为master;

3)一段时间后,A1也失效了,那么此时集群中没有其他的slave可以接管服务,集群将不能继续服务。

如果masters与slaves之间的映射关系是固定的(fixed),提高集群抗灾能力的唯一方式,就是给每个master增加更多的slaves,不过这种方式开支很大,需要更多的redis实例。解决这个问题的方案,可以将集群非对称,且在运行时可以动态调整master-slave的布局(而不是固定master-slave的映射),比如集群中有三个master A、B、C,它们对应的slave为A1、B1、C1、C2,即C节点有2个slaves。Replica迁移可以自动的重新配置slave,将其迁移到某个没有slave的master下。

1)A失效,A1被提升为master;

2)此时A1没有任何slave,但是C仍然有2个slave,此时C2被迁移到A1下,成为A1的slave;

3)此后某刻A1失效,那么C2将被提升为master,集群可以继续提供服务。