Redis集群进阶之路
Redis集群规范
本文档基于Redis 3.X或更高版本,讲解Redis集群算法以及设计原理。此官方文档长期更新且随着Redis新版本特性的变化变动,详细请留意官网。
官网地址:https://redis.io/topics/cluster-spec
主要特性和设计原理
Redis集群目标
Redis集群作为Redis的一个分布式实现,主要实现以下目标(按重要性排序):
·高性能,以及高达的1000个节点的线性可扩展性(linear scalability ),而且是在没有使用代理,异步复制,在值(value)上不执行合并操作的情况下
·写安全(write safety)控制在可接受的范围:当客户端与大多数master建立连接,Redis架构会尽量保持来自客户端的写入请求。其中会存在已确认的写入请求可能会丢失,网络隔离场景下,较小的网络分区中丢失的已确认的写入请求会更大。
·可用性(Availability):在大部分master节点可达,且每个不可达的master节点都只少有一个他的slave节点可达的情况下,Redis集群仍能进行正常运行。此外,基于备份迁移功能,当一个master节点没有slave副本节点时,集群会从有多个slave副本节点的master迁移一定数量的slave节点给它
已实现的部分功能
Redis集群实现了所有Redis非分布式版本提供的单键命令,只要键存储在相同节点就可以执行复杂的多键操作命令(像set类型的合集或交集的命令)。
Redis集群实现了“哈希标签”的概念,可强制某些键存储在同一节点。但在手动重新绑定期间,多键操作可能不可用,但单键操作始终可用。
Redis集群中只有数据库0(db0),且不支持SELECT命令去选择数据库,会报错“(error) ERR SELECT is not allowed in cluster mode”。
Redis集群协议中客户端和服务器的角色
Redis集群中,节点负责保存数据、集群状态信息,以及负责将键映射到正确的节点。集群节点能自动发现其他节点,并检查其是否正常,必要时将slave提升为master。
在执行任务时,集群节点使用TCP总线和二进制协议组成的Redis集群总线(Redis Cluster Bus)进行互相连接。节点使用gossip协议来传播和集群信息,这样可以:发现新节点、发送ping包(用来确保所有节点都处于正常状态),以及在发生特殊情况时发送集群消息。集群总线也用于在集群汇中传播PUB/SUB消息,如管理员手动执行故障转移时。
由于集群节点不能代理请求,所以客户端在接收到重定向错误“-MOVED”和“-ASK”时,会将命令重定向到其他节点。理论上客户端可自由地向集群中所有节点发送请求,必要时会将请求重定向到其他节点,所以客户端不需要保存集群状态。不过客户端可以缓存键值和节点间的映射关系,如此可提高命令执行效率。
写入的安全性
Redis集群节点间使用异步复制(asynchronous replication)传输数据,最后一次故障转移执行隐式合并动作(last failover wins implicit merge function)。这意味着最后被选举出来的master的数据集最终会覆盖到其他slave副本节点。
使用异步复制的缺点就是在故障期间总会丢失一点数据,但连接到大多数master节点的客户端与连接到极少部分master节点的客户端情况完全不同。Redis集群会尽量保存所有与大多数master节点连接的客户端执行的写入,但以下两种情况除外:
1、一个写入操作能到达一个master节点,但当master节点准备回复客户端时,此写入可能还未通过异步复制到它的slave复制节点。若master节点在写入还未复制到slave节点时挂掉,那么此次写入就会丢失,若master节点不可达,就会有一个合适的slave被提升为新master。
2、理论上另一种写入可能的丢失的情况:
a、网络故障造成网络分区,致使master节点不可达
b、故障转移导致slave节点被提升为master
c、Master节点再次可达
d、网络分区后master节点隔离,使用过时路由信息(out-of-date routing table)的客户端写入数据到旧master节点
第二种情况发生的可能性比较小,主要因为master节点不可达一定时间,将不会再接收任何写入请求,且会有其他新master替代它,当网络恢复后仍然会在一小段时间内拒绝写入请求,以便其他节点收到配置更新的通知。处于这种状况也需要客户端路由信息还未更新。
通常所有节点都会尝试通过非阻塞连接尝试(non-blocking connection attempt)尽快去访问一个再次加入到集群里的节点,一旦跟该节点建立一个新的连接就会发送一个ping包过去(这足够升级节点配置信息)。这保证了一个节点在恢复可写入状态之前先更新知配置信息。Redis集群在拥有少数master节点和至少一个客户端的分区上容易丢失为数不少的写入操作,这是因为若master节点被故障转移到集群中多数节点那边的节点上, 那么所有发送到这些master节点的写入操作都会永久性丢失。
一个master节点被故障转移,必须大多数master节点至少“NODE_TIMEOUT”时间无法访问该节点,所以若网络问题在这段时间内恢复,就不会有写入操作丢失。在网络故障造成网络分区情况下,当分区持续超过“NODE_TIMEOUT”时间,集群节点较少的分区的所有写入可能会丢失,但此分区也会禁止客户端的所有写入请求,因此在少数节点所在的分区变得不可用(写)后,会产生一个最大写入损失量(网络分区产生后直至禁止写入时刻),这个量的数据会在网络恢复,旧master成为新master后丢弃。
可用性
Redis集群在节点数比较少分区的不可用。假设集群多数节点所在分区里有大多数可达的master节点,且对于每个不可达的master都至少有一个slave节点可达,在经过“NODE_TIMEOUT”时间后,就会有合适的slave节点被选举出来然后故障转移掉他的master节点,此时集群会再次可用(故障转移通常在1~2s内完成)。
由此可看出Redis集群只能容忍少数节点故障,对于大面积网络故障甚至造成网络分区的情况来说(the event of large net splits),Redis并不能提供很好的可用性。如,一个集群由N个master节点组成,每个master都有一个slave复制节点。当有一个节点因为网络问题被隔离出去,多数节点所在分区仍然能可用。当两个节点在相同问题下被隔离出去集群仍可用过的概率是1-(1/(N*2-1))(在第一个节点故障后还剩下N*2-1个节点,那么失去slave节点只剩master节点的出错的概率是1/(N*2-1))
如一个集群有5个master节点,每个节点都只有一个slave节点,那么在两个节点被隔离分割出去集群不再可用的概率是1/(5*2-1) = 0.1111,即约11%的概率。
Redis集群的备份迁移功能很好的降低了这个概率,备份迁移的主要功能就是避免孤立的master出现,或者尽量保证每个master都一个slave复制节点。因此每当有孤立的master出现,redis集群的备份迁移机制就会启动。但若一组master和slave节点同时全部故障,备份迁移就没用了,集群也会有部分数据不可用,或者集群直接不可用。因此,在资源足够的情况下,尽量将master和slave部署在不同的虚拟机,甚至物理机,数据中心等等。
性能
Redis集群中节点并不是把命令转发到管理所给出键值的正确的节点上,而是把客户端重定向到服务一定范围内键值的节点上。最终客户端获得一份最新的集群路由表,记录哪些节点服务哪些范围的键,因此在正常操作中客户端是直接连接到对应的节点来发送指令。
Redis使用异步复制,节点不会等待其他节点的写入确认(若未使用“WAIT”明确指出)
多键指令仅用于相邻的键,不是重新分片,数据是永远不会在节点间移动的。单键操作等普通操作和Redis单实例一样。这意味着由于线性扩展性的设计,在一个拥有N个master节点的redis集群和Redis独立实例上执行相同的操作,前者的性能将是Redis单实例的N倍。但请求总是能从客户端到达服务器,并从服务器返回数据回复客户端,且客户端会与节点保持长连接,所以延迟问题两者一样。
非常高的性能和可扩展性,同时保持弱但合理的数据安全性和可用性是Redis集群的主要目标。
为什么要避免使用合并操作
Redis集群设计原理是避免在多个节点中存在同个键值对导致冲突,但这并不总是理想的。Redis中的值通常比较大,列表或有序集合中存储数以万计的元素是比较常见的。数据类型的语义也很复杂。传输和合并这类值可能会形成架构主要瓶颈,另外可能需要应用层使用大量的逻辑,以及额外的内存用来存储元数据等等。
There are no strict technological limits here. CRDTs or synchronously replicated state machines can model complex data types similar to Redis. However, the actual run time behavior of such systems would not be similar to Redis Cluster. Redis Cluster was designed in order to cover the exact use cases of the non-clustered Redis version.
Redis集群主要组件概述
键分布模型(Keys distribution model)
键空间(key space)被切割成16384个哈希槽,每个哈希槽存在于一个master节点,那么一个集群最多有16384个master节点,但是一个集群建议的最大master节点数是1000个。
集群中每个master负责16384个哈希槽中的一小部分。当集群没有重新配置(哈希槽从一个节点移动到另一节点)时,集群是稳定的。当集群处于稳定状态,一个哈希槽只被一个master节点负责,但master节点可以有一个或多个slave复制节点,可以在master故障时替换之,且这样可以用来水平扩展读操作(这些读操作不要求实时数据))。
用于将键映射到哈希槽的是本算法如下(下一段落,除了哈希标签以外就是按照这个规则):
HASH_SLOT = CRC16(key) mod 16384
其中,CRC16的定义如下:
·名称:XMODEM(也可以称为 ZMODEM 或 CRC-16/ACORN)
·输出长度:16 bit
·多项数(poly):1021(即是 x16 + x12 + x5 + 1 )
·初始化:0000
·反射输入字节(Reflect Input byte):False
·反射输入CRC(Reflect Output CRC):False
·用于输出CRC的异或常量(Xor constant to output CRC):0000
·该算法对于输入”123456789”的输出:31C3
CRC16的16位输出中的14位会被使用(这也是为什么上面的式子中有一个对16384取余的操作)。在测试中,CRC16能相当好地把不同的键均匀地分配到16384个槽中。
注意: 在本文档的附录A中有CRC16算法的实现。
键哈希标签(Keys hash tags)
为了实现哈希标签而使用的hash槽计算有一个例外,哈希标签是确保两个键都在同一个哈希槽里的一种方式,主要用来实现集群中多键操作(multi-key operations)。
为了实现哈希标签,在某些条件下,键的哈希槽以另一种不同的方式计算。若键是“{...}”模式,那只有“{”和“}”间的字符串用做哈希计算以获取哈希槽。但同时出现多个“{”和“}”是可能的,详细计算方法如下:
·当键包含一个“{”
·且当“{”右边有一个“}”
·且当第一次出现和第一次出现“}”间有一个或多个字符
然而不是直接计算哈希,而是拿出第一个“{”和它右边第一个“}” 间的字符串计算哈希值。
示例:
·{user1000}.following和{user1000}.followers这两个键会被哈希到同一个哈希槽,因为只有“user1000”这个子串会被用来计算哈希值
·foo{}{bar}这个键,“foo{}{bar} ”整个字符串都被用来计算哈希值,因为第一个出现的“{”和它右边第一个出现的“}”间没有任何字符
·foo{{bar}}zap这个键,“{bar”这个字符串会被用来计算哈希值,因为它是第一个出现的“{”和它右边第一个出现的“}”间的字符
·foo{bar}{zap}这个键,“bar”这个字符串会被用来计算哈希值,因为算法会在第一次有效或无效(中间没有任何字符)地匹配到第一个“{”和它右边第一个“}”时停止
·如此,若一个键是以“{}”开头,那么整个键字符会被用来计算哈希值。当使用二进制数据作为键名称时非常有用。
加上哈希标签的特殊处理,下面是用Ruby和C语言实现的HASH_SLOT函数。
Ruby 样例代码:
def HASH_SLOT(key) s = key.index "{" if s e = key.index "}",s+1 if e && e != s+1 key = key[s+1..e-1] end end crc16(key) % 16384 End
C 样例代码:
unsigned int HASH_SLOT(char *key, int keylen) { int s, e; /* start-end indexes of { and } */ /* Search the first occurrence of '{'. */ for (s = 0; s < keylen; s++) if (key[s] == '{') break; /* No '{' ? Hash the whole key. This is the base case. */ if (s == keylen) return crc16(key,keylen) & 16383; /* '{' found? Check if we have the corresponding '}'. */ for (e = s+1; e < keylen; e++) if (key[e] == '}') break; /* No '}' or nothing between {} ? Hash the whole key. */ if (e == keylen || e == s+1) return crc16(key,keylen) & 16383; /* If we are here there is both a { and a } on its right. Hash * what is in the middle between { and }. */ return crc16(key+s+1,e-s-1) & 16383; }
集群节点属性
每个集群节点都有唯一名称,节点ID(名称)是一个十六进制表示的160bit随机数进制,这个随机数是节点第一次启动时生成的(通常是用/dev/urandom)。节点会把ID保存在配置文件,只要节点没被管理员删掉,就会一直使用此ID。
“CLUSTER RESET”可强制重置节点ID。
节点ID在集群中代表着节点身份,节点改变IP连接信息,不需要更新节点ID。集群能检测到连接信息的变化,然后使用在集群上通信的gossip协议发布广播消息,通知配置变更。
节点ID不仅是关联节点的所需信息,在整个Redis集群也是全局唯一的。每个节点也会保存一些关联信息,像具体集群节点的配置详情(此配置在集群中保证最终一致性),还有其他信息,如节点最后ping的时间,此时间都是节点本地时间。
每个节点都维护其他节点的信息:节点ID,节点的IP和端口,一组标志,若标识为slave对应的master节点,上一次发送ping包的时间,上一次收到pong包的时间,节点配置版本号,链路状态和节点负责的哈希范围等。
使用CLUSTER NODES(关于所有字段的详细说明)命令可以获得以上的一些信息,这个命令可以发送到集群中的所有节点。以下示例是在一个只有4个节点的小集群中发送CLUSTER NODES命令到一个master节点得到的输出:
[root@hd4 7003]# redis-cli -p 7000 cluster nodes a466e88499423858c5f53de9be640500d9fb3e5b 127.0.0.1:7000@17000 myself,master - 0 1529050482000 7 connected 2365-5961 10923-11421 5055b631a9b310417fa75948a5e473e2e2e1cfee 127.0.0.1:7001@17001 master - 0 1529050484054 12 connected 1616-1990 7202-10922 d1ce7d9db6086c41f13ef0ce3753f82a3bfc420f 127.0.0.1:7002@17002 master - 0 1529050484054 11 connected 1991-2364 12662-16383 bde6fc14465ecdbc71c6630edb5f9a3ab0c45cf0 127.0.0.1:7006@17006 master - 0 1529050484000 9 connected 0-1615 5962-7201 11422-12661
在上面罗列出来的信息中,各个域依次表示的是:节点ID,IP地址:端口号,标识信息,上一次发送ping包的时间,上一次收到pong包的时间,配置文件版本号,连接状态,节点负责的哈希槽范围
集群总线
每个Redis集群节点使用一个额外的TCP端口接收来自其他节点的传入连接。一般来说此端口比客户端(如redis-cli)端口大10000,如客户端端口是6379,集群总线端口则是16379。在配置端口时,只需指明客户端端口即可。
集群拓扑图
集群是一个网状结构,每个节点使用TCP协议连接到其他节点。N个节点的集群中,每个节点都有N-1个对外的TCP连接和N-1个对内连接。这些连接一经创建就会永久保持。当节点在集群中等待ping响应,足够长时间后在标记某节点不可达前,都将尝试刷新与该节点的最近通信时间。Redis集群节点间形成网状结构后,节点使用gossip协议和配置更新机制,避免节点间在正常情况下交换过多消息,因此交换的消息数量不会以指数形式增长的。
节点间的握手
节点间通过集群总线TCP端口连接,也通过该端口进行ping的发送与接收(无论ping的发起者是否可信)。但是若消息发送来源不被认为是集群的一部分,则所有其他的包将会被集群节点丢弃。
只在两种情况下,一个节点会认为另一节点是集群的一部分:
·若一个节点使用MEET消息广播自己。MEET消息和PING消息完全一样,但它会强制让接收者接受自己为集群中的一部分。只有管理员使用以下命令请求时,节点才会发送MEET消息给其他节点:
CLUSTER MEET IP PORT
·一个已经被信任的节点能通过传播gossip消息让另一节点被注册为集群中的一部分。即A知道B,B知道C,最终B将发送gossip消息“知道C”给A,接着A收到后会把C注册为网络拓扑中的一部分,并尝试直接连接C。这意味着只要在网络拓扑中指定任意节点接入新节点,最终它们会自动完全连通。即集集群节点能自动发现其他节点,但只有系统管理员强制指定信任关系才能实现。这种机制能防止不同的Redis集群因为IP地址变更或者其他网络事件而意外混合起来,从而使得集群更加健壮,以及可用性更强。
重定向和重新分片(Redirection and resharding)
MOVED 重定向
客户端可以自由地向集群每个节点(包括slave节点)发送查询请求。接收请求的节点会分析请求,若命令是可以执行的(即单键查询,或者同一哈希槽的多键查询),则节点将找出这些/个键是哪个节点的哪个哈希槽所服务的。
若哈希槽在这个节点上,那么这个请求执行就非常简单,否则将查看节点上哈希槽与节点的映射表,且返回客户端MOVED错误:
127.0.0.1:7000> get foo856761 (error) MOVED 87 127.0.0.1:7006
错误信息中指明了存储键的哈希槽编号,以及能处理此查询的IP:PORT。客户端需要重新发送请求到该IP:PORT。注意:只要查询的键不在命令接收节点上,都会有与MOVED相关的报错。
集群中节点是以ID来标识的,为简化接口,所以只向客户端返回哈希槽和IP:PORT之间的映射关系
尽管没有要求,但客户端应该记住哈希槽87被 127.0.0.1:7006服务,如此一旦有新的命令需发送,它能计算所处目标键的哈希槽,提高找到正确节点的概率。
另一种方法是在收到MOVED重定向时,使用“CLUSTER NODES”或“CLUSTER SLOTS”命令刷新客户端连接的节点。遇到重定向时,可能多个哈希槽需要重新配置,而不是一个,因此最好是尽快刷新客户端配置信息。
注意:在集群稳定期间(配置没有持续变化),所有客户端将获得一份“哈希槽->节点”的映射表,以提升集群的请求效率,而不用重定向或代理,减少错误。
另外,客户端也要能处理后文将提到的-ASK重定向错误,否则此客户端是不完整的集群客户端。
集群在线重配置(Cluster live reconfiguration)
Redis集群支持在线添加或删除节点。实际上,添加和删除被抽象为同一操作,也就是把哈希槽从一个节点迁移到另一节点。这意味着可以使用相同的机制去平衡集群哈希槽,增加/删除节点等。
·向集群添加新节点就是把一个空节点添加到集群,然后从现有节点(可以是多个节点,也可以是单个节点)迁移部分哈希槽到该节点上。
·删除集群节点就是将该节点的哈希槽迁移到其他现有节点
·平衡集群就是将一组哈希槽在节点间移动。
从实际角度看,哈希槽就是一堆键,所以Redis集群在重新分片(reshard)时做的就是把键从一个节点移动到另一节点。
为了理解这是怎么工作的,我们需要理解用来操作redis集群节点上哈希曹转换表(slots translation table)的CLUSTER子命令:
·CLUSTER ADDSLOTS slot1 [slot2] ... [slotN]
·CLUSTER DELSLOTS slot1 [slot2] ... [slotN]
·CLUSTER SETSLOT slot NODE node
·CLUSTER SETSLOT slot MIGRATING node
·CLUSTER SETSLOT slot IMPORTING node
前两个子命令:ADDSLOTS和DELSLOTS用来给Redis节点指派或移除哈希槽。指派一个哈希槽意味着告诉目标master,它将负责这个/些哈希槽的存储与服务。在哈希槽被指派后,节点会将这个消息通过gossip协议想整个集群传播。(协议在后续“哈希槽配置传播”章节说明)
ADDSLOTS子命令通常用于新建立Redis集群时用于给所有master节点指派16384个哈希槽中的部分。
DELSLOSTS子命令主要在手工配置集群或调试,实际中用的少。
SETSLOT子命令使用“SETSLOT <slot> NODE”形式将哈希槽指派到指定ID的节点。此外哈希槽还能设置为两种特殊状态:MIGRATING and IMPORTING。这两种状态用于将哈希槽从一个节点迁移到另一节点。
·当一个槽被设置为MIGRATING,服务该哈希槽的节点会接受所有查询这个哈希槽的请求,但仅当查询的键还存在原节点上时,原节点会处理该请求,否则此查询会通过一个-ASK重定向(-ASK redirection)转发到迁移的目标节点上
·当一个槽被设置为IMPORTING,仅当接受到ASKING命令后节点才会接受所有查询这个哈希槽的请求。若客户端一直未发送ASKING命令,那么查询会通过-MOVED重定向错误转发到真正处理这个哈希槽的节点上。
以下实例来解释上述。假设有两个master节点A、B。目的是将哈希槽8slave节点A移动到节点B,因此发送命令:
1、在节点B执行:CLUSTER SETSLOT 8 IMPORTING A
2、在节点A执行:CLUSTER SETSLOT 8 MIGRATING B
其他所有节点在每次请求的一个键是属于哈希槽8是,都会把客户端指向节点A:
·所有关于已存在的键的查询都由节点A处理
·所有关于不存在于节点A的键都由节点B处理,因为节点A将重定向客户端到节点B
这种方式让我们可以不用再节点A中创建新的键。另外用于集群配置的“redis-trib”脚本也可以把已存在的属于哈希槽8的键slave节点A移动到节点B。可通过命令实现:
CLUSTER GETKEYSINSLOT slot count
上述命令会返回指定的哈希槽中count个键。对于每个返回的键,redis-trib向节点A发送一个“MIGRATE”命令,该命令将以原子性的方式把指定的键slave节点A移动到节点B。以下是MIGRATE的工作原理:
MIGRATE target_host target_port key target_database id timeout
执行MIGRATE命令的节点会连接目标节点,将序列化后的键发送过去,一旦收到“OK”回复就会将自己数据集中的老键(old key)删除。因此对于一个外部客户端而言,一个键要么存在于节点A要么节点B。
Redis集群中仅有一个数据库(db0),不能切换到其他数据库,但MIGRATE命令能用于其他与Redis集群无关的任务。如迁移比较复杂的键像长列表,都被优化的非常快速。但在调整一个拥有很多键,且键的数据量都很大的集群时,若使用它的应用程序有延时问题的限制,再使用此命令就不明智了。
当最终迁移完成,在集群中所有节点上执行命令“SETSLOT <slot> NODE <node-id>”以便将槽设置为正常状态。
ASK重定向
前面有提到关于ASK重定向(ASK redirection),那么为啥不直接使用MOVED重定向呢?因为当使用MOVED时,意味着将哈希槽永久地迁移到另一节点,且希望接下来的所有查询都发到这个指定节点上去。而ASK意味着只要下一个查询发送到指定节点上去。
这个命令是必要的,因为下一个关于哈希槽8的查询需要的键或许还在节点A中,因此我们希望客户端尝试在节点A中查找,若需要的话也在节点B中查找。由于这是发生在16384个槽的其中一个槽,所以对于集群的性能影响是在可接受的范围。
然而我们需要强制客户端的行为,以确保客户端会在尝试节点A中查找后去尝试在节点B中查找。若客户端在发送查询前发送了ASKING命令,那么节点B只会接受被设为IMPORTING的槽的查询。 本质上来说,ASKING命令在客户端设置了一个一次性标识(one-time flag),强制一个节点可以执行一次关于带有IMPORTING状态的槽的查询。
因此从客户端角度看,ASK重定向的完整语义如下:
·若接收到ASK重定向,那么把查询的对象调整为指定的节点。
·先发送ASKING命令,再开始发送查询。
·现在不要更新本地客户端的映射表把哈希槽8映射到节点B。
一旦完成了哈希槽8的转移,节点A会发送一个MOVED消息,客户端会把哈希槽8映射到新的IP:PORT上。注意,即使客户端出现bug,过早地执行这个映射更新,也是没有问题的,因为它不会在查询前发送ASKING命令,节点B会用MOVED重定向错误把客户端重定向到节点A上。
CLUSTER SETSLOT命令文档中,哈希槽迁移以类似的术语进行解释,但使用不同的措辞(为了文档中的冗余)。
客户端首次连接和处理重定向
尽管有些Redis集群客户端可能不在内存中哈希槽编号与服务节点地址间的映射列表,且只能通过连接到随机节点然后在需要时进行客户端连接的重定向。
实现的客户端应该保存哈希槽编号与服务节点地址间的映射列表,此信息也不要求是最新的,因为请求时若连接到的节点无法获取所需数据,会重定向到其他节点,此时会触发客户端更新映射信息。
客户端通常在两种情况下获取哈希槽与节点映射列表:
·启动时保存初始化的列表信息
·当收到MOVED重定向
请注意:客户端可能根据MOVED重定向更新变动的哈希槽,但这种方法仅适用于一个哈希槽更新,当遇到大量哈希槽映射信息更新,如slave被提升为master,所有映射到旧master的哈希槽会重新映射到新master。因此更合适的对MOVED重定向做出回应的方法是:重新获取哈希槽节点的映射信息
为了获取哈希槽与节点的映射信息,集群提供了一种命令“CLUSTER NODES”的替代方案,且此方案只提供客户端需要的信息。这个命令是“CLUSTER SLOTS”,命令显示每组哈希槽范围是由哪个master、slave节点服务的。以下是“CLUSTER SLOTS”输出实例
[root@hd4 ~]# redis-cli -c -p 7000 cluster slots 1) 1) (integer) 2365 2) (integer) 5961 3) 1) "127.0.0.1" 2) (integer) 7000 3) "a466e88499423858c5f53de9be640500d9fb3e5b" 4) 1) "127.0.0.1" 2) (integer) 7008 3) "948addb812fe9322a25fbbdac9de940bab09f9f7" 2) 1) (integer) 10923 2) (integer) 11421 3) 1) "127.0.0.1" 2) (integer) 7000 3) "a466e88499423858c5f53de9be640500d9fb3e5b" 4) 1) "127.0.0.1" 2) (integer) 7008 3) "948addb812fe9322a25fbbdac9de940bab09f9f7" 3) 1) (integer) 1616 2) (integer) 1990 3) 1) "127.0.0.1" 2) (integer) 7001 3) "5055b631a9b310417fa75948a5e473e2e2e1cfee" 4) 1) "127.0.0.1" 2) (integer) 7005 3) "406bda57ed591c2bd3b15955f687a57b03a653c0" 5) 1) "127.0.0.1" 2) (integer) 7003 3) "9b1d9c3e7bbcc955afce649f439cd2d094957313" 4) 1) (integer) 7202 2) (integer) 10922 3) 1) "127.0.0.1" 2) (integer) 7001 3) "5055b631a9b310417fa75948a5e473e2e2e1cfee" 4) 1) "127.0.0.1" 2) (integer) 7005 3) "406bda57ed591c2bd3b15955f687a57b03a653c0" 5) 1) "127.0.0.1" 2) (integer) 7003 3) "9b1d9c3e7bbcc955afce649f439cd2d094957313" 5) 1) (integer) 1991 2) (integer) 2364 3) 1) "127.0.0.1" 2) (integer) 7002 3) "d1ce7d9db6086c41f13ef0ce3753f82a3bfc420f" 4) 1) "127.0.0.1" 2) (integer) 7004 3) "b36883be3b39692f71a441a67277ab23dff80afb" 5) 1) "127.0.0.1" 2) (integer) 7009 3) "5837a7c77a04b5100222dca1d226e4980764a97f" 6) 1) (integer) 12662 2) (integer) 16383 3) 1) "127.0.0.1" 2) (integer) 7002 3) "d1ce7d9db6086c41f13ef0ce3753f82a3bfc420f" 4) 1) "127.0.0.1" 2) (integer) 7004 3) "b36883be3b39692f71a441a67277ab23dff80afb" 5) 1) "127.0.0.1" 2) (integer) 7009 3) "5837a7c77a04b5100222dca1d226e4980764a97f" 7) 1) (integer) 0 2) (integer) 1615 3) 1) "127.0.0.1" 2) (integer) 7006 3) "bde6fc14465ecdbc71c6630edb5f9a3ab0c45cf0" 4) 1) "127.0.0.1" 2) (integer) 7007 3) "382b8977ccb4523495bed7ebdbab866f5ada4930" 8) 1) (integer) 5962 2) (integer) 7201 3) 1) "127.0.0.1" 2) (integer) 7006 3) "bde6fc14465ecdbc71c6630edb5f9a3ab0c45cf0" 4) 1) "127.0.0.1" 2) (integer) 7007 3) "382b8977ccb4523495bed7ebdbab866f5ada4930" 9) 1) (integer) 11422 2) (integer) 12661 3) 1) "127.0.0.1" 2) (integer) 7006 3) "bde6fc14465ecdbc71c6630edb5f9a3ab0c45cf0" 4) 1) "127.0.0.1" 2) (integer) 7007 3) "382b8977ccb4523495bed7ebdbab866f5ada4930"
返回数组的每个元素的前两个子元素是该范围的始末哈希槽。附加元素表示地址端口对(address-port pairs)。第一个地址端口对是服务该范围哈希槽的master节点,以下的都是该master节点的正处于正常状态的slave复制节点。
如输出的第一个元素表示,槽从2365至5961(开始和结束哈希槽)由127.0.0.1:7000服务,且可通过127.0.0.1:7008水平扩展读负载。
集群配置不正确时,CLUSTER SLOTS不能保证返回的哈希槽范围覆盖16384个哈希槽,因此客户初始化哈希槽信息时,若用户执行有关键的命令属于未分配的哈希槽,应当用NULL填充空节点,并报告一个错误。
当一个哈希槽被发现未被分配键,返回一个错误给客户端前,客户端应尝试获取哈希槽映射信息,已检查集群配置是否正确。
多键操作
客户端可通过哈希标签任意进行多键操作。如以下有效操作:
MSET {user:1000}.name Angela {user:1000}.surname White
当键所属哈希槽正在进行重新分片时,多键操作可能不可用。详细的说,在重新分片期间,针对相同节点(源节点和目标节点)所有已存在键的多键操作是可用的。
在重新分片时,操作的键不存在或键在源节点和目的节点之间,将产生-TRYAGAIN错误。客户端可以一段时间后再尝试操作,或报错(Operations on keys that don't exist or are - during the resharding - split between the source and destination nodes, will generate a -TRYAGAIN error)
一旦指定的哈希槽的迁移操作结束,所有多键操作可再次用于该哈希槽。
使用slave节点扩展读取功能
一般slave节点会将客户端重定向到给定命令中涉及的哈希槽的master节点上,但客户端也可使用命令“READONLY”在slave节点上扩展读性能。命令“READONLY”告知slave节点,允许不在乎数据是否是最新的、没有写请求的客户端连接它
当连接处于只读模式,请求涉及到不是slave的master节点服务的键时,集群将发送一个重定向到客户端。发生这种情况可能是因为:
·客户端发送了一个关于哈希槽的命令,但该哈希槽并不是由这个slave的master节点提供服务
·集群哈希槽经过重新分配,slave节点不再为给定哈希槽提供服务
当发生这些情况,客户端应更新哈希槽与节点的映射表。
总结,“READONLY”设置slave的只读模式,连接的只读状态能用命令“READWRITE”清除。
容错性(Fault Tolerance)
节点心跳和gossip消息
Redis集群节点间周期性交换ping和pong的数据包。两种数据包具有相同的数据结构,都携带重要的配置信息。唯一不同是消息类型字段。一般将ping包和pong包合称为心跳数据包。
一般节点发送ping数据包,接收到的节点会回复pong数据包,但并非所有情况下都会有回复。在传播配置时,节点通过PONG包发送配置信息,而不会触发一个回复。
通常集群节点会每秒随机ping几个节点,这样发送的ping包(和接收到的pong包)的总数会是一个跟集群节点数量无关的常数。
在过去的一半NODE_TIMEOUT时间里都没有发送ping包过去或接收从那节点发来的pong包的节点,会保证去ping每一个其他节点。在NODE_TIMEOUT过去前,若当前TCP连接有问题,节点会尝试去重连接另一个节点,以确保不会被当作不可达的节点。
若NODE_TIMEOUT被设置得很小而节点非常多,那么集群内交互的消息会非常多。因为每个节点都会尝试去ping每个在过去一半NODE_TIMEOUT时间里都没更新信息的节点。如在一个NODE_TIMEOUT设置为60s的100节点集群中,每个节点将每30s发送99个ping数据包,那么每秒发送的ping数据包就是3.3个,乘以100个节点就是整个集群每秒有330个ping数据包在发送中。但由于每秒330个包被均匀分部在100个不同的节点,因此每个节点接收的流量还是可以接受的。
心跳数据包内容
PING和PONG数据包包含很常见的header信息,以及一个PING和PONG的gossip协议特殊数据包。
Header信息包含以下方面:
·节点ID,一个160位的伪随机字符串,在首次将节点加入节点时分配,在Redis集群生命周期内永久不变,除非删除重新添加。
·currentEpoch和configEpoch两个字段,用来挂载Redis集群使用的分布式算法(后续会有说明)。若节点是slave角色,configEpoch是上个已知master的configEpoch。
·节点标识,标明节点是master还是slave身份,以及其他只占用1个bit的节点信息。
·数据包发送节点所服务的哈希槽范围(bitmap,转义成范围),或者该节点是slave节点,则该范围是它的master节点所服务的哈希槽范围
·数据包发送节点TCP的IP:PORT信息(集群总线端口,即Redis客户端端口加上10000)
·数据包发送节点角度看集群状态(OK还是down)
·master节点ID,若数据包发送节点是slave角色
PING和PONG都包含一个gossip字段,向接收者提供发送节点对集群中其他节点的看法。Gossip字段仅包含发送者已知的一组节点中的几个随机节点的信息。Gossip字段提到的节点数量与群集大小成正比。
每个在gossip字段中的节点都包含一下信息:
·节点ID
·节点IP:PORT
·节点标识
从数据包发送节点看来,gossip字段用于让接收节点能获得其他节点的状态信息。这对于失效检测或者发现集群中的其他节点都是非常有用。
故障检测
Redis集群故障检测用于在master不再可达时将一个合适的slave提升为master,若无法提升则整个集群将处于错误状态,且停止接收客户端的请求。
如前所述,每个节点都会获取一份其他已知节点的标识列表。其中有两个标识用于故障检测,分别是:PFAIL和FAIL,PFAIL表示可能失效(possible failure),这是一个未确认的故障类型。FAIL表示节点故障,这是已经被大多数节点在一定时间内确认过的故障类型。
·PFAIL标识:
在节点超过NODE_TIMEOUT时间不可达时,节点将用PFAIL标识另一节点不可达,不管另一节点是什么节点类型。
Redis集群节点不可达指发送给某节点的ping包在超过NODE_TIMEOUT时间未收到回复。为了此机制能正常工作,NODE_TIMEOUT必须比网络往返时间(network round trip time)长。为增加正常工作时的集群可靠性,节点在经过一半NODE_TIMEOUT时间还没收到目标节点对ping的回复,则会尝试重新连接该节点,此机制下的连接会一直处于有效的活跃状态,因此节点断开连接通常不会导致错误的故障报告。
·FAIL标识
单独一个PFAIL标识只是每个节点的一些关于其他节点的本地信息,此不足以触发slave节点身份的提升。要让一个节点真正被认为故障,那需要PFAIL状态上升为FAIL状态。在前文有提到,每个节点向其他所有节点发送gossip消息中又包含一些随机的已知节点的状态,最终每个节点都能收到一份其他所有节点的节点标识。这样,每个节点都有一个机制去标记他们检查到的其他节点的故障状态。
满足一下条件会触发PFAIL状态升级为FAIL状态:
1、节点A,B中的A标记B为PFAIL
2、节点A通过gossip字段收集到集群中大部分master节点标识节点B的状态信息
3、大部分master节点标记节点PFAIL状态,或在NODE_TIMEOUT * FAIL_REPORT_VALIDITY_MULT这个时间内是处于PFAIL状态(实际中,有效因子一般设置为2,所以为NODE_TIMEOUT的两倍)
若以上条件都满足,那么节点A:
1、标记节点B为FAIL
2、向所有可达节点发送一个FAIL消息
FAIL消息会强制每个接收到此消息的节点把节点B标记为FAIL状态。
注意:FAIL标识基本都是单向的,即一个节点能从PFAIL状态升级为FAIL状态,但FAIL标识只能在下列情况中被清除:
·节点已重新可达,且它是一个slave节点。此时FAIL标识可以清除,因为slave节点并没有被故障转移。
·节点已重新可达,且它是一个master节点,但不服务任何范围的哈希槽,此时master节点并没有真正参与到集群,正等待加入服务哈希槽中。
·节点已重新可达,且它是一个master节点,但经过很长时间(N * NODE_TIMEOUT)后也没有检测到任何slave节点被提升。
PFAIL状态到FAIL状态的转变使用一种弱协议(agreement):
1、节点在某时间段内收集其他节点的信息,因此即使大多数master节点需要“同意”标记某个节点为FAIL状态,实际上这也只是表明节点在不同时间不同节点上收集了节点不确定、也不需要大多数master同意的信息;然而,在大多数节点都同意后,每个节点将丢弃旧的PFAIL状态,最终统一指定节点的状态(However we discard failure reports which are old, so the failure was signaled by the majority of masters within a window of time)。
2、检测到节点FAIL状态的每个节点都会将FAIL消息强加于群中的其他节点时,但无法确保消息到达所有节点。如节点检测到FAIL状态,但因为网络分区问题无法将消息发送到任何节点。
然而Redis集群故障检测有活跃性要求:最终所有节点都应该对给定节点的状态达成一致。脑裂可能造成两种情况:要么少数节点认为指定节点处于FAIL状态,要么少数节点认为指定节点不处FAIL状态。针对这两种情况,集群最终会就状态达成一致:
第一种情况,若大对数节点已经标记某个master节点为FAIL状态,由于故障检测和链式效应(chain effect),剩余其他节点最终也将标记这个master节点为FAIL状态。
第二种情况,当只有少数节点标记了某个master节点为FAIL状态,slave节点不会被提升(使用一个更正式的算法保证每个节点最终收到节点被提升的消息)。且每个节点将依据文前所述清楚规则清除FAIL状态(经过很长时间“N * NODE_TIMEOUT”后也没有检测到任何slave节点被提升)。
FAIL标识仅用于触发slave节点提升(slave promotion)算法的安全部分。理论上一个slave节点会在他的master节点不可达时独立工作用且启动slave节点提升程序,然后等待master节点来拒绝该提升(若master节点对大部分节点恢复连接)。PFAIL到FAIL的状态变化、弱协议、强制在集群的可达部分用最短的时间传播状态变更的 FAIL 消息,这些东西增加的复杂性有实际的好处。由于这些机制,若集群处于错误状态,所有节点都会在同一时间停止接收写入操作,这从Redis集群客户端角度来看是个很好的特性。还有非必要的选举,是slave节点在无法访问master节点的时候发起的,若该master节点能被其他大多数master节点访问的话,这个选举会被拒绝掉。
配置处理、传播,以及故障转移
集群当前配置版本(Cluster current epoch)
Redis集群使用类似于Raft算法的“术语”。在Redis集群中该术语被称为EPOCH,用于对事件进行增量版本控制。当多个节点提供冲突的信息,另一节点可通过这个EPOCH来判断哪个是最新的。
currentEpoch是一个64位的无符号数字(unsigned)。
在节点刚加入集群时,不管是slave还是master节点,currentEpoch都是0。
每当节点接收到来自其他节点的数据包时,若发送方的epoch(包含在集群总线消息头中)大于本地的epoch,那将更新发送方的epoch为本地节点的currentEpoch。基于此逻辑,集群所有节点最终都将更新为这个最大的epoch。
这个信息在此处是用于,当一个节点的状态发生改变时为了执行一些动作寻求其他节点的同意(agreement)。
目前这种情况只发生在slave被提升过程中(将在下一节表述)。本质上,epoch 是集群里的逻辑时钟,并决定一个给定的消息覆盖另一个带着更小epoch的消息。
配置版本号(Configuration epoch)
每个master节点总是通过发送ping和pong数据包向其他节点传播自己的configEpoch,以及一份自己维护的哈希槽范围列表。
当新节点被创建是,master节点中的configEpoch为0。
在slave选举中configEpoch会被更新slave节点由于故障转移事件被提升为master节点时,为了取代它那失效的master节点,会把 configEpoch 设置为它赢得选举的时候的 configEpoch 值。
下一节会解释,configEpoch用于在不同节点提出不同的配置信息时解决冲突(可能发生在网络分区的产生和节点故障的情况中)。
Slave节点也会在ping和pong数据包中广播configEpoch信息,不过slave节点的configEpoch表示上次它与master节点交换数据时master节点的configEpoch。如此能让其他节点检测slave节点的配置是否应该更新(master节点都不会向一个配置过时的slave节点投票,任何情况下)。
每当可达节点的configEpoch更新,节点都会将之更新到nodes.conf中。currentEpoch也是如此。这两个变量会在节点继续操作前保证更新以及刷新到nodes.conf。
在故障转移期间使用简单算法生成的configEpoch保证最新、自增、唯一。
Slave节点的选举与提升
在master节点的投票帮助下,slave节点的选举与提升由故障master的所有slave节点自行处理。一个slave节点的选举是在master节点被至少一个具有成为master节点必备条件的slave节点标记为FAIL状态时发生。
要让一个slave提升为master,需要发起一次选举并获胜。当master处于FAIL状态,所有给定master的slave都能发起一次选举。然而只有一个slave能赢得选举升级成为master。
当满足以下条件,slave节点就可以发起选举:
·slave的master节点处于FAIL状态。
·slave的master节点负责的哈希槽数量不为0。
·slave节点和master节点间复制进程(replication link)的连接断开不超过给定的时间(可配置),以确保被升级的slave节点的数据是可靠的、比较新的。
slave节点为了在选举中当选,第一步应该自增他的currentEpoch,且向集群中master节点请求投票。
slave节点通过广播一个FAILOVER_AUTH_REQUEST数据包向集群每个master节点请求投票。然后等待回复,最大等待时间是NODE_TIMEOUT*2,但至少2秒。
一旦一个master投票给一个给定的slave,会回复一个FAILOVER_AUTH_ACK,且在NODE_TIMEOUT*2时间内不能再给同个master节点的其他slave节点投票,期间它将完全不能回复其他slave节点的授权请求。主要防止多个slave在同一时间被选举。
发出投票请求后,slave节点会丢弃所有带有epoch参数比currentEpoch小的回应(ACKs),这确保了它不计入先前选举的票数。
一旦slave节点从大多数master节点获得ACKs回应,那他就赢得选举,否则若无法在NODE_TIMEOUT*2(至少2s)时间内得到大多数master节点的回复,则终止选举,在NODE_TIMEOUT*4时间后,会有另外的slave节点尝试发起选举。
Slave 排名(slave rank)
并非master节点一进入FAIL状态slave节点就会开始选举,slave开始选举前会等待一点时间,延时时间的计算方法如下:
DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds + SLAVE_RANK * 1000 milliseconds
固定延时(fixed delay)确保master节点的FAIL状态在集群内广播后slave节点再发起选举,否则master节点们在不知道那个master已经故障的情况下,会拒绝投票。
随机延时(random delay)用于减少同时多个slave节点发起选举的可能性。因为若同时多个slave节点发起选举或许会导致没有任何节点赢得选举,要再次发起另一个选举的话会使集群在当时变得不可用。
SLAVE_RANK是故障master的slave从master复制过来的数据处理数量的排名(越多的越靠前)。Slave交换消息时,当master故障会尽力生成一个排名:数据复制处理量最多的排名为0,第二的为1。总之数据复制处理量越多越靠前,SLAVE_RANK就越小,所增加的延时就越少。若靠前排名的slave选举失败,其他人会很快尝试。其他人尝试时,不会严格按照slave排名来重新发起选举。
一旦有slave节点在选举中获胜,他就会自增出一个比其他已存在的master更大的configEpoch。 它开始通过ping和pong数据包向其他节点广播自己master的身份,该数据包中会包含它服务的哈希槽范围,以及configEpoch。
为加速其他节点配置的调整,将向集群所有节点广播一个pong数据包。那些现在访问不到的节点最终也会收到一个ping包或pong包,并且进行配置调整。
其他节点将检测到有一个新的master节点(带着更大的configEpoch)在服务旧的哈希槽范围,然后更新自己的配置。旧master的所有slave(或故障后恢复正常重新加入集群的旧master)将更新调整配置,接着从新master复制数据。节点如何重新加入集群会在下一节解释。
Master节点回复slave节点的投票请求
上节讨论了slave如何在选举中当选,本节将从master节点的角度解析为给定slave节点投票时发生的事情。
Master节点接收来自slave节点、要求以FAILOVER_AUTH_REQUEST请求的形式的投票。要授予一个投票,必须满足以下条件:
· 在一个给定的时段(epoch版本)里,一个master节点只能投一次票,且拒绝给以前时段(epoch版本)投票:每个master节点都有一个lastVoteEpoch字段,只要认证请求数据包(auth request packet)里面的currentEpoch小于lastVoteEpoch,那么master节点就会拒绝再次投票。当一个master节点积极响应一个投票请求,那么lastVoteEpoch就会相应的更新,且持久化至nodes.conf。
·只有某个slave的master节点被标记为FAIL状态后,其余master节点才会给该slave投票
·若认证请求里的currentEpoch小于master的currentEpoch,该请求会被忽略。因为master节点的回应总是带着和认证请求一致的currentEpoch。若同一slave节点再次请求投票,会先递增currentEpoch,以此保证master节点在收到相同slave节点的不同版本currentEpoch的认证请求时会选择新版本而丢弃旧版本的请求。
若不满足最后一条会出现以下例子中的问题:
·Master节点的currentEpoch是5,lastVoteEpoch是1(在几次失败的选举后这也许会发生的)。
·slave节点的currentEpoch是3。
·slave节点尝试用epoch值为4(3+1)来赢得选票,master节点回复ok,数据包里currentEpoch是5,但此回复延迟了。
·slave节点尝试用epoch值为5(4+1)来再次赢得选票,收到的是带着currentEpoch值为5的延迟回复,此回复会被当作有效的来接收。
1、 master节点若已经为某个失效master节点的一个slave节点投票后,在经过 NODE_TIMEOUT * 2 时间之前不会为同个失效master节点的另一个slave节点投票。这并不是严格要求的,因为两个slave节点用同个 epoch 来赢得选举的可能性很低,不过在实际中,系统确保正常情况当一个slave节点被选举上,那么它有足够的时间来通知其他slave节点,以避免另一个slave节点发起另一个新的选举。
2、 master节点不会用任何方式来尝试选出最好的slave节点,只要slave节点的master节点处于 FAIL 状态并且投票master节点在这一轮中还没投票,master节点就能进行积极投票。
3、 若一个master节点拒绝为给定slave节点投票,它不会给任何负面的回应,只是单纯忽略掉这个投票请求。
4、 master节点不会授予投票给那些configEpoch值比master节点哈希槽表里的configEpoch更小的slave节点。记住,slave节点发送了它的master节点的configEpoch值,还有它的master节点负责的哈希槽范围信息。本质上来说,这意味着,请求投票的slave节点必须拥有它想要进行故障转移的哈希槽的配置信息,而且信息应该比它请求投票的master节点的配置信息更新或者一致。
configEpoch使得slave节点在提升过程中对网络分区更具有抵抗力
本节说明如何使用config epoch来使得slave节点在提升过程中对网络分区更具有抵抗力。
·master节点并非永久可达。Master节点有三个slave节点A,B,C
·在master节点故障后,slave节点A赢得选举被推选为新master节点
·此后发生网络问题造成网络分区使得大多数节点无法访问节点A
·经过重新选举节点B成为新master
·此后发生网络问题造成网络分区使得大多数节点无法访问节点B
·第一次的网络分区恢复,A节点重新可达
此刻B节点仍处于大多数节点不可达状态,节点A在恢复可达后会与节点C竞选尝试获得选票对节点B进行故障转移。
这两个有同样的哈希槽的slave节点最终都会请求被提升,然而由于它们发布的configEpoch是不一样的,而且节点C的epoch比较大,所以所有的节点都会把它们的配置更新为节点C的。
节点 A 会从来源于节点 C(负责同样哈希槽的节点)的ping包中检测出节点C的epoch是更大的,所以它会重新设置自己为节点C的一个slave节点。
哈希槽配置传播
Redis集群的一个重要组成部分是用于传播关于集群节点负责哪些哈希槽的信息的机制。这对于新集群的启动和提升slave节点来负责处理哈希槽(master节点负责的哈希槽)的能力来说是至关重要的。
此机制也允许节点在任何时间脱离集群,然后再次以合理的方式加入集群。
哈希槽配置传播有两种方式:
·心跳消息(Heartbeat messages)。PING或PONG数据包发送者总会附带它(或者它的master节点)服务的哈希槽范围信息。
·更新消息(UPDATE messages)。在每个心跳数据包中都包含configEpoch信息,以及哈希槽所服务的集合信息。若心跳包的接收者发现发送者发送的信息是陈旧的,则会返回一个更新过的数据包,强制发送者更新信息。
心跳消息和UPDATE消息的接收者使用某些简单规则将哈希槽列表更新到节点。当新创建一个redis集群节点,它本地哈希槽槽列表(哈希槽和给定节点ID 的映射关系表)被初始化,每个哈希槽被置为NULL,也就是说此时每个哈希槽还未绑定集合或未链接到任何节点。这与下例情况相似:
0 -> NULL
1 -> NULL
2 -> NULL
...
16383 -> NULL
一个节点为更新其哈希槽而要遵循的第一条规则如下:
RULE1:若一个哈希槽未被分配(还是NULL),然后一个可达节点来认领它,那么我将修改我的哈希槽列表,并将这个哈希槽关联到这个节点。
因此,若我们接收到来自节点A(服务哈希槽1和哈希槽2、configEpoch为3)的心跳信息,那该表将被修改为:
0 -> NULL
1 -> A [3]
2 -> A [3]
...
16383 -> NULL
当创建新集群时,管理员需要手动将每个master节点所服务的哈希槽分配给master节点本身(使用命令CLUSTER ADDSLOTS,通过redis-trib工具)此后信息将迅速在群集中传播。
然而只有这条规则是不够的。我们知道哈希槽映射表会在两种情况下发生变化:
·一个slave节点在故障转移中替换它的master
·一个哈希槽从一个节点重新分片到不同节点
现在再让我们看看故障转移。当一个slave节点故障转移替换它的master后,会获得一个保证比master要大的configEpoch(通常大于先前生成的任何configEpoch)。如节点B,是A的一个salve节点,可以在configEpoch为4情况下故障转移B为新master。之后节点B将发送心跳包(第一次时,在集群中大规模广播),且由于第二条规则,接收者将更新其哈希槽列表:
RULE2:若一个哈希槽已被分配,有个节点它的configEpoch比哈希槽当前拥有者的值更大,并且该节点宣称正在负责该哈希槽,那么我会把这个哈希槽重新绑定到这个新节点上。
因此在接收到来自B节点通告configEpoch为4,哈希槽1和2的消息后,接收者将按照以下方式更新列表:
0 -> NULL
1 -> B [4]
2 -> B [4]
...
16383 -> NULL
由于第二规则,所以集群所有节点最终都会同意哈希槽的拥有者是所有声称拥有它的节点中configEpoch值最大的那个。
此种机制在Redis集群中被称作最终故障转移获胜(last failover wins)。
这也会发生在重新分片的情况中,当一个节点导入哈希槽后完成导入操作,它的configEpoch会增加来保证此更改在集群中最终传播到所有可达节点。
更新消息(UPDATE messages)
结合上述,能更加明了的看到更新消息的工作方式。节点A可能在一段时间后重新加入集群,它将发送心跳数据包,并通告它正服务于configEpoch为3的哈希槽1和2。所有接收者将转而看到相同的哈希插槽关联着具有较高configEpoch的节点B,将发送心跳包。因此,它们会发送带有最新哈希槽配置的更新消息到A。 A将更新其配置,鉴于上面第二规则。
节点如何重新加入集群
节点重新加入集群使用相同的基本机制。继续上面的例子,节点A将被通告哈希槽1和2现在由B节点服务。假设这两个是由A服务的唯一哈希槽,那么由A服务的哈希槽将下降到0。因此A将重配置为新master的slave节点。
事实上后面的规则要复杂一点。通常A可能会过很长时间后再加入集群,在此期间可能最初由节点A服务的哈希槽变成由多个其他master节点服务,如哈希槽1由B服务,2则由C服务。
因此,实际上Redis集群节电角色切换规则是:master节点将改变其配置,以复制(作为一个slave节点的身份)最终所需要服务的哈希槽。
在重配置期间,最终负责的哈希槽数会降为0,然后节点会相应的重新配置。但注意一般情况下,这意味着旧master将成为故障转移它的slave节点的slave节点。然而基本上这些规则覆盖了所有情况。
所有的Slave节点在故障转移它旧master时都会做相同的事情:重配置以复制旧master最终所需要服务的哈希槽。
备份迁移(Replica migration)
Redis集群实现一个概念为备份迁移(Replica migration)功能,以此提升系统可用性。集群中有master/slave两种角色,若master和slave间映射关系是固定的,那么久而久之,当发生多个单一节点独立故障时,系统可用性会变得很有限。
如在一个集群中,每个master都有一个slave节点,当master或slave节点故障时集群还是能正常操作(但master和slave节点同时故障集群就不可了),然而这样长期会积累很多由硬件或软件问题引起的单一节点独立故障。如:
·master A有一个slave A1
·master A故障,A1被提升为新master
·三小时后A1因为自身故障挂掉了。由于没有其他slave节点可以提升为master节点(A仍未恢复正常),集群无法继续正常操作。
若master和slave间的映射关系固定,让集群面对以上问题能轻松解决的唯一方法是为而每个master添加多个slave节点,然而此也会付出更多成本,如多一些实例,更多的内存等。
一个候选方案是在集群中创建非对称性master-slave节点数量,让集群架构随时间自动变化。如集群中有三个master节点A、B、C,A和B各自有一个slave节点,A1和B1。节点C有两个slave节点C1和C2。
备份迁移是slave节点自动重新配置的过程,主要是为了将“多余”的slave节点迁移到没有可工作的slave的master节点上。因此以上例子将变成:
·master A故障,A1被提升为新master
·节点C2迁移成为节点A1的slave节点(保证A1有可故障转移的slave节点)
·三小时后A1也故障
·节点C2取代A1成为新master节点
·集群仍然正常工作
备份迁移算法
迁移算法不用任何形式的协议,因为Redis集群中slave节点布局不是集群配置信息(配置信息要求前后一致并且/或者用config Epochs来标记版本号)的一部分。它使用的是一个避免在master节点没有备份时slave节点大批迁移的算法。这个算法保证,一旦集群配置信息稳定下来,最终每个master节点都至少会有一个slave节点作为备份。
说说算法是怎么工作的。在开始之前我们需要清楚怎么才算是一个好的slave节点:一个好的slave节点是指从给定节点的角度看,该slave节点不处于FAIL状态。
每个slave节点若检测出存在至少一个没有好的slave节点的单一master节点,那么就会触发这个算法的执行。然而在所有检测出这种情况的slave节点中,只有一部分slave节点会采取行动。 通常这“一部分slave节点”都只有一个,除非有不同的slave节点在给定时间间隔里对其他节点的失效状态有稍微不同的视角。
采取行动的slave节点是属于那些拥有最多slave节点的master节点,且不处于FAIL状态及拥有最小的节点ID。
如,假设有10个master节点,它们各有1个slave节点,另外还有2个master节点,它们各有5个slave节点。会尝试迁移的slave节点是在那2个拥有5个slave节点的master节点中的所有slave节点里,节点ID最小的那个。已知不需要用到任何协议,在集群配置信息不稳定的情况下,有可能发生一种竞争情况:多个slave节点都认为自己是不处于 FAIL 状态并且拥有较小节点ID(比较难出现)。若这种情况发生,结果是多个slave节点都会迁移到同个master节点下,不过这种结局是无害的。这种竞争发生的话,有时会使得割让出slave节点的master节点变成没有任何备份节点,当集群再次达到稳定状态的时候,本算法会再次执行,然后把slave节点迁移回它原来的master节点。
最终每个master节点都会至少有一个slave节点作为备份节点。通常表现出来的行为是,一个slave节点从一个拥有多个slave节点的master节点迁移到一个孤立的master节点。
这个算法能通过一个用户可配置的参数 cluster-migration-barrier 进行控制。这个参数表示的是,一个master节点在拥有多少个好的slave节点的时候就要割让一个slave节点出来。如这个参数若被设为 2,那么只有当一个master节点拥有2个可工作的slave节点时,它的一个slave节点会尝试迁移。
configEpoch冲突解决算法
Slave在故障转移中升级,会生成新的configEpoch值,此值保证唯一。
但也有两种事件会以不安全的方式创建新的configEpoch值,只是递增本地节点的currentEpoch,然后希望在同一时间不出现冲突。这两个事件都由管理员手动触发:
·在大多数master节点不可达时,具有TAKEOVER选项的CLUSTER FAILOVER命令能够手动将slave节点升级为master节点。在多数据中心环境下这很有用。
·在没有没有协议性能问题的情况下,集群迁移哈希槽会在本地节点生成新的configEpoch(Migration of slots for cluster rebalancing also generates new configuration epochs inside the local node without agreement for performance reasons.)
具体来说,在手动重新分片中,当一个哈希槽从节点A迁移到节点B时,重新分片程序会迫使B升级他的configEpoch到集群中已发现的最大的configEpoch再加1(若configEpoch已是集群中最大,则不再升级),此行为不需要其他节点同意。实际重新分片中通常涉及到几百个哈希槽,每个哈希槽迁移都需要一个协议在重新分片过程中来产生新的配置epoch,非常低效。另外它需要集群节点实时同步来存储最新的配置,因为此种运行方式,当第一个槽移动只需要一个新的configEpoch,使得在生产环境更加高效。
然而由于以上两种情况,有可能(虽然不太可能发生)最终会在多个节点生成相同的configEpoch。若管理员执行重新分片操作的同时发生故障转移事件,很有可能会导致currentEpoch冲突。
此外,软件bug和系统错误也可能导致多个节点持有相同的configEpoch。
服务不同哈希槽的master拥有相同的configEpoch,这是ok的,重要的是slave故障转移它的master必须拥有一个更大、唯一的configEpoch。
也就是说人工干预或重新分片会以不同的方式改变集群的配置。Redis集群特性要求哈希槽配置经常汇总,因此在任何情况下,我们都希望所有master节点拥有不同的configEpoch。
为了实现这一点,使用一个冲突解决算法,以此保证两个节点最终拥有相同的configEpoch。
·若一个master发现其它master节点正在通告跟它相同的configEpoch
·且若与通告相同的节点相比,节点具有字典上较小的节点ID configEpoch
·然后它把currentEpoch加1作为新的configEpoch
若有一些节点有相同的configEpoch,除了最大节点 ID的节点其他节点都将自增configEpoch,保证无论发生什么最终有唯一configEpoch。
该机制还保证在新群集创建后,所有节点以不同的configEpoch启动(即使这实际上未被使用),因为redis-trib确保在启动时使用CONFIG SET-CONFIG-EPOCH。然而,若由于某种原因,一个节点被配置错误,它将自动更新它的配置到一个不同的configEpoch。
节点重置
节点可以软重置(无需重启)以便以不同角色或在不同集群中重用。这在正常操作、测试和云环境非常有用,其中给定节点可以被重新配置,加入不同的节点集以放大或创建新的集群。
在集群节点中使用命令CLUSTER RESET 来重置,命令提供两个选项:
·CLUSTER RESET SOFT
·CLUSTER RESET HARD
命令必需直接发送到目标节点重置。若没有加上重置类型,默认是软重置.
下面是重置执行的操作列表:
·软重置和硬重置:若是slave节点,则将其转换为master,并丢弃其数据集。若是master节点且包含键,则中止重置操作
·软重置和硬重置:所有哈希槽被释放,手工故障转移状态也被重置
·软重置和硬重置:节点表中的所有其他节点都被删除,因此节点不再知道任何其他节点
·仅硬重置:currentEpoch,configEpoch和lastVoteEpoch都被设置成 0
·仅硬重置:节点ID被更新为新的随机ID
非空数据集的master不能重置(通常先重新分布数据到其它节点)。然而特殊情况下,想销毁当前集群然后重新创建新的集群时,先执行FLUSHALL清空后数据,再进行重置。
从集群删除节点
将所有数据重新分配给其他节点并关闭它,可实现将节点从现有集群删除。但其他节点仍然会保存已删除节点的ID和连接信息,并尝试与之连接。
因此,删除一个节点后,还要从各节点保存的其他节点列表中删除信息,通过CLUSTER FORGET <node-id>命令完成。
该命令执行后会完成两件事:
·从节点列表删除具有指定ID的节点
·60s内禁止具有相同ID的节点被重新添加
第二项有必要是因为Redis集群使用gossip协议来自动发现节点,假设从节点A移除节点X,会导致B 把节点X又告诉A。由于60秒禁止具有相同ID的节点被重新添加,Redis群集管理工具可以在60s内在所有节点中删除信息,从而防止由于自动发现而重新添加节点。
更多信息可在CLUSTER FORGET文档中找到
发布/订阅(Publish/Subscribe)
在一个Redis集群中,客户端能订阅任何一个节点,也能发布消息给任何一个节点。集群会确保发布的消息都会按需进行转发。 目前的实现方式是单纯地向所有节点广播所有的发布消息,在将来的实现中会用bloom filters或其他算法来优化。
附录
附录A:CRC16算法的ANSI C版本的参考实现
/* * Copyright 2001-2010 Georges Menie (www.menie.org) * Copyright 2010 Salvatore Sanfilippo (adapted to Redis coding style) * All rights reserved. * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * Neither the name of the University of California, Berkeley nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /* CRC16 implementation according to CCITT standards. * * Note by @antirez: this is actually the XMODEM CRC 16 algorithm, using the * following parameters: * * Name : "XMODEM", also known as "ZMODEM", "CRC-16/ACORN" * Width : 16 bit * Poly : 1021 (That is actually x^16 + x^12 + x^5 + 1) * Initialization : 0000 * Reflect Input byte : False * Reflect Output CRC : False * Xor constant to output CRC : 0000 * Output for "123456789" : 31C3 */ static const uint16_t crc16tab[256]= { 0x0000,0x1021,0x2042,0x3063,0x4084,0x50a5,0x60c6,0x70e7, 0x8108,0x9129,0xa14a,0xb16b,0xc18c,0xd1ad,0xe1ce,0xf1ef, 0x1231,0x0210,0x3273,0x2252,0x52b5,0x4294,0x72f7,0x62d6, 0x9339,0x8318,0xb37b,0xa35a,0xd3bd,0xc39c,0xf3ff,0xe3de, 0x2462,0x3443,0x0420,0x1401,0x64e6,0x74c7,0x44a4,0x5485, 0xa56a,0xb54b,0x8528,0x9509,0xe5ee,0xf5cf,0xc5ac,0xd58d, 0x3653,0x2672,0x1611,0x0630,0x76d7,0x66f6,0x5695,0x46b4, 0xb75b,0xa77a,0x9719,0x8738,0xf7df,0xe7fe,0xd79d,0xc7bc, 0x48c4,0x58e5,0x6886,0x78a7,0x0840,0x1861,0x2802,0x3823, 0xc9cc,0xd9ed,0xe98e,0xf9af,0x8948,0x9969,0xa90a,0xb92b, 0x5af5,0x4ad4,0x7ab7,0x6a96,0x1a71,0x0a50,0x3a33,0x2a12, 0xdbfd,0xcbdc,0xfbbf,0xeb9e,0x9b79,0x8b58,0xbb3b,0xab1a, 0x6ca6,0x7c87,0x4ce4,0x5cc5,0x2c22,0x3c03,0x0c60,0x1c41, 0xedae,0xfd8f,0xcdec,0xddcd,0xad2a,0xbd0b,0x8d68,0x9d49, 0x7e97,0x6eb6,0x5ed5,0x4ef4,0x3e13,0x2e32,0x1e51,0x0e70, 0xff9f,0xefbe,0xdfdd,0xcffc,0xbf1b,0xaf3a,0x9f59,0x8f78, 0x9188,0x81a9,0xb1ca,0xa1eb,0xd10c,0xc12d,0xf14e,0xe16f, 0x1080,0x00a1,0x30c2,0x20e3,0x5004,0x4025,0x7046,0x6067, 0x83b9,0x9398,0xa3fb,0xb3da,0xc33d,0xd31c,0xe37f,0xf35e, 0x02b1,0x1290,0x22f3,0x32d2,0x4235,0x5214,0x6277,0x7256, 0xb5ea,0xa5cb,0x95a8,0x8589,0xf56e,0xe54f,0xd52c,0xc50d, 0x34e2,0x24c3,0x14a0,0x0481,0x7466,0x6447,0x5424,0x4405, 0xa7db,0xb7fa,0x8799,0x97b8,0xe75f,0xf77e,0xc71d,0xd73c, 0x26d3,0x36f2,0x0691,0x16b0,0x6657,0x7676,0x4615,0x5634, 0xd94c,0xc96d,0xf90e,0xe92f,0x99c8,0x89e9,0xb98a,0xa9ab, 0x5844,0x4865,0x7806,0x6827,0x18c0,0x08e1,0x3882,0x28a3, 0xcb7d,0xdb5c,0xeb3f,0xfb1e,0x8bf9,0x9bd8,0xabbb,0xbb9a, 0x4a75,0x5a54,0x6a37,0x7a16,0x0af1,0x1ad0,0x2ab3,0x3a92, 0xfd2e,0xed0f,0xdd6c,0xcd4d,0xbdaa,0xad8b,0x9de8,0x8dc9, 0x7c26,0x6c07,0x5c64,0x4c45,0x3ca2,0x2c83,0x1ce0,0x0cc1, 0xef1f,0xff3e,0xcf5d,0xdf7c,0xaf9b,0xbfba,0x8fd9,0x9ff8, 0x6e17,0x7e36,0x4e55,0x5e74,0x2e93,0x3eb2,0x0ed1,0x1ef0 }; uint16_t crc16(const char *buf, int len) { int counter; uint16_t crc = 0; for (counter = 0; counter < len; counter++) crc = (crc<<8) ^ crc16tab[((crc>>8) ^ *buf++)&0x00FF]; return crc; }