Redis系列文章
原理篇
源码篇
- 【Redis源码分析之基础流程】
- 【Redis源码分析之持久化】
- 【Redis源码分析之主从复制】
- 【Redis源码分析之哨兵】
- 【Redis源码分析之集群故障转移】
- 【Redis源码分析之集群Meet命令和请求路由】
问题分析
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协议工作原理就是节点不断通信交换信息,一段时间后所有节点都会知道集群完整的信息,这种方式类似于流言传播。如下图所示:
通信过程:
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状态的情况)
| 集群节点数 | 需要发送的节点数量 |
|---|---|
| 3 | 1 |
| 4 | 2 |
| 5—39 | 3 |
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会处于不能提供服务的状态,从而导致集群失效。
为了提高可靠性,可以在每个主服务下面各挂载两个从服务实例,在上图所示,共需要增加3个实例。但假设集群中有100个主服务,为了更高的可靠性,就需要增加100个实例。为了提高可靠性,随集群规模扩大,从服务器实例数量线性增加。为了解决这个问题,提供了一种副本漂移的方法。
只需要给一个主C增加两个从服务器,假设A发生故障,主A的从A1执行切换,切换完成之后从A1变成主A1。此时主A1会出现单点问题,当检测到单点问题后,集群会主动从主C的从服务中漂移一个给有单点问题的主A1做从服务器。如下图所示:
在Cluster定时任务中,会检测如下条件:
1)是否存在单点的主节点,即主节点没有任何一台可用的从节点。
2)是否存在有两台及以上可用从节点的主节点。
如果以上两个条件都满足,从有最多可用从节点名的主节点中选择一台从节点执行副本漂移。选择标准为按节点名称的字母序从小到大,选择最靠前的一台从节点执行漂移。过程如下:
1)从C的记录中将C1移除。
2)将C1所记录的主节点修改为A1。
3)在A1中添加C1为从节点。
4)将C1的数据同步源设置为A1。
7. 参考资料
1)《Redis核心技术与实战》——极客时间
2)《Redis运维与实战》
3)《Redis 5源码设计与分析》