Redis原理之集群

126 阅读19分钟

Redis系列文章

原理篇

源码篇

问题分析


Redis原理之集群

为了能够保存更多的数据,会采用横向扩展的方式,实现切换集群。对于Redis来说也是,从Redis3.0开始,官方提供了一个Redis Cluster的方案,用于实现切片集群。Redis Cluster方案中规定了数据和实例的对应规则。

1. 数据切片和实例的对应关系

Redis Cluster方案采用哈希槽(Hash Slot)来处理数据和实例之间的映射关系。在Redis Cluster方案中,一个切片集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的Key,被映射到一个哈希槽中。

具体映射过程,可以分两步:

1)首先根据键值对的Key,按照CRC16算法,计算一个16bit的值。

2)然后再用这个16bit的值,对16384取模,得到0—16383范围内的模数,每个模数代表一个相应编号的哈希槽。

哈希槽如何映射到具体Redis实例上呢?

在部署Redis Cluster方案时,可以使用Cluster create命令创建集群,此时Redis会自动把这些槽平均分布在集群实例上,例如:集群中有N个实例,那么每个实例上的槽数为16384/N个。

除了创建时分配,也可以创建完后,手动分配。但是在手动分配哈希槽时,需要把16384个槽分配完,否则Redis集群无法正常工作。

2. 客户端如何定位数据

在定位键值对数据时,所在的哈希槽时可以通过计算得出,这个计算可以在客户端发送请求时就执行,但要进一步定位实例,还需要知道哈希槽分布在哪个实例上。一般来说,客户端和集群实例建立连接后,实例就会就会把哈希槽的分配信息发送给客户端。但是在集群刚建立的时候,每个实例只知道自己被分配到了哪些哈希槽,并不知道其他实例拥有的哈希槽。

那么,客户端为什么可以在访问任何一个实例时,都能获取所有的哈希槽信息?因为,Redis实例会把自己的哈希槽信息发送给和它相连接的其他实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

客户端接收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

1)在集群中,实例有新增或删除,Redis需要重新分配哈希槽。

2)为了负载均衡,Redis需要把哈希槽在所有实例上重新分布一遍。

此外,实例之间还可以相互传递消息,获得最新的哈希槽分配信息,但是客户端无法主动感知这些变化,这就会导致,它缓存的分配信息和实际的分配信息不一致。

Redis Cluster方案提供了一种重定向机制,客户端给一个实例发送数据读写时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

客户端把一个键值对的操作请求发送给一个实例时,如果这个实例上并没有键值对映射的哈希槽,那么这个实例就会给客户端返回以下MOVED命令相应结果,结果中包含了新实例的访问地址。

get Hello:key
(err) MOVED 13320 172.16.17.5:6379

其中MOVED命令表示,客户端请求的键值对所在的哈希槽13320,实际上在172.16.17.5这个实例上。通过返回MOVED命令,就相当于把哈希槽所在的新实例的信息告诉了客户端,这样客户端就可以直接和新实例的访问地址链接,并发送操作请求。

如果实例还正在迁移数据,那么这种情况下,客户端会收到一条ASK的报错信息。如下所示:

get Hello:key
(err) ASK 13320 172.16.17.5:6379

这个ASK命令表示,客户端请求的键值对所在的哈希槽13320,在172.16.17.5这个实例上,但这个哈希槽正在迁移,此时客户端需要先给172.16.17.5发送一个ASKING命令,表示让这个实例允许客户端接下来发送命令,然后客户端再向这个实例(新实例)发送GET命令,以读取数据。

和MOVED命令不同,ASK命令并不会更新客户端缓存的哈希槽分配信息。ASK命令的作用只是让客户端能给新实例发送一次请求。

3. 集群功能限制

1)key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key,由于执行mset、mget等操作可能存在多个节点上,因此不被支持。

2)key事务操作支持有限。同理支持只能支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时,无法使用事务功能。

3)key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash、list等,映射到不同的节点。

4)不支持多数据库空间。单机下Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即db0。

5)复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

4. 节点通信

4.1 通信流程

在分布式存储中需要提供维护节点元数据信息的机制,元数据是指:节点负责哪些数据,是否出现故障等状态信息。Redis集群采用P2P的Gossip协议,Gossip协议工作原理就是节点不断通信交换信息,一段时间后所有节点都会知道集群完整的信息,这种方式类似于流言传播。如下图所示:

image-20210605153307399.png

通信过程:

1)集群中每个节点都会单独开辟一个TCP通道,用于节点直接彼此通信,通信端口号在基础端口号(server.port)上 加10000

2)每个节点在固定周期内通过特定规则选择几个节点发送PING消息。

3)接收到PING节点的消息,用PONG消息作为响应。

4.2 Gossip消息

Gossip协议的主要职责就是信息交换,信息交换的载体就是节点彼此发送的Gossip消息。常用的Gossip消息可以分为:PING消息、PONG消息、MEET消息、FAIL消息等。

  • MEET消息:用于新节点加入。消息发送者通知接收者,加入到当前集群,MEET消息通信完后,接收节点会加入到集群并进行周期性PING、PONG消息交换。
  • PING消息:集群内交换最频繁的消息,用于检测几点是否在线和交换彼此状态消息。PING消息封装了自己和部分其他节点的状态数据。
  • PONG消息:接收到PING、MEET消息,作为响应消息回复给发送方。PPONG消息封装了自身状态数据。
  • FAIL消息:当节点判定集群内另一个节点为fail状态时,会向集群广播一个FAIL消息,其他节点接收后会更新为fail状态。

4.3 节点选择

虽然Gossip协议的信息交换支持,具有天然的分布式特性,但它是有成本的。由于内部需要频繁进行节点信息交换,PING/PONG消息会携带当前节点和其他部分节点的状态数据,会加重带宽和计算的负担。因此节点每次选择需要通信接单列表变得很重要。

1)选择发送消息的节点数量:

集群每个节点维护定时任务,默认每秒执行10次定时任务,每秒随机选择5个节点,找出最久没有通信的一个节点发送PING消息, 用于保证Gossip消息交换的随机性。每100ms会扫描本地节点列表,发现节点最近一次接受PONG的时间大于cluster-node-timeout/2,会立刻发送PING消息,防止节点信息太久没有更新。

2)消息数据量

每个PING消息的数据量,体现在消息头和消息体重。消息头主要占用空间字段为:myslots[CLUSTER_SLOTS/8],占用2KB,这块空间占用相对固定。消息体会携带一定数量的其他节点信息,用于信息交换。选择数量算法如下:

//cluster.c#clusterSendPing
int freshnodes = dictSize(server.cluster->nodes)-2;
//默认包含接单的1/10
wanted = floor(dictSize(server.cluster->nodes)/10);
//至少携带3个其他节点信息
if (wanted < 3) wanted = 3;
if (wanted > freshnodes) wanted = freshnodes;
//pfail状态的节点
int pfail_wanted = server.cluster->stats_pfail_nodes;

最终发送节点的数据,最多为:wanted + pfail_wanted个。

以下是集群中节点数,和PING命令要发送节点数量的关系。(不考虑pfail状态的情况)

集群节点数需要发送的节点数量
31
42
5—393

5. 故障转移

5.1 故障发现

当某个节点出现问题时,需要通过机制识别出节点是否发生故障。Redis集群内的节点通过PING/PONG消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态,如:主从状态、节点故障等。集群节点中如果出现故障,有两种状态:pfail和fail。

pfail状态:

集群中每个节点会定期向其他节点发送ping消息(每隔1s向最久没有回复PONG的节点发送PING,另外超过cluster-node-timeout/2时间内没有发送PING命令,也会发送PING命令),接收节点回复PONG消息作为响应。如果在cluster-node-timeout时间(默认15s)内没有回复,发送节点认为接收节点存在故障,把接收节点标记为pfail状态。pfail状态只能代表一个节点的意见。

fail状态:

当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。ping/pong的消息体会携带集群1/10的其他节点状态数据,pfail节点会优先考虑(实际上会额外多发送pfail状态的节点)。当接收节点发现消息中含有pfail的节点状态时,会在本地找到故障节点,保存到下线报告列表中。

通过Gossip消息传播,集群内的节点不断收集到故障节点的下线报告。当半数以上(大于等于 (N/2 + 1))持有槽的主节点都标记某个节点状态为pfail时,会触发fail状态。

1)为什么是负责槽的主节点参与故障发现决策:因为集群模式下,只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,从节点只进行主节点数据和状态信息的复制。

2)为什么需要半数以上处理槽的主节点?必须半数以上是为了应对网络分区等原因造成集群分割情况。

另外下线报告中保存了报告故障的节点和最近收到下线报告的时间,当触发fail状态判断时,会先检测下线报告是否过期,再获取报告故障节点的数量。如果在cluster-node-time*2(默认30s)的时间内该下线报告没有更新则认为过期,过期的下线报告会被删除。

如果在cluster-node-time*2时间内无法收集一半以上槽节点的下线报告,那么之前的下线报告有可能会过期,那么故障节点就没法被标记为fail,从而导致故障转移失败。因此不建议将cluster-node-time设置得过小。

当标记对应故障节点为fail状态时,会向集群广播一条fail消息,通知所有的节点将故障节点标记为fail状态。fail消息体只包含故障节点的ID。广播fail消息的作用:

1)通知集群内所有的节点,标记故障节点为fail状态,并立即生效。

2)通知故障节点的从节点触发故障转移流程。

5.2 故障恢复

故障节点变为fail状态后,如果故障节点是持有槽的主节点,则需要在它的从节点中选择一个替换它,保证集群的高可用。故障主节点的所有从节点承担故障恢复的义务。

5.2.1 资格检查

每个从节点都要检测与主节点断线时间,判断是否有资格替换故障主节点。如果从节点和主节点断线时间超过cluster-node-time * cluster-slave-validity-factor(默认15s * 10=150s),则当前从节点不具备故障转移资格。

注:该判断只是个简单的判断,实际实现会更详细一些。

步骤一:

与主节点的断线时间分成两种:

1)如果还和主节点保持连接,data_age = 当前时间 - 与主节点最近一次通信时间。

2)如果已经是主节点断开连接:data_age = 当前时间 - 与主节点主节点断开时间。

步骤二:

data_age 如果大于cluster-node-time,则减去cluster-node-time 的时间。

步骤三:

判断data_age 是否大于(mstime_t)server.repl_ping_slave_period * 1000) + (server.cluster_node_timeout * server.cluster_slave_validity_factor)

repl_ping_slave_period 为主从复制,发送PING命令的时间。默认为10s,cluster_slave_validity_factor默认为10

因为实际上,默认情况下,从节点和主节点最大断开时间,不得超过175s。

5.2.2 准备选举时间

当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能继续执行后续流程。这里会采用延迟触发机制。

步骤一

步周,默认都设置为当前时间 + 500ms + (0,500)ms的随机数。

步骤二

会根据复制偏移量来进行排名,偏移量越大,排名越高,也说明从节点延迟越低,应该具有更高的优先级替换故障主节点。

选举时间会再加上,排名 * 1000 ms。如果某个从节点的排名最高,那么它是不需要再加上这部分时间的。

步骤三

在等待故障选举时间过程中,会重新计算排名,如果排名变后了,则会重新加上新旧判断差距的延迟时间。

5.2.3 发起选举

当故障选举时间达到后,会发起选举流程:

步骤一:更新全局配置纪元

配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元(clusterNode.configEpoch),标示当前主节点的版本,所有的主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。整个集群又维护了一个全局的配置纪元(clusterState.currentEpoch),用于记录集群内所有主节点配置纪元的最大值。

配置纪元会根据ping/pong消息在集群内传播,当发送方与接收方都是主节点,且配置纪元相等是,代表出现了冲突,nodeId更大的一方会递增全局配置纪元,并赋值给当前节点来区分冲突。

配置纪元的作用:

1)标记集群内每个主节点的不同版本,和当前集群的最大版本。

2)每次集群发生重要事件(新加入节点或者主从切换),从节点竞争选举。都会递增集群全局的配置纪元,并赋值给相关主节点,用来记录这一关键事件。

3)主节点具有更大的配置纪元,代表了更新的集群状态。当节点进行ping/pong消息交换时,出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。

步骤二:广播选举消息

在集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。

5.2.4 选举投票

只有持有槽的主节点才会处理故障选举消息。 每个持有槽的主节点在一个配置纪元内都有唯一个一张选票,当接到第一个请求投票的从节点消息时,回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将被忽略。

Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够(N/2 + 1)个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行选举,即使只有一个从节点也可以完成选举过程。

当从节点收集到(N/2 + 1)个持有槽的主节点投票时,从节点可以执行替换主节点操作。

每个配置纪元代表一次选举周期,如果在投票之后的MAX(cluster-node-timeout * 2 ,2000ms)时间内没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

5.2.5 替换主节点

当从节点收集到足够的选票后,触发替换主节点操作:

1)当前从节点取消复制,变成主节点。

2)执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。

3)向集群广播自己的pong消息,通知集群内所有的节点,当前从节点变为主节点并接管了故障主节点的槽信息。

5.2.6 故障转移时间

1)pfail监控时间:cluster-node-timeout

2)pfail消息传播时间<=cluster-node-timeout/2。定时任务会对超过cluster-node-timeout/2没有发送PING命令,也会发送PING消息。消息体在选择包含哪些节点时,会优先选取下线状态节点。因此通常可以在这段时间内收集到半数以上主节点的pfail报告,从而完成故障发现。

3)从节点故障转移 <=1000ms。由于延迟发起选举机制,偏移量最大的从节点最多延迟1秒发起选举。通常第一次选举就会成功。

所以预估故障转移时间如下:单位ms,默认配置下,大概23.5s可以完成。

failover-time <= cluster-node-timeout + cluster-node-timeout/2 + 1000

6. 副本漂移

考虑如下图所示部署方式,集群中有6个实例,3主3从。每对主从只能有一个处于故障状态。假设一对主从同时发生故障,则集群中的某些slot会处于不能提供服务的状态,从而导致集群失效。

image-20210605161646090.png

为了提高可靠性,可以在每个主服务下面各挂载两个从服务实例,在上图所示,共需要增加3个实例。但假设集群中有100个主服务,为了更高的可靠性,就需要增加100个实例。为了提高可靠性,随集群规模扩大,从服务器实例数量线性增加。为了解决这个问题,提供了一种副本漂移的方法。

只需要给一个主C增加两个从服务器,假设A发生故障,主A的从A1执行切换,切换完成之后从A1变成主A1。此时主A1会出现单点问题,当检测到单点问题后,集群会主动从主C的从服务中漂移一个给有单点问题的主A1做从服务器。如下图所示:

image-20210605162302426.png

在Cluster定时任务中,会检测如下条件:

1)是否存在单点的主节点,即主节点没有任何一台可用的从节点。

2)是否存在有两台及以上可用从节点的主节点。

如果以上两个条件都满足,从有最多可用从节点名的主节点中选择一台从节点执行副本漂移。选择标准为按节点名称的字母序从小到大,选择最靠前的一台从节点执行漂移。过程如下:

1)从C的记录中将C1移除。

2)将C1所记录的主节点修改为A1。

3)在A1中添加C1为从节点。

4)将C1的数据同步源设置为A1。

7. 参考资料

1)《Redis核心技术与实战》——极客时间

2)《Redis运维与实战》

3)《Redis 5源码设计与分析》