redis-cluster-specification

Redis集群规范(译)

欢迎来到Redis集群规范。在这里,你可以了解到Redis集群的算法和设计原理。这篇文档还在持续改进过程中,与Redis集群的具体实现保持一致。

主要目标与设计原理

Redis集群设计目标

Redis集群是Redis的分布式实现,主要有如下几个目标,按照重要性依次排列:

  • 高性能以及线性扩展到1000个节点。不使用代理,主从间采用异步复制,并且不支持冲突值的合并操作。
  • 可接受程度的写安全性:当(发生网络分区)客户端可以与系统多数派主节点保持联系时,系统会(尽最大努力)尝试保留来自客户端的所有写数据。通常情况下,可能会有一个小窗口时间内的写数据会丢失。当节点处于少数派网络分区时,这个丢失数据的时间窗口会变得更大。
  • 可用性:当发生网络分区时,如果大多数主节点都可用,并且那些不可用的主节点都至少有一个可用的从节点,那么Redis集群仍然可以保持可用。而且通过从节点迁移命令,可以将具有多个从节点的主节点的从节点分配给没有从节点的主节点。

本文档描述的内容在Redis3.0以及更高版本中实现。

实现的功能子集

Redis集群实现了单机版Redis的所有单键命令。那些复杂的支持操作多键操作的命令,比如像集合类型的并集或交集指令,也都可以被支持,只要这些键被映射到同一个节点。

Redis集群还支持哈希标签的概念,用来强制指定键映射到相同节点。但是,在手动重分片过程中,多键操作的指令可能变得不可用,但是单键操作一直都是有效的。

Redis集群不支持多个数据库空间,只有一个数据库空间:0。因此,也不支持SELECT指令。

Redis集群协议中的角色

在Redis集群中,服务端节点负责持有数据,以及保持集群状态,包括键与节点的映射关系。节点还可以自动发现其他节点,探测失败节点,以及在需要故障转移的时候推选主节点。

为了完成这些集群功能,所有集群节点彼此间通过二进制协议在TCP连接上通信,称之为Redis集群总线。每个节点都和其他每个节点通过集群总线连接在一起。节点通过Gossip协议传播集群信息,用于发现新节点,探测节点的健康状况,以及在特殊情况下发送集群消息。集群总线同样还可以用来在集群内传播发布/订阅消息,以及编排由用户发起的手动故障转移(手动故障转移指由集群管理员直接发起的故障转移,而不是由集群失败探测器自动发起的)。

由于集群节点不能代理转发客户端请求,客户端在接收到重定向错误-Moved 和 -ASK的时候,可以被重定向到其他节点。客户端理论上可以自由的发送请求给任意节点,然后被重定向到目标节点,所以客户端没有必要持久化集群状态。但是如果客户端可以合理的缓存键和节点的映射关系,可以在某种程度上提升性能。

写安全性

Redis集群在节点间采用异步复制机制,并且隐式的采取最后故障恢复胜出的合并策略。这意味着最后一个选举胜出的节点数据集最终将会替代其他数据拷贝。在发生网络分区期间,有一个时间窗口可能会丢失写数据。但是,多数派集群的时间窗口和少数派时间窗口是非常不同的 。

与少数派集群相比,多数派Redis集群会更努力尝试保留客户端的写数据。以下列举了一些故障期间导致多数派集群节点丢失写数据的场景:

  1. 写操作发送给了主节点,主节点回复写成功给客户端,但是这个写操作可能无法通过异步复制传播到从节点。如果主节点在把写操作同步给从节点前就退出了,并且主节点在接下来的很长一段时间内都处于不可用状态,从节点被推举成为新的主节点,那么写操作可能就永远丢失了。通常很难观察到主节点突然间完全失败(导致丢失写数据的)现象,因为主节点通常同时回复给客户端(写操作成功)并回复给从节点(传播写操作)。但这确实是真实世界中可能会发生的故障模式。

  2. 另一个理论上可能的写丢失故障步骤如下:

    • 网络分区中主节点变得不可用
    • 通过故障转移机制,从节点选举胜出为主节点
    • 过了一段时间,原来的主节点从新变得可用
    • 在旧节点被转换成新主节点的从节点前,拥有过期路由关系的客户端可能会继续尝试写入旧的主节点。

    这第二种故障场景不大可能会发生。因为当一段时间内主节点无法与集群中的大多数主节点进行通信的时候,主节点将会拒绝写操作,并且当网络分区恢复以后,写操作将在一小段时间内继续被拒绝,这样可以确保旧节点可以接收到其他节点传递的集群状态变更(从而转换角色为从节点)。这个故障场景还要求客户端的路由关系没有得到更新。

    写往网络分区中少数派集群的写操作,会在更长时间窗口内丢失数据。例如,当发生网络分区时,如果少数派集群所在分区中有至少一个客户端和集群保持联系,那么Redis集群将会丢失数量可观的写操作,因为所有写往这些主节点的写操作都可能会丢失,如果这些主节点在被多数派集群故障转移了。

    具体来说,要想让一个主节点被故障转移,它必须在至少NODE_TIMEOUT时间内无法被多数派主节点访问。因此,如果网络分区在这段时间内得到了修复,那么就没有写操作会丢失。当网络分区持续时间超过了NODE_TIMEOUT,少数派集群的写操作可能会丢失。但是,少数派集群中的主节点在超过NODE_TIMEOUT时间都无法与多数派主节点通信的情况下会立刻拒绝写操作。因此,少数派主节点变得不可用以后,存在一个最大的时间窗口(可能丢失数据)。在那以后,没有写操作会被允许或者丢失。

可用性

在少数派网络分区中,Redis集群是不可用的。在多数派分区中,假设至少有多数派主节点可用,并且那些不可用的主节点都有从节点的情况下,在经过NODE_TIMEOUT时间段额外加上几秒用于推举主节点并故障转移旧的主节点,Redis集群又可以重新变得可用(故障转移通常需要1到2秒)。

这意味着,Redis集群设计上可以允许集群中少数节点的故障,但不适合那种发生大网络分区并继续保持可用性的应用场景。

假设一个Redis集群中包含N个主节点,每个主节点有一个从节点。如果有一个节点被网络隔离开,那么多数派集群可以继续保持可用性,如果有两个节点被隔离开,我们有1-(1/(N*2-1))的概率继续保持可用性(在第一个节点离开的情况下,我们总共还有N*2-1个节点,那么第二个离开的节点和第一个离开的节点恰好是主从关系的概率是1/(N*2-1))。

举个例子,集群中有5个主节点和5个从节点,当有两个节点被网络分区隔离开的时候,集群有1/(5*2-1)=11.11%的概率不再可用。

庆幸的是,Redis集群有一个从节点迁移机制,可以将从节点迁移到那些孤儿主节点,在现实场景中Redis集群的可用性得到了改善(孤儿主节点指没有从节点的主节点)。因此,每次发生故障事件,集群都会重新调整从节点布局,以期更好的度过下一次故障。

性能

在Redis集群中节点不会代理转发请求到正确节点,但是它们会重定向客户端到正确节点覆盖特定的键空间。

最终,客户端可以得知最新的集群信息,以及哪个节点负责处理哪些键。因此,在后续操作中客户端可以直接向正确的节点发送命令。

由于采用了异步复制机制,主节点不会等待从节点的确认(除非客户端指定了WAIT命令)

另外,由于多键操作指令被限制在操作同节点的键,数据从来不会在节点间移动,除非重分片的场景。

普通指令的处理和单机版Redis的处理方式完全一样。这意味着,在N个主节点的Redis集群中,由于线性扩展,你可以预期达到N个单机版Redis性能的性能。与此同时,通常指令都是在一个往返中完成,因为客户端通常会保持与节点的长链接,因此延迟曲线图也和单机版Redis相同。

提供非常高的性能与扩展性,同时保留弱但合理的数据安全与可用性,这就是Redis集群的主要设计目标。

为什么不允许冲突合并操作

Redis集群设计避免了多个节点间相同Key-Value的版本冲突,因为在Redis的数据模型中这不是很合理可取。在Redis中,Value通常非常大;在列表或者有序集合中存储上百万个元素,这是很常见的场景。而且,数据类型通常很复杂。传输并合并这些类型的值,会成为性能瓶颈,同时还需要在应用逻辑上增加不少的改进处理,需要更多内存来存储元信息,等等。

这里并没有严格意义上的技术限制。CRDTs或者同步复制状态机可以处理类似于Redis的复杂数据类型。但是,这类系统的实际运行时表现并不像Redis集群。Redis集群的设计是用来完全覆盖单机版Redis的使用场景。

Redis集群主要概览

键空间分布模型

键空间被分割成16384个虚拟槽,限制了集群的主节点个数上限为16384(但是实际建议最大节点个数在~1000级别).

每个主节点负责处理16384个哈希槽中的部分子集。如果没有发生集群重配置(比如,哈希槽从一个节点迁移到另一个节点),那么Redis集群就是稳定的。当集群处于稳定状态,哈希槽只会被一个主节点映射处理(当然,这个主节点可能会有一个或多个从节点,在发生故障或者网络分区期间替换主节点,也可以用于扩展读操作性能,如果数据过期是可以接受的 )。

映射键到哈希槽点基本算法如下(阅读下一节的哈希标签规则,作为此规则的补充):

1
HASH_SLOT = CRC16(key) mod 16384

CRC16具体细则如下:

  • 名称:XMODEM(也称为ZMODEM,或者CRC-16/ACORN)
  • 宽度:16位
  • 多项式:1021( 实际公式:x16 + x12 + x5 + 1)
  • 初始值:0000
  • Reflect Input byte:否
  • Reflect Output CRC:否
  • 输出CRC的异或运算常量:0000
  • “123456789”的输出结果:31C3

CRC16算法生成的16位结果中的14位被实际使用(这也是为什么上面公式中为什么要对16384取余)。

在将不同类型的键空间均匀分布到16384个槽的测试中,CRC16表现的非常好。

注意:我们使用的CRC16算法的参考实现,在本文档的附录A中给出。

键哈希标签

除了上一节所述的哈希槽计算规则,还存在补充规则:哈希标签。哈希标签是一种用来确保多个键被分配到同一个哈希槽的方式,通常在Redis集群中需要多键操作的时候会被使用。

为了能支持哈希标签,我们用一种稍微不同的方式来计算键的哈希槽。如果键包含”{…}”模式,那么只有在{ 和 }之间的字符串会被用来计算哈希槽。但是,考虑到有可能出现多个{或者}字符的情况,因此还需要遵守如下规则:

  • 如果键包含’{’字符
  • 并且在’{’字符的后面包含’}‘
  • 并且在第一次出现’{‘字符和第一次出现的’}’字符之间存在着一个或者多个字符

如果符合上述条件,那么只有在第一次出现的’{‘字符和第一次出现的’}’字符之间的字符串会被用来计算哈希槽。

举个例子:

  • 键{user1000}.following 和 键{user1000}.followers 将会被映射到相同哈希槽,因为只有中间的字符串”user1000”会被用来计算
  • 至于键foo{}{bar},整个键都会被用来计算哈希,因为第一次出现的’{‘和’}’之间没有字符
  • 至于键foozap,中间字符串”{bar”会被用来计算哈希,因为它出现在第一次出现的’{‘字符和后面第一次出现的’}’字符之间
  • 如果是键foo{bar}{zap},那么中间字符串“bar”会用来计算哈希,因为算法在遇到第一个合法或者非法的匹配{和}情况就会终止。
  • 由此可以推断,如果键是以{}开头的,那么可以确保整个键都会被用来计算哈希。这在使用二进制数据作为键的时候很有用

加上哈希标签这个例外情况,以下是HASH_SLOT函数的完整实现。

Ruby代码示例:

1
2
3
4
5
6
7
8
9
10
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代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}

集群节点属性

每个节点都有一个在集群中唯一的名称。这个集群名称是160位随机数字的16十进制表示,在集群第一次启动的时候确定(通常是通过 /dev/urandom)。节点会将它的ID存储在它的配置文件中,并从此一直使用同一的ID,除非配置文件被系统管理员删除,或者通过CLUSTER RESET指令重置。

节点ID用来在集群中唯一标识节点。节点在不更改ID的情况下,可以更改它的IP信息。集群也可以通过在集群总线上运行的Gossip协议探测到节点的IP/端口变化与重配置。

节点ID不仅仅是节点的关联信息,也是节点唯一一个在全局始终保持一致的属性。节点还有其他关联信息,其中包括该节点的集群配置信息,这个最终将会在全局达成一致,以及一些节点本地信息,例如最近一次Ping其他节点的时间。

每个节点都维护着集群中其他节点的如下信息:节点ID,IP和端口,节点标志,该节点的主节点(如果是从节点),最近一次Ping的时间,与该节点连接状态,以及该节点负责处理的哈希槽映射。

关于节点字段的细节描述在CLUSTER NODES中可以找到。

CLUSTER NODES命令可以发送给任意节点,从回复中可以从该节点的本地视角看到这个集群的状态以及集群中每个节点的信息。

以下是一个发送给三节点小集群的主节点的CLUSTER NODES命令的样例输出。

1
2
3
4
$ redis-cli cluster nodes
d1861060fe6a534d42d8a19aeb36600e18785e04 127.0.0.1:6379 myself - 0 1318428930 1 connected 0-1364
3886e65cc906bfd9b1f7e7bde468726a052d1dae 127.0.0.1:6380 master - 1318428930 1318428931 2 connected 1365-2729
d289c575dcbc4bdd2931585fd4339089e461a27d 127.0.0.1:6381 master - 1318428931 1318428931 3 connected 2730-4095

上面的例子中,按序列出了节点的属性信息:node id, address:port, flags, last ping sent, last pong received, configuration epoch, link state, slots。关于这些字段的细节,在我们讨论到Redis集群相关部分的时候会涉及。

集群总线

每个节点都提供一个额外的TCP端口用来接受其他集群节点的连接。这个端口号和普通接受客户端连接的命令端口号相差固定偏移量,通常是10000.举个例子,如果节点在6379端口上为客户端提供服务,那么集群总线端口号16379也会被打开。

点对点通信,专门使用集群总线和集群总线协议:一个定义了不同类型大小的数据帧的二进制协议。这个协议并没有公开在文档中,因为这个协议不是用来让外部设备与集群节点通信的。但是,你可以通过阅读Redis集群源码中的 cluster.h 和 cluster.c 获得更详细的细节。

集群拓扑

Redis集群是一个完全连接的网格,每个节点都和其他每个节点通过一个TCP连接通信。

在一个N节点集群中,每个节点有N-1个传出连接,N-1个传入连接。

这些TCP连接始终保持长链接,并且不是在需要的时候才创建。当一个节点预期从集群总线中得到PING的PONG响应时,它不会一直等待下去直到节点被标记为不可达,而是通过从头创建新连接的方式刷新连接。

虽然Redis集群中的节点形成了完全相连的网格,节点间通过Gossip协议和配置更新机制来避免在正常情况下节点间交换太多消息,因此消息数量不会是指数级。

节点握手

节点总是会接受传入集群总线端口上的连接,甚至即使对端节点是不可信的,也会对PING消息作出响应。但是,如果对端节点不是集群成员,那么所有其他类型消息都会被节点丢弃。

节点只有在如下两种情况下,才会将另一个节点接纳为集群成员:

  • 如果对端节点发送了 MEET 消息。MEET消息和PING消息类似,但是它会强制接收方接受发送方为集群成员。节点只有在系统管理员发送如下指令的情况下才会发送MEET消息给其他节点:

CLUSTER MEET ip port

  • 如果一个可信赖节点通过Gossip协议”介绍”了新节点,那么这个新节点也可以被接纳为集群成员。举个例子,如果A知道B,B知道C,然后B通过Gossip协议将C介绍给A,那么A将会登记C为集群的一部分,并和C建立联系。

这意味着,只要我们将两个子图中的节点相连,最终他们会自动形成完全相连的图。这意味着集群可以自动发现其他节点,但是需要系统管理员强制指定初始信任关系。

这个机制使得集群鲁棒性更佳,避免了不同Redis集群在改变了IP地址或者其他网络变更以后被混淆在一起。

重定向与重分片

MOVED 重定向

客户端可以自由地向任意节点发送请求,包括从节点。节点会分析这个请求,如果它是可以接受的(也就是,单键操作或者映射到同一个哈希槽的多键操作),节点会查找这个键的哈希槽的服务节点。

如果哈希槽由当前节点负责,那么会按照流程继续处理,否则节点会查找内部哈希槽映射表,返回给客户端一个MOVED错误,如下所示:

1
2
GET x
-MOVED 3999 127.0.0.1:6381

error信息中包含了哈希槽(3999)以及可以处理这个请求的节点的IP和端口信息。客户端需要向这个节点重新发起请求。假设客户端经过很长一段时间以后再发起请求,并且在此期间集群配置发生了变更,目标节点可能也会返回MOVED error,因为3999这个哈希槽可能正在由另一个节点提供服务。如果目标节点没有更新信息,类似的情况也会发生。

所以,虽然集群节点是通过ID来唯一标识的,我们仍然尝试简化我们的客户端接口,只暴露哈希槽和节点IP端口对的映射关系。

虽然不是必须的,客户端还是可以尝试记住哈希槽3999与节点127.0.0.1:6381的映射。这样一旦需要路由新的请求,客户端可以计算出键的哈希槽,并有更大的可能性选中正确的节点。

一种替代方案是当客户端收到MOVED重定向的时候,通过CLUSTER NODES或者CLUSTER SLOTS指令更新客户端的集群配置信息。因为在这种情况下,很可能多个哈希槽已经被重配置了,而不仅仅是一个哈希槽,所以尽快更新客户端的集群配置信息是最好的策略。当集群处于稳定状态(没有正在进行中的配置的变更),最终所有客户端都会获取到哈希槽与节点的映射关系,这样客户端可以直接与正确的节点通信,不需要重定向,代理,或者引入其他单点故障实体,使得集群变得高效。

客户端必须要处理稍后介绍的ASK重定向,否则它不是一个完整实现了Redis集群协议的客户端。

集群在线重配置

Redis集群支持在运行过程中增加/移除节点。增删节点可以抽象为同一类操作:将哈希槽从一个节点迁移到另一个节点。这意味着,这个机制可以用来再平衡Redis集群,增删节点,等等。

  • 将一个全新节点加入集群,先添加一个空节点为集群成员,然后从别的节点迁移部分哈希槽到这个空节点就行
  • 移除一个节点,先将哈希槽指派给其他节点
  • 集群再平衡,可以通过在节点间迁移哈希槽的方式达成

因此,关键在于支持节点间的哈希槽迁移能力。从实践角度来看,哈希槽只是键空间的子集,所以Redis集群在重分片期间所做的事情,就是逐个将键从实例挪到另一个实例上。迁移哈希槽,就意味着迁移所有属于这个哈希槽的键。

为了解释这是如何工作的,我们先展示一下可以操中哈希槽映射表的CLUSTER子命令。

如下子命令是可用的(排除掉其他在这个场景下用处不大的):

头两个子命令 ADDSLOTSDELSLOTS, 用来指派/取消指派哈希槽给节点. 指派哈希槽意味着告诉这个主节点,它负责存储以及处理这个哈希槽的数据。

哈希槽被指派以后,这个信息会通过Gossip协议传播到全集群,后文配置传播一节将涉及。

ADDSLOTS 通常用在全新集群初始化的时候给每个主节点分配分片。

DELSLOTS 通常用在手工调整集群配置或者调试任务,实际情况下它很少使用。

SETSLOT 通常用来指派哈希槽到特定节点,如果使用的是 SETSLOT <slot> NODE 格式.否则哈希槽可以被设置为两种特殊状态: MIGRATING 或者 IMPORTING。这两种特殊状态在迁移哈希槽的过程中会用到。

  • 当哈希槽被设置为MIGRATING状态, 节点仍然会继续接受这个哈希槽的请求,但是如果请求中的键不存在,节点将会返回-ASK,重定向客户端到MIGRATING的目标节点。
  • 当哈希槽被设置IMPORTING状态, 只有当客户端先发送ASKING命令之后,节点才会处理这个哈希槽的请求。如果没有ASKING,请求将会被MOVED重定向回这个哈希槽的真实处理节点,就像前文所述的普通处理流程。

让我们通过一个例子来解释清楚。假设,我们有两个Redis主节点,A和B。我们希望将哈希槽8从A节点迁移到B节点,所以我们发起如下指令:

  • 发送给B:CLUSTER SETSLOT 8 IMPORTING A
  • 发送给A:CLUSTER SETSLOT 8 MIGRATING B

这时候,其他节点如果收到属于哈希槽8的键请求,会将客户端MOVED重定向到节点A。这些请求会按照如下方式处理:

  • 如果键还在节点A上,那么由A继续处理
  • 如果键不在节点A上,那么请求会被ASK重定向到节点B

这样,我们可以避免在A节点上创建新键。在此期间,一个叫做redis-trib的程序会被启动,Redis集群会将哈希槽8的键从A节点迁移到B节点。以下是迁移过程:

1
CLUSTER GETKEYSINSLOT slot count

上述命令可以返回属于给定哈希槽的键列表。对于服务端回复的每个键,redis-trib会给A节点发送MIGRATE 指令,这个指令可以原子性地将一个键从节点A迁移到B(两个节点都会被锁定一小段时间以避免并发竞争)。如下是 MIGRATE 指令的原理:

1
MIGRATE target_host target_port key target_database id timeout

MIGRATE 会连接到目标节点,将键的序列化版本发送给对方,收到OK以后从本地数据库中移除键。从一个外部客户端的视角来看,此时这个键存在于A或者B两者之一。

通常在Redis集群中不需要指定数据库,但是MIGRATE 是一个通用命令,可以用于其他任务,不仅仅是Redis集群。虽然 MIGRATE 指令专门优化过可以尽可能快地迁移复杂类型的键,但是如果应用层对数据库延迟敏感,那么在Rediscover集群中重新配置大键的存储位置可能不是一个明智的做法。

迁移过程最终完成以后, SETSLOT <slot> NODE <node-id> 指令会被发送到参与迁移过程的这两个节点上以确保它们的状态正确.此外,同样的命令还会发送到其他节点,以避免等待集群重配置过程才能获取到迁移变化信息。

ASK 重定向

在前面章节我们简单提及了ASK重定向。为什么我们不能直接使用MOVED重定向?因为MOVED重定向代表了我们认为哈希槽已经永久迁移到了另一个节点,接下来的所有请求应该发送给那个节点。而ASK意味着临时重定向,只有下一次请求应该发送给其他节点。

这是因为下一次关于哈希槽8的请求,所处理的键可能还在节点A上,因此我们希望客户端总是先尝试A,如果需要再尝试B。由于这种情况只会发生在16384个槽中的一个,因此对集群的性能影响是可以接受的。

我们需要强制客户端遵守这个规则。为了避免客户端在从A被重定向到B以后只尝试B节点,当一个哈希槽被标记为IMPORTING状态时,节点B只会在收到客户端的ASKING命令以后才会接受这个哈希槽的请求。

基本上ASKING命令会在客户端设置一个一次性标记,确保服务端会处理处于IMPORTING状态的哈希槽请求。

从客户端的视角来看,ASK重定向的完整语义包括如下节点:

  • 如果收到了ASK重定向回复, 只能发送这个被重定向的请求到指定节点,其他请求需要继续发送到旧节点
  • 先发送ASKING指令再将请求发送到目标节点
  • 不能在本地映射表中,将哈希槽8指向节点B

一旦哈希槽迁移完成, A 会回复给客户端MOVED,客户端此时可以永久性映射哈希槽8到新IP/端口。如果一个有缺陷的客户端提前做了映射,这也不会成为问题,因为它没有在发送请求前先发送ASKING指令,那么B节点会通过MOVED错误将其重定向回节点A。

槽迁移在CLUSTER SETSLOT文档中也做了介绍,类似的概念但不同的术语。

客户端第一次连接和处理重定向

假设有一种客户端实现,它不在内存中记录槽配置(槽与节点地址的映射),并且每次都随机选择一个节点并预期重定向到正确节点,这样的客户端实现将会非常低效。

Redis集群的客户端应该足够聪明,可以记住槽配置信息。但是这个配置信息不需要是最新的。因为即使连接到错误的节点也会收到重定向回复,这会触发客户端的更新。

在以下两种情况下,客户端需要获取哈希槽的完整列表并映射到节点地址:

  • 在启动时需要填充槽配置
  • 当收到 MOVED 重定向的时候

由于客户端在收到MOVED重定向的时候,可能只会在映射表中更新重定向的条目。但是,通常这种做法不够高效,因为通常情况下多个槽的配置都发生了变更(比如说,如果一个从节点当选为主节点,那么旧节点的所有哈希槽都需要重新映射)。更简单的做法是重新获取一份槽与节点的完整映射。

为了获取槽配置信息,Redis集群提供了 CLUSTER NODES 指令的替代品,不需要额外解析,并且仅提供客户端需要的有效信息。

这个新指令叫做 CLUSTER SLOTS ,提供槽范围数组以及负责处理槽范围的主从节点信息。

如下是 CLUSTER SLOTS命令的样例输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:7000> cluster slots
1) 1) (integer) 5461
2) (integer) 10922
3) 1) "127.0.0.1"
2) (integer) 7001
4) 1) "127.0.0.1"
2) (integer) 7004
2) 1) (integer) 0
2) (integer) 5460
3) 1) "127.0.0.1"
2) (integer) 7000
4) 1) "127.0.0.1"
2) (integer) 7003
3) 1) (integer) 10923
2) (integer) 16383
3) 1) "127.0.0.1"
2) (integer) 7002
4) 1) "127.0.0.1"
2) (integer) 7005

数组中每个元素的头两个子元素代表了槽范围的起始和终止。其他元素代表了地址-端口对信息。第一个地址-端口对是主节点,剩下的是健康的从节点(例如,没有被标记为FAIL)。

例如,上述输出内容的第一个元素表明,从5461到10922范围的槽(起始和终止都包括)有主节点127.0.0.1:7001提供服务,并且可以通过连接从节点127.0.0.1:7004的方式扩展只读负载。

CLUSTER SLOTS 指令不保证返回的结果会覆盖16384个槽,这在集群配置错误的情况下是有可能发生的。因此客户端在初始化配置的时候应该将目标节点填充为空对象,并且当用户命令操作的键属于未分配的哈希槽的时候应该报告错误。

在发现槽没有分配到对应节点并返回错误之前,客户端应该尝试再次尝试获取槽配置信息,检查集群配置是否恢复正常。

多键操作

通过哈希表中,客户端可以自由使用多键操作。例如,下列操作是合法的:

1
MSET {user:1000}.name Angela {user:1000}.surname White

当哈希槽重分片的时候,属于这个哈希槽的多键操作可能变得不可用。

更具体而言,即使在哈希槽重分片期间,只要这些键都存在而且都存储在同一个节点,那么多键操作还是可用的。

在重分片期间,对于不存在的键或者分跨不同节点的多个键的指令会得到 -TRYAGAIN 错误。客户端可以在稍后一段时间再重试,或者回报错误。

一旦哈希槽的迁移过程结束,与之相关的多键操作又会重新变回可用。

读扩展

通常情况下,从节点会将客户端重定向到负责相关哈希槽的主节点,但是客户端可以通过 READONLY 指令利用从节点来扩展读性能。

READONLY 指令告诉从节点,客户端可以接受读到过期数据且不需要执行写操作。

在连接处于只读模式的情况下,当从节点的主节点不负责处理请求涉及的键槽时,客户端会收到重定向错误。这可能是因为:

  1. 客户端发送的请求中涉及到不被主节点负责的键槽
  2. 集群配置发生了变化(比如重分片),那么从节点再也不能为这个哈希槽提供服务

当这些情况发生时,客户端应该像前面几节中提到的调整本地哈希槽映射。

连接的只读状态可以通过 READWRITE 指令清除。

容错

心跳检测与Gossip消息

Redis群集节点不断交换ping和pong数据包。这两种数据包具有相同的结构,并且都携带重要的配置信息。唯一的实际区别是消息类型字段。我们将ping和pong包通称为心跳包。

通常节点发送ping数据包,触发接收方回复pong数据包。当然这不是必须如此。节点也可以仅发送pong数据包以向其他节点发送有关其配置的信息,而不会触发回复。例如,这在希望尽快广播新配置的情况下很有用。

通常,节点将每秒ping几个随机节点,以确保每个节点发送的ping数据包总数(以及接收到的pong数据包)是一个恒定的数量,而不会随着集群中的节点数量变化。

但是,每个节点还需要ping所有超过一半NODE_TIMEOUT时间,未发送过ping或没有接收pong消息的节点。在NODE_TIMEOUT过去之前,节点还会尝试将重新建立到该节点的新连接,避免因为当前TCP连接的故障而误认为连接不可达。

如果将NODE_TIMEOUT设置为较小的数字同时节点数(N)非常大,那么全局消息数量可能变得相当大,因为每个节点将尝试对那些超过一半NODE_TIMEOUT时间没有新消息的其他节点发送ping指令。

例如,在100节点集群中将超时设置为60秒,每个节点将尝试每30秒发送99个ping,总ping数为3.3 /秒。乘以100个节点,在整个群集中每秒330次ping。

有一些方法可以降低消息数量,但目前Redis集群还没有出现有关失效检测机制占用带宽的问题报告,因此这种简单直接的设计用到了现在。注意,即使是在上面的例子中,每秒交换的330个数据包也是在100个不同的节点之间均匀分配,因此每个节点接收的流量是可接受的。

心跳包内容

Ping和pong数据包包含了所有类型数据包通用的消息头(其他数据包例如,请求故障转移投票的数据包),以及特定于Ping和Pong数据包的Gossip消息部分。

公共消息头具有以下信息:

  • 节点ID,一个160位伪随机字符串,在第一次创建节点时分配,并在节点所有生命周期内保持不变。

  • 发送节点的currentEpoch和configEpoch字段,用来承载执行Redis Cluster使用的分布式算法(这将在下一节中详细介绍)。如果节点是从节点,则configEpoch是其主节点的最后已知的configEpoch。

  • 节点标志,代表了节点是从节点或者主设备,以及其他单比特节点信息。

  • 由发送节点服务的散列槽的位图,或者如果节点是从属节点,则代表其主节点服务的槽的位图。

  • 发送方TCP普通服务端口(即Redis用于接受客户端命令的端口;添加10000可以得到集群总线端口)。

  • 从发送方的角度来看集群的状态(下线或者可用)。

  • 发送节点的主节点ID(如果它是从属节点)。

Ping和pong包也包含Gossip消息。这部分消息向接收方展示了发送方节点对集群中其他节点的看法,但仅包含发送者已知的几个随机节点的信息。Gossip消息中提到的节点数量与集群大小成比例。

Gossip消息中提到的每个节点信息,将包含以下字段:

  • 节点ID。
  • 节点的IP和端口。
  • 节点标志。

Gossip消息允许接收节点从发送者的角度获得关于其他节点的状态的信息。这对于故障检测和自动发现集群中的其他节点都很有用。

故障检测

Redis群集故障检测用于识别主节点或从节点无法被大多数节点访问的场景,然后通过将从节点提升为主节点来进行应对故障。如果无法提升从节点,群集将处于错误状态,停止接收来自客户端的请求。

如前所述,每个节点都会为每一个其他已知节点设置一系列关联标志。有两个标志用于故障检测,称为PFAIL和FAIL。 PFAIL表示可能的失败,是一种未确认的失败类型。FAIL意味着节点出现故障,并且大多数主节点在固定有限时间内确认了这种情况。

PFAIL标志

当节点的可达时间超过NODE_TIMEOUT时,这个节点会被标记为PFAIL。主节点和从节点都可以将一个节点标记为PFAIL,而不管其类型如何。

Redis群集节点的不可达性概念指的是,我们有一个活动的ping(我们发送的ping,我们尚未得到回复)等待的时间超过NODE_TIMEOUT。要使此机制起作用,与网络往返时间相比,NODE_TIMEOUT必须足够大。为了提升正常时期的可靠性,只要NODE_TIMEOUT的一半时间已经过去而且对端节点没有回复ping,节点将尝试重新连接。此机制可确保连接保持活跃状态,因此避免了断开的连接导致误报节点故障。

FAIL标志

PFAIL标志只是每个节点关于其他节点的本地信息,但它不足以触发从节点选举。要想让节点被视为下线状态,PFAIL条件需要升级到FAIL条件。

如本文档的节点心跳部分所述,每个节点都向每个其他节点发送Gossip消息,包括一些随机已知节点的状态。每个节点最终都会到关于其他每个节点的标志。这样每个节点就有了通知其他节点它们检测到的故障情况的机制。

满足以下条件时,PFAIL条件将升级为FAIL条件:

  • 某个节点A将另一个节点B标记为PFAIL。

  • 节点A通过Gossip消息从群集中的大多数主节点的角度收集关于B状态的信息。

  • 大多数主节点在NODE_TIMEOUT * FAIL_REPORT_VALIDITY_MULT时间内发出PFAIL或FAIL状态信号。 (当前实现中的有效性因子设置为2,因此这只是NODE_TIMEOUT时间的两倍)。

如果满足以上所有条件,则节点A将:

  • 标记节点B为FAIL。

  • 向所有可达节点发送FAIL消息。

FAIL消息将强制每个接收方将节点B标记为FAIL状态,无论它是否已经标记处于PFAIL状态的节点。

请注意,FAIL标志是单向的。也就是说,节点可以从PFAIL转换成FAIL,但只能在以下情况下清除FAIL标志:

  • 该节点已经可以访问并且是从属节点。在这种情况下,可以清除FAIL标志,因为从节点不需要故障转移。

  • 该节点已经可以访问,并且是不为任何哈希槽提供服务的主节点。在这种情况下,可以清除FAIL标志,因为没有哈希槽的主节点实际上不参与集群,并且正在等待配置以加入集群。

  • 该节点已经可以访问并且是主节点,但是很长时间(NODE_TIMEOUT的N倍)已经过去而没有出现可检测的从节点选举。这种情况下,最好重新加入群集并继续服务。

值得注意的是,尽管PFAIL - > FAIL过渡使用了一种协议形式,但使用的协议很薄弱:

  1. 节点在一段时间内收集其他节点的视图,因此即使需要大多数主节点达成“一致”,实际上这只是我们在不同时间从不同节点收集的状态,我们不确定也不需要恰好在某个时刻大多数主节点都达成了一致。由于我们会丢弃旧的故障报告,因此FAIL状态确实是由大多数主节点在一个时间窗口内发出故障信号所触发的。

  2. 虽然检测到FAIL条件的节点会使用FAIL消息强制集群中的其他节点上执行该操作,但并不能确保消息可以到达所有节点。例如,节点可以检测到FAIL条件,并且由于网络分区将不能到达任何其他节点。

Redis集群故障检测机制具有活跃度要求:最终所有节点都应该就给定节点的状态达成一致。裂脑可能会导致两种情况,一些少数节点认为该节点处于FAIL状态,或者少数节点认为该节点未处于FAIL状态。在这两种情况下,最终集群将统一对给定节点的状态视图:

情况1:如果大多数主节点已将节点标记为FAIL,由于故障检测及其产生的连锁效应,其他节点最终将目标节点标记为FAIL,因为在指定的时间窗口中将产生足够多的故障报告。

案例2:当只有少数主节点将节点标记为失败时,从节点当选为主节点的将不会发生(因为它使用更正式的算法,确保每个节点最终都知道这次当选),那么每个节点将根据前文所述的规则清除FAIL状态(即NODE_TIMEOUT经过N次后没有从节点选举成功)。

FAIL标志仅仅是运行选主算法的安全部分的触发器。理论上,从节点的行为不受FAIL标志影响,在发现主节点无法访问时就启动选主算法,并等待其他主节点的拒绝消息以确认主节点实际上仍然可以被多数主节点访问。然而,这种PFAIL-> FAIL状态过渡,弱一致协议和强制在集群中最短时间内传播状态的FAIL消息的复杂性实际具有更多优势。由于这些机制的存在,当群集处于错误状态的存在,通常所有节点将几乎同时停止接受写入。从使用Redis集群的应用程序的角度来看,这是一个理想的功能。还避免了由于本地问题而无法到达其主设备(此时,主设备仍然可由大多数其他主节点访问)的从节点发起的错误选举尝试。

配置处理,传播和故障恢复

集群当前纪元(currentEpoch)

Redis集群使用类似于Raft算法“term”的概念。在Redis集群中,该术语称为纪元,它用来为事件提供增量版本控制。当多个节点提供的信息发生冲突时,其他节点可以了解哪个集群状态是最新的。

currentEpoch是64位无符号整数。

在创建节点时,每个Redis节点(包括从节点和主节点)都将currentEpoch设置为0。

每当从其他节点接收到消息时,如果发送方的纪元(集群总线消息报头的一部分)大于本地节点的纪元,那么currentEpoch被更新为发送方的纪元。

正因如此,最终所有节点的currentEpoch与集群中最大的configEpoch达成一致。

当群集的状态发生变化且节点需要某种协议以执行某些操作时,可以用到这个信息。

目前,这只发生在从节点晋升为主节点期间,下一节会展开描述。基本上可以认为纪元就是集群的逻辑时钟,并且规定纪元大的信息胜过纪元小的。

配置纪元(configEpoch)

每个主节点会在ping和pong数据包中广播他的configEpoch以及一个其负责的哈希槽的位图。

创建新节点时,configEpoch在master中设置为零。

在从节点发起选举的时候会创建新的configEpoch。从节点通过递增epoch的方式试图取代主节点,并试图获得大多数主节点的授权。当从节点获得了授权,将创建一个新的唯一configEpoch,并且从节点将使用新的configEpoch转为主节点。

如下一节所述,configEpoch有助于在不同节点声明不同配置(由于网络分区和节点故障而可能发生的情况)时解决冲突。

从节点也会在ping和pong数据包中通告configEpoch字段,此时该字段代表最近一次与其通信的主节点的configEpoch。这允许其他实例据此检测出旧配置需要更新的从节点(主节点不会投票给旧配置的从节点)。

每次某个已知节点的configEpoch变化时,所有接收到此信息的节点都会将configEpoch永久存储在nodes.conf文件中。 currentEpoch值也是如此。在节点继续运行其他操作之前,这两个变量可以确保被保存并刷新到磁盘。

在故障转移期间使用简单算法生成的configEpoch值保证是新的,增量的和唯一的。

从节点选举与晋升

从节点的选举与晋升过程由从节点处理,在主节点的帮助下获得投票支持。如果从节点具备了成为主节点的先决条件,并且观察到主节点处于FAIL状态,那么它可以发起选举。

从节点要想晋升为主节点,它需要发起选举并赢得选举。如果主节点处于FAIL状态,那么该节点的从节点都可以开始选举,但是只有一个从节点会赢得选举并晋升。

当满足以下条件时,从节点会开始发起选举:

  • 主节点处于FAIL状态。

  • 主节点为至少一个哈希槽提供服务。

  • 与主服务器断开连接的时间不超过给定的时间,以确保从节点上的数据尽量最新。此时间是用户可配置的。

为了被选举,从节点首先要递增其currentEpoch计数器,并请求从其他主实例获得投票。

通过向集群的每个主节点广播FAILOVER_AUTH_REQUEST消息的方式,从节点请求获得投票,然后至多等待NODE_TIMEOUT的两倍时间(但总是至少持续2秒)。

一旦主节点通过FAILOVER_AUTH_ACK消息将票投给某个从节点,在接下来的NODE_TIMEOUT * 2时间段内,它就不能再投票给同一主节点的其他从节点。在此期间,它不允许对涉及同一个主节点的其他授权请求进行回复。虽然这不是保证算法安全性所必需的,但对于防止多个从节点同时被选中(使用不同的configEpoch)非常有用,通常这不是我们希望发生的情况。

如果AUTH_ACK回复中包含的epoch小于当前最新拉票请求被发出时的currentEpoch,这个投票会被丢弃。这确保了从节点不会将历史选举的投票计入当前选举。

如果从节点接收到来自大多数主节点的ACK,它就赢得了选举。否则,如果在2*NODE_TIMEOUT期间没有收到多数派投票(至少2秒),那么选举将中止,并且在NODE_TIMEOUT * 4之后将再次尝试选举(并且始终至少4秒)。

从节点排名

如果主节点陷入FAIL状态,从节点会在等待一段时间之后开始发起选举。延迟规则计算如下:

1
2
DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds +
SLAVE_RANK * 1000 milliseconds.

固定延迟确保FAIL状态在群集中传播开,否则如果从节点尝试发起选举,但其他主节点仍然不知道FAIL状态,将会拒绝投票。

随机延迟用于避免从节点的同步操作,因此它们不太可能同时开始选举。

SLAVE_RANK代表了从节点的排名,这与从主服务器处理的复制数据量有关。当主节点发生故障时,从节点互相交换消息以建立(尽力而为)排名:具有最新复制偏移的从节点为等级0,复制偏移第二的等级为1,依此类推。通过这种方式,拥有最新数据的从节点有机会在其他从节点前当选。

排名顺序不是严格强制的;如果高级别的从节点未能成功当选,其他节点将很快尝试。

一旦从节点赢得选举,它就会获得一个新的唯一且增量的configEpoch,大于任何其他现有的主节点。它开始在ping和pong数据包中声明自己为主节点,提供服务的哈希槽,其中的configEpoch将大于过去的。

为了加速其他节点的重新配置,PONG消息会被广播到集群的所有节点。目前,不可达节点可以通过从其他节点接收ping或pong数据包的方式被重新配置,或者如果其他节点检测到它的心跳包信息已过期,将从其他节点接收到UPDATE数据包。

其他节点如果检测到有一个新主服务器提供相同的哈希槽服务并具有更大的configEpoch,将会升级其配置。旧主节点的从节点(也包括故障转移后重新加入集群的旧主节点)不仅会升级配置,还会重新配置以从新主节点进行复制。如何配置重新加入群集的节点将在下一节中介绍。

投票请求的回复

在上一节中,讨论了从节点如何发起选举与当选。本节从投票的主节点的角度解释发生的事情。

主节点收到来自从节点的FAILOVER_AUTH_REQUEST请求,以期得到投票。

从节点要获得投票,需要满足以下条件:

  1. 主节点接到只对同一纪元投票一次,并且拒绝投票给旧纪元:每个主节点都会记录一个lastVoteEpoch字段,只要auth请求数据包中的currentEpoch不大于lastVoteEpoch,它就会拒绝投票。当主节点对投票请求作出肯定回复时,lastVoteEpoch会相应更新,并安全地存储在磁盘上。

  2. 只有当从节点所属的主节点被标记为FAIL时,其他主节点才会投票给从节点。

  3. Auth请求的currentEpoch小于主节点currentEpoch的,请求将被忽略。因此主节点的回复始终与auth请求由相同的currentEpoch。如果从节点再次发起选举投票,需要增加currentEpoch,这个机制可以避免主节点的延迟旧回复被计入新投票。

如果不使用规则3,可能会导致的问题示例:

主节点的currentEpoch为5,lastVoteEpoch为1(这可能在几次选举失败的情况下出现)

  • 从节点currentEpoch是3。

  • 从节点试图用epoch 4(3 + 1)发起选举,主节点用currentEpoch 5回复OK,但是回复被延迟了。

  • 后续从节点尝试再次发起选举,使用epoch 5(4 + 1),这时带有epoch 5的延迟回复到达了slave,并被接受为有效投票。

  1. 如果已经发出投票,那么接下来在NODE_TIMEOUT * 2时间段期间,主节点不会再投票给同一主节点的从节点。这不是严格要求的,因为两个从节点不可能在同一纪元赢得选举。但实际上它确保了,当一个从节点被选中时,它有足够的时间通知其他从节点,并避免另一个从节点为了执行不必要的第二次故障转移,赢得新选举的可能性。

  2. 参与投票的主节点不会以任何方式去辨别最好的从节点。只要从节点的主节点处于FAIL状态并且当前节点没有在当前纪元内投票,则会授予投票。最好的从节点通常最有可能开始发起选举并在其他从节点之前赢得选举,因为它通常能够提前开始投票过程,因为它的排名更高,如上一节所述。

  3. 当主节点拒绝投票时,它不会回复否定消息,该请求只会被忽略。

  4. 如果从节点发送的configEpoch小于接收方节点中记载的从节点哈希槽所对应的configEpoch,节点将会拒绝投票。请记住,从节点发送的是它的主节点的configEpoch,以及哈希槽的位图。这意味着请求获得投票的从节点必须拥有其想要故障转移的主节点的哈希槽配置,该配置必须等于主节点配置或者更新。

网络分区期间configEpoch的用途案例

本节展示了纪元概念如何让从节点晋升过程对网络分区更具抵抗力。

  • 主节点无限期不可达。主节点有三个从节点A,B,C。

  • 节点A赢得选举并晋升为主节点。

  • 网络分区使与大多数集群隔离,不可用。

  • 节点B赢得选举并晋升为主节点。

  • 网络分区使B与大多数集群隔离,不可用。

  • 先前的网络分区被修复,A再次可用。

此时B已下线且A还保持master的角色(实际上UPDATE消息应该立即重新配置它,但在这里我们假设所有UPDATE消息都丢失了)。与此同时,节点C将尝试选举,以便将B故障转移。这时将发生如下情况:

  1. C将尝试选举并成功当选,因为对于其他大多数珠节点而言,它的主人实际上已经下线了。它将获得一个新的增量configEpoch。

  2. A无法声称自己是负责哈希槽的主节点,因为与A发布的配置纪元相比,其他节点已经将相同的哈希槽关联到更高的配置纪元(B的纪元)。

  3. 因此,所有节点都将升级其映射表,将哈希槽关联到C,集群将继续其操作。

正如您将在下一节中看到的,重新加入群集的陈旧节点通常会尽快收到有关配置更改的通知,因为只要它ping任何其他节点,接收方就会检测到它有陈旧信息并将发送一个UPDATE消息。

哈希槽配置传播

Redis集群的一个重要部分就是有关哪个节点为哪一组哈希槽提供服务的信息传播机制。这对于新机器的启动以及在从节点晋升为服务哈希槽的主节点后,集群其他节点升级配置的能力至关重要。

同样的机制还可以让网络分区的节点以合理的方式重新加入集群。

哈希槽配置有两种传播方式:

  1. 心跳消息。 ping和pong消息包含了有关发送方(或其主节点)负责的哈希槽信息。

  2. UPDATE消息。由于在每个心跳包中都有发送方的configEpoch和哈希槽信息,如果接收方发现信息是陈旧的,它将发送包含新信息的包,迫使过期节点更新其信息。

心跳或UPDATE消息的接收方使用一些简单规则来更新哈希槽映射表。创建新的Redis群集节点时,其本地哈希槽表被简单地初始化为NULL条目,以便每个哈希槽不绑定或链接到任何节点。这看起来类似于以下内容:

1
2
3
4
5
0 -> NULL
1 -> NULL
2 -> NULL
...
16383 -> NULL

为了更新其哈希槽表,第一个需要遵循的规则如下:

规则1:如果哈希槽未被分配(设置为NULL),并且某个已知节点声明对它负责,那么节点将修改本地哈希槽表并将哈希槽与其关联。

因此,如果我们收到节点A发来的心跳包,configEpoch值为3,并且声称对哈希槽1和2提供服务,则该表将被修改为:

1
2
3
4
5
0 -> NULL
1 -> A [3]
2 -> A [3]
...
16383 -> NULL

创建新群集时,系统管理员需要手动分配(使用CLUSTER ADDSLOTS命令,通过redis-trib命令行工具或通过任何其他方式)每个主节点服务的哈希槽,并且这些信息将快速传播到集群中。

但是这条规则还不够。我们知道哈希槽映射还可能在如下两个场景下发生变化:

  1. 在故障转移期间,从节点会替换其主节点。

  2. 哈希槽从一个节点重新分配到另一个节点。

现在让我们先关注故障转移的情况。当从节点故障转移其主节点时,它获得新的configEpoch,大于其主节点的值(并且通常大于先前生成的任何其他configEpoch)。例如,作为主节点A的从节点B,可以使用configEpoch4来故障转移.它将开始对外发送心跳包(第一次在集群范围内进行大规模广播),并且由于以下第二规则,接收方将更新他们的哈希槽表:

规则2:如果已经分配了一个哈希槽,并且一个已知节点使用的configEpoch大于当前与该槽相关联的主机的configEpoch,哈希槽将被重新绑定到新节点。

因此,如果接收到来自B的消息,其中包含configEpoch为4,并且声称对哈希槽1和2提供服务之后,接收方将按以下方式更新其表:

1
2
3
4
5
0 -> NULL
1 -> B [4]
2 -> B [4]
...
16383 -> NULL

活动(liveness)属性:由于第二个规则,最终集群中的所有节点都会达成一致,对于给定哈希槽,声明最大configEpoch的节点将成为这个哈希槽的所有者。

Redis群集中的这个机制被称为最近故障转移获胜

在重新分片期间也会发生同样的情况。 当节点完成哈希槽的导入操作时,其configEpoch会增加,以确保配置更改可以在整个群集中传播开。

走近UPDATE消息

在前面几节的基础上,更容易理解UPDATE消息的工作原理。 节点A可能在一段时间后重新加入群集。 它将发送心跳包,声称它服务于哈希槽1和2,configEpoch为3。含有更新版(配置)信息的接收方,将发现这些散列槽已经与更高configEpoch的节点B相关联。 因此,他们将哈希槽的新配置通过UPDATE消息发送给A。 由于上面的规则2,A将更新其本地配置。

节点重新加入集群

当节点重新加入群集时,基本上使用与上面相同的机制。继续上面的例子,节点A被通知哈希槽1和2现在由B提供服务。假设这是A服务的唯二哈希槽,那么A服务的哈希槽的数量将下降到0!所以A将被重新配置成新主节点的从节点。

实际规则比这复杂一点。通常,A可能在很长一段时间之后才重新加入,这时最初由A服务的哈希槽现在改由多个主节点提供服务,例如,哈希槽1可以由B服务,而哈希槽2由C提供。

因此,实际的Redis集群节点角色切换规则是这样:旧主节点将更改其配置,以复制(变成它的从节点)”窃取”了最后一个哈希槽的节点

在重新配置期间,最终哈希槽的数量将下降到零,并且节点将相应地被重新配置。请注意,在简单情况下,这只意味着旧节点会成为在故障转移后替换它的从节点的从节点。但是,在更一般情况下,这个规则可以涵盖所有可能的情况。

从节点也一样:它们也被重新配置,从”窃取”了前任主节点的最后一个哈希槽的节点复制数据。

副本迁移

Redis Cluster实现了一个名为副本迁移的概念,以提高系统的可用性。我们的想法是,在具有主从配置的集群中,如果主从之间的映射是固定的,那么随着时间的推移,在发生多次彼此独立的单点故障后,那么集群可用性会变得很有限。

例如,在每个主节点都有一个从节点的集群中,主节点或从节点之一发生故障时集群还可以继续运行,但如果两者都失败则集群无法提供服务。然而,还存在一类故障,是由日积月累的硬件或软件问题引起的互相独立的单点故障。例如:

  • 主节点A有一个从节点A1。

  • A失败了。 A1被提升为新的主节点。

  • 三小时后,A1以独立的方式失败(与A的失败无关)。由于节点A仍处于下线状态,因此没有其他从节点可以晋升为主节点。集群将无法继续正常运行。

如果主从之间的映射是固定的,那么要使集群更能抵抗上述情况的唯一方法就是向每个主服务器添加从服务器,但这样做成本很高,因为它需要运行更多Redis实例,更多内存等等。

替代方案是在集群中创建不对称,让集群布局随着时间的推移自动变更。例如,群集可以具有三个主节点A,B,C。A和B各自具有单个从节点A1和B1。然而,主节点C是不同的并且具有两个从节点:C1和C2。

副本迁移是自动重新配置从节点以便迁移到无副本覆盖的主站(无可用从节点)的过程。有了副本迁移,上面提到的场景变为:

  • A失败了。 A1晋升。

  • 由于A1没有被任何从节点备份,C2迁移为A1的从节点。

  • 三小时后,A1也失败了。

  • C2被提升为新的主节点以取代A1。

  • 群集可以继续操作。

副本迁移算法

迁移算法不使用任何形式的一致性协议,因为Redis集群中的主从布局,不属于与configEpoch一致和/或版本化的集群配置。相反,当主节点没有从节点备份时,它使用算法来避免从节点的大规模迁移。该算法最终确保(一旦集群配置稳定),每个主节点将由至少一个从节点备份。

如下是算法的工作原理。首先,我们需要在此上下文中定义什么是好的副本:从节点的角度来看,好的副本是不处于FAIL状态的从节点。

每当从节点检测到至少有一个主节点没有好的副本的时候,就会触发算法的执行。然而,在所有检测到这个情况的从节点中,只有节点的部分子集会采取行动。通常这只会包含单个从节点,除非不同的从节点在给定时刻对其他节点的故障状态有略微不同的视图。

这个采取行动的节点,是具有最大连接从节点数的主节点中的从节点,它不处于FAIL状态且具有最小的节点ID。

因此,如果有10个主服务器,每个服务器有1个从节点,还有2个主服务器各有5个从节点,那么将尝试迁移的从服务器会是:在具有5个从服务器的2个主服务器中 - 具有最低节点ID的服务器。由于没有使用协议,当集群配置不稳定时,可能会出现竞争条件,其中多个从节点认为自己才是具有较低节点ID的非故障从节点(在实践中不太可能发生这种情况) )。如果发生这种情况,结果是多个从节点迁移到同一个主服务器,这是无害的。如果竞争发生的方式会导致主节点没有从节点,则只要集群再次稳定,算法将再次重新执行并将从设备迁移回主节点。

最终每个主节点都将得到至少一个从节点的备份。但是,正常行为是单个从节点从具有多个从节点的主节点迁移到孤立主节点。

该算法可以被一个名为cluster-migration-barrier的可配置参数控制:在从节点迁移之前,必须留下主节点的良好副本数。例如,如果此参数设置为2,则只有在其主服务器还能保留两个工作从节点时,从节点才能尝试迁移。

configEpoch冲突解决算法

在故障转移期间通过从节点晋升创建新的configEpoch值时,它们将保证是唯一的。

但是,在两种不同的情况下,新的configEpoch值以不安全的方式创建,只是递增本地节点的本地currentEpoch并希望同时没有冲突。这两个事件都是系统管理员触发的:

  1. 具有TAKEOVER选项的CLUSTER FAILOVER命令能够手动将从节点提升为主节点,而无需大多数主节点可用。例如,这在多数据中心设置中很有用。

  2. 集群再平衡过程中,迁移哈希槽还会在本地节点内生成新的configEpoch,出于性能原因不需要达成一致。

具体来说,在手动重新分片期间,当哈希槽从节点A迁移到节点B时,重新分片程序将强制B将其配置升级到集群中已知最大的epoch加1(除非节点已经具有最大configEpoch),而不需要来自其他节点的同意。通常,真实世界的重新分片涉及移动数百个散列槽(特别是在小集群中)。对于每个移动的哈希槽,要求在重新分片期间生成新configEpoch的协议是低效的。此外,每次还需要在每个节点中使用fsync来存储新配置。由于它的执行方式,我们只在移动第一个哈希槽时需要一个新的configEpoch,这使得它在生产环境中更加高效。

然而,由于上述两种情况,有可能(尽管不太可能)出现具有相同configEpoch的多个节点。系统管理员执行的重新分片操作以及同时发生的故障转移(加上很多坏运气)如果传播速度不够快,可能会导致currentEpoch冲突。

此外,软件错误和文件系统损坏也可能导致出现具有相同configEpoch的多个节点。

当服务于不同哈希槽的主服务器具有相同的configEpoch时,这不是问题。更重要的是,故障转移主节点的从节点需要有唯一的configEpoch。

也就是说,手动干预或重新分配可能会以不同方式更改群集配置。 Redis集群的活动属性要求哈希槽配置最终收敛,因此在任何情况下我们都希望所有主节点都具有不同的configEpoch。

为了保证此规则,当两个节点出现相同的configEpoch时使用如下冲突解决算法

  • 如果主节点检测到另一个主节点正在使用相同的configEpoch。

  • 并且如果节点具有按字典顺序排序更小的节点ID

  • 然后它将currentEpoch增加1,并将其用作新的configEpoch。

如果有任何一组节点具有相同的configEpoch,那么除节点ID最大的节点之外的所有节点都将向前移动,从而保证每个节点最终将选择一个唯一的configEpoch,而不管发生了什么。

这个机制还保证在创建一个新的集群之后,所有节点都以不同的configEpoch开始(即使实际上没有使用它),因为redis-trib确保在启动时使用CONFIG SET-CONFIG-EPOCH。但是,如果由于某种原因导致节点配置错误,它将自动将其配置更新为不同的configEpoch。

节点重置

节点可以通过软件重置(无需重新启动),以便在不同的角色或不同的集群中重复使用。这在正常操作,测试和云环境中非常有用,在这些环境中,可以重新配置给定节点以加入不同的集合以扩大或创建新集群。

在Redis集群中,使用CLUSTER RESET命令重置节点。该命令有两种变体:

  • CLUSTER RESET SOFT

  • CLUSTER RESET HARD

必须将命令直接发送到节点才能重置。如果未提供重置类型,则执行软重置。

以下是重置的操作步骤:

  1. 如果节点是从节点,则将其转换为主节点,并丢弃其数据集。如果节点是主节点并包含键,则重置操作将中止。

  2. 释放所有哈希槽,重置手工故障切换状态。

  3. 节点表中的所有其他节点都被删除,因此节点不再知道任何其他节点。

  4. currentEpoch,configEpoch和lastVoteEpoch设置为0。

  5. 节点ID更改为新的随机ID。

无法重置具有非空数据集的主节点(因为通常需要将数据重分片到其他节点)。但是,在适当的特殊条件下(例如,当为了创建新集群而完全销毁集群时),必须在继续重置之前执行FLUSHALL

节点移除

通过将其所有数据重新分配给其他节点(如果它是主节点)并将其下线,实际上可以从现有集群中删除节点。但是,其他节点仍将记住其节点ID和地址,并将尝试与其连接。

因此,当删除节点时,我们还希望从所有其他节点表中删除其条目。这是通过使用**CLUSTER FORGET <node-id>**命令完成的。

该命令有两个作用:

  1. 它从节点表中删除具有指定节点ID的节点。

  2. 它设置了60秒禁止,以防止重新添加具有相同节点ID的节点。

第二个操作是必需的,因为Redis Cluster使用Gossip协议来自动发现节点,因此从节点A移除节点X可能导致节点B再次将节点X传播到A节点。由于禁止60秒,Redis集群管理工具有60秒的时间从所有节点中删除节点,避免由于自动发现而重新添加节点。

有关详细信息,请参阅CLUSTER FORGET文档。

发布/订阅

在Redis群集中,客户端可以订阅每个节点,也可以发布到每个其他节点。 集群将确保根据需要转发已发布的消息。

当前的实现将简单地将每个已发布的消息广播到所有其他节点,将来可能使用Bloom过滤器或其他算法优化。

附录

附录A:CRC16的ANSI C参考实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/*
* 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;
}