欢迎阅读 Valkey 集群规范。在这里,您将找到有关 Valkey Cluster 算法和设计原理的信息。本文档正在编写中,并将与 Valkey 的实际实现持续同步。
设计的主要特性和原理
Valkey Cluster 目标
Valkey Cluster 是 Valkey 的分布式实现,其设计目标按重要性排序如下:
- 高性能和高达 1000 个节点的线性可伸缩性。没有代理,使用异步复制,并且不对值执行合并操作。
- 可接受的写入安全性:系统会尽力保留来自连接到大多数主节点的客户端的所有写入。通常会存在一些小窗口,已确认的写入可能会在此期间丢失。当客户端位于少数分区中时,丢失已确认写入的窗口会更大。
- 可用性:Valkey Cluster 能够在一个分区中幸存下来,其中大多数主节点可达,并且每个不再可达的主节点至少有一个可达的副本。此外,通过使用副本迁移,不再有任何副本复制的主节点将从拥有多个副本的主节点那里获得一个副本。
已实现的功能子集
Valkey Cluster 实现了 Valkey 非分布式版本中所有单键命令。针对涉及操作中的所有键都哈希到相同哈希槽的情况,Valkey Cluster 实现了执行复杂多键操作(如集合并集和交集)的命令。
Valkey Cluster 实现了一个称为哈希标签的概念,可用于强制某些键存储在相同的哈希槽中。然而,在手动重新分片期间,多键操作可能会在一段时间内不可用,而单键操作始终可用。
从 9.0 版本开始,Valkey 集群支持多个数据库,类似于独立模式,但有一些额外的限制。
Valkey 集群协议中的客户端和服务器角色
在 Valkey Cluster 中,节点负责保存数据并维护集群状态,包括将键映射到正确的节点。集群节点还能够自动发现其他节点,检测不工作的节点,并在发生故障时根据需要将副本节点提升为主节点以继续运行。
为了执行其任务,所有集群节点都通过 TCP 总线和二进制协议(称为 Valkey 集群总线)连接。集群中的每个节点都通过集群总线连接到所有其他节点。节点使用谣言协议(gossip protocol)传播有关集群的信息,以便发现新节点,发送 ping 包以确保所有其他节点正常工作,以及发送通知特定条件所需的集群消息。集群总线还用于在集群中传播发布/订阅消息,并在用户请求时协调手动故障转移(手动故障转移不是由 Valkey Cluster 故障检测器发起,而是由系统管理员直接发起)。
由于集群节点无法代理请求,客户端可能会通过重定向错误 -MOVED
和 -ASK
被重定向到其他节点。理论上,客户端可以自由地向集群中的所有节点发送请求,并在需要时获得重定向,因此客户端不需要持有集群状态。但是,能够缓存键与节点之间映射的客户端可以显著提高性能。
写入安全性
Valkey Cluster 使用节点间的异步复制和最后故障转移胜出的隐式合并函数。这意味着最后选举出来的主数据集最终会替换所有其他副本。在分区期间,总是存在丢失写入的可能性窗口。然而,当客户端连接到大多数主节点时,与连接到少数主节点时相比,这些窗口有很大不同。
Valkey Cluster 更努力地保留由连接到大多数主节点的客户端执行的写入,而不是少数侧的写入。以下是导致在故障期间丢失在大多数分区中接收到的已确认写入的场景示例:
-
写入可能到达主节点,但当主节点能够回复客户端时,写入可能尚未通过主节点和副本节点之间使用的异步复制传播到副本。如果主节点在写入未到达副本的情况下宕机,并且主节点在足够长的时间内无法访问导致其某个副本被提升,那么该写入将永久丢失。在主节点完全突然故障的情况下,这通常很难观察到,因为主节点会尝试同时回复客户端(确认写入)和副本(传播写入)。然而,这是一种现实世界中的故障模式。
-
另一种理论上可能导致写入丢失的故障模式如下:
- 主节点由于分区而无法访问。
- 它被其某个副本故障转移。
- 一段时间后,它可能再次可达。
- 具有过期路由表的客户端可能会在旧主节点被集群转换为(新主节点的)副本之前,向其写入数据。
第二种故障模式不太可能发生,因为主节点在足够长的时间内无法与大多数其他主节点通信以进行故障转移时,将不再接受写入,并且当分区修复后,在短时间内仍拒绝写入,以允许其他节点通知配置更改。这种故障模式还需要客户端的路由表尚未更新。
针对分区少数侧的写入有更大的丢失窗口。例如,Valkey Cluster 在存在少数主节点和至少一个或多个客户端的分区中会丢失相当数量的写入,因为所有发送到这些主节点的写入都可能在主节点在大多数侧被故障转移时丢失。
具体来说,一个主节点要被故障转移,它必须在至少 NODE_TIMEOUT
的时间内无法被大多数主节点访问,因此如果分区在该时间之前修复,则不会丢失任何写入。当分区持续时间超过 NODE_TIMEOUT
时,在那之前在少数侧执行的所有写入都可能丢失。然而,Valkey Cluster 的少数侧在 NODE_TIMEOUT
时间过去且未与大多数侧联系后,将立即开始拒绝写入,因此存在一个最大窗口,在此之后少数侧将不再可用。因此,在该时间之后,不再接受或丢失任何写入。
可用性
Valkey Cluster 在分区的少数侧不可用。在分区的多数侧,假设至少有大多数主节点和每个不可达主节点的一个副本,集群将在 NODE_TIMEOUT
时间加上副本被选举并对其主节点执行故障转移所需的几秒钟后再次可用(故障转移通常在 1 或 2 秒内执行)。
这意味着 Valkey Cluster 旨在应对集群中少数节点故障,但它不适用于需要在大规模网络分裂事件中保持可用性的应用程序。
在一个由 N 个主节点组成且每个节点带有一个副本的集群示例中,集群的多数侧将保持可用,只要单个节点被分区隔离;当两个节点被分区隔离时,其可用性概率为 1-(1/(N*2-1))
(在第一个节点失败后,总共有 N*2-1
个节点,而唯一没有副本的主节点失败的概率是 1/(N*2-1)
)。
例如,在一个有 5 个节点且每个节点带有一个副本的集群中,当两个节点从多数侧被分区隔离后,集群不再可用的概率是 1/(5*2-1) = 11.11%
。
由于 Valkey Cluster 的一个特性叫做副本迁移,集群可用性在许多现实场景中得到了改善,因为副本会迁移到孤立的主节点(不再有副本的主节点)。因此,在每次成功的故障事件后,集群可能会重新配置副本布局,以更好地抵抗下一次故障。
性能
在 Valkey Cluster 中,节点不代理命令到负责给定键的正确节点,而是将客户端重定向到服务键空间给定部分的正确节点。
最终,客户端会获得集群的最新表示以及哪个节点服务哪些键子集,因此在正常操作期间,客户端直接联系正确的节点以发送给定命令。
由于使用异步复制,节点不会等待其他节点对写入的确认(除非通过 WAIT
命令显式请求)。
此外,由于多键命令仅限于相邻键,因此除了重新分片时,数据从不会在节点之间移动。
正常操作的处理方式与单个 Valkey 实例完全相同。这意味着在具有 N 个主节点的 Valkey Cluster 中,您可以期望获得与单个 Valkey 实例乘以 N 的相同性能,因为设计是线性扩展的。同时,查询通常在一次往返中完成,因为客户端通常与节点保持持久连接,因此延迟数据也与单个独立 Valkey 节点的情况相同。
在保持弱但合理的写入安全性和可用性的同时实现极高性能和可伸缩性是 Valkey Cluster 的主要目标。
为什么避免合并操作
Valkey Cluster 设计避免了在多个节点中存在相同键值对的冲突版本,因为在 Valkey 数据模型中这并不总是可取的。Valkey 中的值通常非常大;常见的是包含数百万个元素的列表或有序集合。此外,数据类型在语义上也很复杂。传输和合并这类值可能会成为主要瓶颈和/或可能需要应用程序端逻辑的大量参与、存储元数据的额外内存等等。
这里没有严格的技术限制。CRDT 或同步复制状态机可以建模类似于 Valkey 的复杂数据类型。然而,这些系统的实际运行行为不会与 Valkey Cluster 相似。Valkey Cluster 的设计旨在涵盖非集群 Valkey 部署的精确用例。
Valkey Cluster 主要组件概述
键分布模型
集群的键空间被分割成 16384 个槽,有效地将集群大小的上限设置为 16384 个主节点(然而,建议的最大节点数约为 1000 个节点)。
集群中的每个主节点处理 16384 个哈希槽的子集。当没有集群重新配置正在进行时(即哈希槽没有从一个节点移动到另一个节点),集群是稳定的。当集群稳定时,单个哈希槽将由单个节点服务(然而,服务节点可以有一个或多个副本,这些副本将在网络分裂或故障时替换它,并且可以用于扩展读操作,只要接受读取过时数据)。
用于将键映射到哈希槽的基本算法如下(有关此规则的哈希标签例外情况,请阅读下一段):
HASH_SLOT = CRC16(key) mod 16384
CRC16 定义如下:
- 名称:XMODEM(也称为 ZMODEM 或 CRC-16/ACORN)
- 宽度:16 位
- 多项式:1021(实际为 x^16 + x^12 + x^5 + 1)
- 初始化:0000
- 反转输入字节:否
- 反转输出 CRC:否
- 异或常数到输出 CRC:0000
- “123456789”的输出:31C3
CRC16 输出的 16 位中有 14 位被使用(这就是为什么上面的公式中有一个模 16384 操作)。
在我们的测试中,CRC16 在将不同类型的键均匀分布到 16384 个槽中表现出色。
注意:本文档附录 A 中提供了所使用的 CRC16 算法的参考实现。
哈希标签
哈希槽的计算有一个例外,用于实现哈希标签。哈希标签是一种确保多个键分配到相同哈希槽的方式。这用于在 Valkey Cluster 中实现多键操作。
为了实现哈希标签,在某些条件下,键的哈希槽计算方式略有不同。如果键包含 "{...}" 模式,则只有 {
和 }
之间的子字符串被哈希以获得哈希槽。然而,由于可能存在多个 {
或 }
,算法通过以下规则明确定义:
- 如果键包含
{
字符。 - 并且如果
{
的右侧有}
字符。 - 并且如果第一个
{
出现和第一个}
出现之间有一个或多个字符。
那么,不是哈希整个键,而是只哈希第一个 {
出现和其后第一个 }
出现之间的内容。
示例
- 键
{user1000}.following
和{user1000}.followers
将哈希到相同的哈希槽,因为只有子字符串user1000
会被哈希以计算哈希槽。 - 对于键
foo{}{bar}
,整个键将像往常一样被哈希,因为第一个{
后面紧跟着}
且中间没有字符。 - 对于键
foo{{bar}}zap
,子字符串{bar
将被哈希,因为它是第一个{
和其右侧第一个}
之间的子字符串。 - 对于键
foo{bar}{zap}
,子字符串bar
将被哈希,因为算法在第一个有效或无效(中间没有字节)的{
和}
匹配处停止。 - 从算法中可以得出,如果键以
{}
开头,则保证整个键都会被哈希。这在将二进制数据用作键名时非常有用。
Glob 风格模式
接受 glob 风格模式的命令,包括 KEYS
、SCAN
和 SORT
,针对暗示单个槽位的模式进行了优化。这意味着,如果所有能匹配模式的键都必须属于特定槽位,则只会在该槽位中搜索匹配模式的键。模式槽位优化是在 Valkey 8.0 中引入的。
当模式满足以下条件时,优化会生效:
- 模式包含哈希标签,
- 哈希标签之前没有通配符或转义字符,并且
- 花括号内的哈希标签不包含任何通配符或转义字符。
例如,SCAN 0 MATCH {abc}*
可以成功识别哈希标签并仅扫描对应于 abc
的槽位。然而,模式 *{abc}
、{a*c}
或 {a\*bc}
无法识别哈希标签,因此需要扫描所有槽位。
哈希槽示例代码
加上哈希标签的例外情况,以下是 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;
}
集群节点属性
集群中的每个节点都有一个唯一的名称。节点名称是 160 位随机数的十六进制表示,该随机数在节点首次启动时获得(通常使用 /dev/urandom)。节点会将其 ID 保存在节点配置文件中,并永久使用该 ID,或者至少在系统管理员未删除节点配置文件或通过 CLUSTER RESET
命令请求硬重置之前。
节点 ID 用于在整个集群中标识每个节点。给定节点可以更改其 IP 地址,而无需更改节点 ID。集群还能够检测 IP/端口的变化,并使用在集群总线上运行的谣言协议进行重新配置。
节点 ID 并非与每个节点关联的唯一信息,但它是唯一始终全局一致的信息。每个节点还关联有以下一组信息。有些信息是关于此特定节点的集群配置细节,并在集群中最终保持一致。另一些信息,例如上次 ping 节点的时间,则是每个节点本地的。
每个节点都会维护其所知道的集群中其他节点的以下信息:节点 ID、节点的 IP 和端口、一组标志、如果被标记为 replica
则是该节点的主节点、上次 ping 节点的时间和上次收到 pong 的时间、节点的当前配置纪元(本规范后面会详细解释)、链接状态以及服务的哈希槽集。
有关所有节点字段的详细解释在 CLUSTER NODES
文档中有所描述。
CLUSTER NODES
命令可以发送到集群中的任何节点,并根据被查询节点对集群的本地视图提供集群状态和每个节点的信息。
以下是发送到由三个节点组成的小集群中一个主节点的 CLUSTER NODES
命令的示例输出。
$ valkey-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
在上述列表中,不同字段的顺序是:节点 ID、地址:端口、标志、上次发送 ping、上次收到 pong、配置纪元、链接状态、槽位。关于上述字段的详细信息将在我们讨论 Valkey Cluster 的特定部分时介绍。
集群总线
每个 Valkey Cluster 节点都有一个额外的 TCP 端口,用于接收来自其他 Valkey Cluster 节点的传入连接。此端口将通过在数据端口上加上 10000 获得,或者可以通过 cluster-port
配置指定。
示例 1
如果 Valkey 节点监听客户端连接的端口是 6379,并且您没有在 valkey.conf 中添加 cluster-port 参数,那么集群总线端口 16379 将被打开。
示例 2
如果 Valkey 节点监听客户端连接的端口是 6379,并且您在 valkey.conf 中设置 cluster-port 20000
,那么集群总线端口 20000 将被打开。
节点间通信完全使用集群总线和集群总线协议:一个由不同类型和大小的帧组成的二进制协议。集群总线二进制协议未公开文档化,因为它不适用于外部软件设备使用此协议与 Valkey Cluster 节点通信。但是,您可以通过阅读 Valkey Cluster 源代码中的 cluster.h
和 cluster.c
文件来获取有关集群总线协议的更多详细信息。
集群拓扑
Valkey Cluster 是一个全连接网络(full mesh),其中每个节点都通过 TCP 连接与其他所有节点连接。
在 N 个节点的集群中,每个节点有 N-1 个出站 TCP 连接和 N-1 个入站连接。
这些 TCP 连接始终保持活动状态,而不是按需创建。当一个节点在集群总线中期望一个 pong 回复以响应 ping 时,在等待足够长的时间以将节点标记为不可达之前,它会尝试通过从头开始重新连接来刷新与该节点的连接。
虽然 Valkey Cluster 节点形成一个全连接网络,但节点使用谣言协议和配置更新机制,以避免在正常情况下节点之间交换过多的消息,因此交换的消息数量不是指数级的。
节点握手
节点总是接受集群总线端口上的连接,甚至在收到 ping 时也会回复,即使 ping 的节点不受信任。然而,如果发送节点不被认为是集群的一部分,接收节点将丢弃所有其他数据包。
一个节点只通过两种方式接受另一个节点作为集群的一部分:
-
如果一个节点通过
MEET
消息(CLUSTER MEET
命令)介绍自己。MEET
消息与PING
消息完全相同,但强制接收者接受该节点作为集群的一部分。节点只有在系统管理员通过CLUSTER MEET ip port
请求时,才会向其他节点发送MEET
消息。 -
如果一个已受信任的节点散布关于另一个节点的谣言,该节点也会将另一个节点注册为集群的一部分。因此,如果 A 知道 B,并且 B 知道 C,最终 B 会向 A 发送关于 C 的谣言消息。当这种情况发生时,A 会将 C 注册为网络的一部分,并尝试与 C 连接。
这意味着只要我们将节点连接成任何连通图,它们最终都会自动形成一个完全连接图。这意味着集群能够自动发现其他节点,但前提是存在由系统管理员强制建立的信任关系。
这种机制使集群更加健壮,但防止了不同 Valkey 集群在 IP 地址更改或其他网络相关事件后意外混合。
重定向和重新分片
MOVED 重定向
Valkey 客户端可以自由地向集群中的任何节点(包括副本节点)发送查询。节点将分析查询,如果查询可接受(即,查询中只提及一个键,或者提及的多个键都属于同一个哈希槽),它将查找负责键或键所属哈希槽的节点。
如果该哈希槽由当前节点服务,查询将直接处理;否则,节点将检查其内部哈希槽到节点的映射,并回复客户端一个 MOVED 错误,示例如下:
GET x
-MOVED 3999 127.0.0.1:6381
错误信息中包含键的哈希槽(3999)和可以服务该查询的实例的端点:端口。客户端需要将查询重新发送到指定的节点端点地址和端口。端点可以是 IP 地址、主机名,也可以为空(例如 -MOVED 3999 :6380
)。空端点表示服务器节点具有未知端点,客户端应将下一个请求发送到与当前请求相同的端点,但使用提供的端口。
请注意,即使客户端等待很长时间才重新发出查询,并且在此期间集群配置发生更改,如果哈希槽 3999 现在由另一个节点服务,目标节点仍将回复 MOVED 错误。如果被联系的节点没有更新的信息,也会发生同样的情况。
因此,尽管从集群节点的角度来看,节点是通过 ID 标识的,但我们试图通过仅公开哈希槽与由端点:端口对标识的 Valkey 节点之间的映射来简化我们与客户端的接口。
客户端不是必须这样做,但应该尝试记住哈希槽 3999 由 127.0.0.1:6381 服务。这样,一旦需要发出新命令,它就可以计算目标键的哈希槽,并有更大的机会选择正确的节点。
另一种方法是在收到 MOVED 重定向时,使用 CLUSTER SHARDS
或已废弃的 CLUSTER SLOTS
命令,直接刷新整个客户端的集群布局。当遇到重定向时,很可能多个槽位被重新配置,而不仅仅是一个,因此尽快更新客户端配置通常是最佳策略。
请注意,当集群稳定时(配置没有正在进行的更改),所有客户端最终都将获得哈希槽 -> 节点的映射,从而使集群高效运行,客户端可以直接寻址正确的节点,而无需重定向、代理或其他单点故障实体。
客户端还必须能够处理本文件后面描述的 -ASK 重定向,否则它就不是一个完整的 Valkey Cluster 客户端。
在线重新分片
Valkey Cluster 支持在集群运行时添加和删除节点。添加或删除节点被抽象为同一操作:将哈希槽从一个节点移动到另一个节点。这意味着可以使用相同的基本机制来重新平衡集群、添加或删除节点等。
- 要向集群添加新节点,需将一个空节点添加到集群中,并将一些哈希槽从现有节点移动到新节点。
- 要从集群中删除节点,分配给该节点的哈希槽将被移动到其他现有节点。
- 要重新平衡集群,在节点之间移动给定的一组哈希槽。
实现的核心是能够移动哈希槽。从实际角度来看,哈希槽只是一组键,因此 Valkey Cluster 在重新分片期间真正做的是将键从一个实例移动到另一个实例。移动一个哈希槽意味着移动所有恰好哈希到此哈希槽的键。
要理解这是如何工作的,我们需要展示用于操作 Valkey Cluster 节点中槽位转换表的 CLUSTER
子命令。
以下子命令可用(此情况中其他不相关的命令除外):
CLUSTER ADDSLOTS
slot1 [slot2] ... [slotN]CLUSTER DELSLOTS
slot1 [slot2] ... [slotN]CLUSTER ADDSLOTSRANGE
start-slot1 end-slot1 [start-slot2 end-slot2] ... [start-slotN end-slotN]CLUSTER DELSLOTSRANGE
start-slot1 end-slot1 [start-slot2 end-slot2] ... [start-slotN end-slotN]CLUSTER SETSLOT
slot NODE nodeCLUSTER SETSLOT
slot MIGRATING nodeCLUSTER SETSLOT
slot IMPORTING node
前四个命令,ADDSLOTS
、DELSLOTS
、ADDSLOTSRANGE
和 DELSLOTSRANGE
,简单地用于将槽位分配(或移除)给 Valkey 节点。分配槽位意味着告诉给定的主节点,它将负责存储和提供指定哈希槽的内容。
哈希槽分配后,它们将通过谣言协议在集群中传播,如后面配置传播部分所述。
ADDSLOTS
和 ADDSLOTSRANGE
命令通常在新集群从头开始创建时使用,为每个主节点分配所有 16384 个可用哈希槽的子集。
DELSLOTS
和 DELSLOTSRANGE
主要用于手动修改集群配置或调试任务:实际上很少使用。
如果使用 SETSLOT
形式,SETSLOT
子命令用于将槽分配给特定的节点 ID。否则,槽可以设置为两种特殊状态:MIGRATING
和 IMPORTING
。这两种特殊状态用于将哈希槽从一个节点迁移到另一个节点。
- 当一个槽被设置为 MIGRATING 时,节点将接受所有关于此哈希槽的查询,但前提是所查询的键存在,否则查询将通过
-ASK
重定向转发到迁移的目标节点。 - 当一个槽被设置为 IMPORTING 时,节点将接受所有关于此哈希槽的查询,但前提是请求前面带有
ASKING
命令。如果客户端没有发送ASKING
命令,查询将通过-MOVED
重定向错误重定向到真正的哈希槽所有者,就像通常发生的那样。
我们通过一个哈希槽迁移的例子来进一步说明。假设我们有两个 Valkey 主节点,分别称为 A 和 B。我们想将哈希槽 8 从 A 移动到 B,因此我们发出如下命令:
- 我们向 B 发送:CLUSTER SETSLOT 8 IMPORTING A
- 我们向 A 发送:CLUSTER SETSLOT 8 MIGRATING B
所有其他节点将继续将客户端指向节点“A”,每当查询到属于哈希槽 8 的键时,因此会发生以下情况:
- 所有关于现有键的查询都由“A”处理。
- 所有关于“A”中不存在键的查询都由“B”处理,因为“A”会将客户端重定向到“B”。
通过这种方式,我们不再在“A”中创建新键。同时,在重新分片和 Valkey Cluster 配置期间使用的 valkey-cli
将把哈希槽 8 中现有的键从 A 迁移到 B。这通过以下命令完成:
CLUSTER GETKEYSINSLOT slot count
上述命令将返回指定哈希槽中的 count
个键。对于返回的键,valkey-cli
会向节点“A”发送 MIGRATE
命令,该命令将以原子方式将指定键从 A 迁移到 B(两个实例都会被锁定,所需时间通常很短,因此没有竞态条件)。MIGRATE
的工作原理如下:
MIGRATE target_host target_port "" target_database id timeout KEYS key1 key2 ...
MIGRATE
将连接到目标实例,发送键的序列化版本,一旦收到 OK 代码,将从自身数据集中删除旧键。从外部客户端的角度来看,一个键在任何给定时间都只存在于 A 或 B 中。
在 Valkey Cluster 中,无需指定除 0 以外的数据库,但 MIGRATE
是一个通用命令,可用于不涉及 Valkey Cluster 的其他任务。MIGRATE
经过优化,即使在移动长列表等复杂键时也能尽可能快,但在 Valkey Cluster 中,如果使用数据库的应用程序有延迟限制,重新配置包含大键的集群则不被认为是明智的做法。
当迁移过程最终完成后,SETSLOT
命令会发送到参与迁移的两个节点,以便将槽位重新设置为正常状态。同样的命令通常会发送到所有其他节点,以避免等待新配置在集群中自然传播。
CLUSTER SETSLOT
命令的复制
从 Valkey 8.0 开始,如果副本运行的是 Valkey 8.0+ 版本,CLUSTER SETSLOT
命令会被复制。主节点默认等待最多 2 秒,以等待所有健康的副本确认复制。如果在此时间内并非所有健康的副本都确认复制,主节点将中止该命令,客户端将收到 NOREPLICAS Not enough good replicas to write
错误。操作员可以重试该命令或使用 TIMEOUT
参数自定义超时时间,以进一步提高在线重新分片的可靠性。
CLUSTER SETSLOT slot [MIGRATING|IMPORTING|NODE] node-id [TIMEOUT timeout]
timeout
以秒为单位指定,值为 0 表示无限等待时间。
复制槽信息并确保健康的副本确认显著降低了主节点在执行命令后丢失复制状态的可能性。例如,考虑一个场景,目标主节点 B
正在完成槽迁移。在 SETSLOT
命令复制到其副本节点 B'
之前,B
可能会向源主节点 A
发送集群 PONG
消息,促使 A
放弃其对相关槽的所有权。如果 B
在此之后立即崩溃,其副本节点 B'
(可能被选举为新的主节点)将不知道槽所有权的转移,如果没有成功复制 SETSLOT
命令。这将导致该槽没有所有者,从而可能导致数据丢失和集群拓扑不一致。
空分片中的选举
从 Valkey 8.0 开始,Valkey 集群引入了在空分片中选举主节点的能力。此行为确保即使分片正在接收其第一个槽,也可以选举出一个主节点。这可以防止在空分片中没有可用的主节点来处理来自官方槽所有者的重定向请求的场景,从而在实时重新分片期间保持可用性。
ASK 重定向
在上一节中,我们简要讨论了 ASK 重定向。为什么我们不能简单地使用 MOVED 重定向?因为 MOVED 意味着我们认为哈希槽被永久地由另一个节点服务,并且下一个查询应该针对指定的节点尝试。而 ASK 意味着只将下一个查询发送到指定的节点。
这是必要的,因为关于哈希槽 8 的下一个查询可能仍是关于 A 中的键,所以我们总是希望客户端先尝试 A,如果需要再尝试 B。由于这种情况只发生在 16384 个可用哈希槽中的一个,因此对集群的性能影响是可以接受的。
我们需要强制客户端的这种行为,所以为了确保客户端只在尝试 A 之后才尝试节点 B,节点 B 只在客户端发送 ASKING
命令后才接受关于设置为 IMPORTING 的槽的查询。
基本上,ASKING
命令在客户端上设置一个一次性标志,强制节点服务关于 IMPORTING 槽的查询。
从客户端角度来看,ASK 重定向的完整语义如下:
- 如果收到 ASK 重定向,只将重定向的查询发送到指定节点,但后续查询继续发送到旧节点。
- 以
ASKING
命令开始重定向的查询。 - 暂不更新本地客户端表以将哈希槽 8 映射到 B。
一旦哈希槽 8 迁移完成,A 将发送一个 MOVED 消息,客户端可以永久地将哈希槽 8 映射到新的端点和端口对。请注意,如果一个有缺陷的客户端提前进行映射,这不是问题,因为它在发出查询之前不会发送 ASKING
命令,因此 B 将使用 MOVED 重定向错误将客户端重定向到 A。
在 CLUSTER SETSLOT
命令文档中,槽迁移以相似的术语但不同的措辞(为了文档的冗余性)进行了解释。
从 Valkey 8.0 开始,当源分片或目标分片中的主节点在实时重新分片期间发生故障时,另一分片中的主节点将自动尝试更新其迁移/导入状态,以与新选举出的主节点正确配对。如果此更新成功,ASK 重定向将继续运行,无需管理员干预。如果槽迁移失败,管理员可以通过运行 valkey-cli --cluster fix
命令手动恢复中断的槽迁移。
此外,自 Valkey 8.0 起,副本现在能够在槽迁移期间返回 ASK
重定向。此功能以前不可用,因为早期版本中的副本不知道正在进行的槽迁移。请参阅 READONLY 命令。
客户端连接和重定向处理
为了高效,Valkey Cluster 客户端维护当前槽配置的映射。然而,此配置不要求是最新状态。当联系错误的节点导致重定向时,客户端可以相应地更新其内部槽映射。
客户端通常需要在两种不同情况下获取槽位和映射节点地址的完整列表:
- 启动时,用于填充初始槽位配置
- 当客户端收到
MOVED
重定向时
请注意,客户端可以通过仅更新其表中的移动槽来处理 MOVED
重定向;然而,这通常效率不高,因为通常会同时修改多个槽的配置(例如,如果一个副本被提升为主节点,则旧主节点服务的所有槽都将被重新映射)。更简单的方法是收到 MOVED
重定向后,从头开始获取完整的槽到节点的映射。
客户端可以发出 CLUSTER SLOTS
命令来检索槽范围数组以及服务指定范围的关联主节点和副本节点。
以下是 CLUSTER SLOTS
的输出示例:
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 个槽的范围,因此客户端应通过用 NULL 对象填充目标节点来初始化槽配置映射,并在用户尝试执行关于属于未分配槽的键的命令时报告错误。
在发现槽未分配时向调用方返回错误之前,客户端应尝试再次获取槽配置,以检查集群现在是否已正确配置。
多键操作
使用哈希标签,客户端可以自由使用多键操作。例如,以下操作是有效的:
MSET {user:1000}.name Angela {user:1000}.surname White
当键所属的哈希槽正在重新分片时,多键操作可能会变为不可用。
更具体地说,即使在重新分片期间,针对所有键都存在且都仍然哈希到同一槽(无论是源节点还是目标节点)的多键操作仍然可用。
针对不存在的键或在重新分片期间分散在源节点和目标节点之间的键的操作,将生成 -TRYAGAIN
错误。客户端可以等待一段时间后重试操作,或报告错误。
一旦指定哈希槽的迁移终止,所有多键操作将再次对该哈希槽可用。
使用副本节点扩展读操作
通常,副本节点会将客户端重定向到给定命令中涉及的哈希槽的权威主节点,但客户端可以使用 READONLY
命令来利用副本扩展读操作。
READONLY
告诉 Valkey Cluster 副本节点,客户端可以读取可能过时的数据,并且不打算运行写入查询。
当连接处于只读模式时,集群只会在操作涉及非副本主节点服务的键时向客户端发送重定向。这可能发生在以下情况:
- 客户端发送了关于副本主节点从未服务过的哈希槽的命令。
- 集群被重新配置(例如重新分片),副本不再能为给定哈希槽提供服务。
当这种情况发生时,客户端应按前几节所述更新其哈希槽映射。
可以使用 READWRITE
命令清除连接的只读状态。
容错
心跳和谣言消息
Valkey Cluster 节点持续交换 ping 和 pong 数据包。这两种数据包具有相同的结构,并且都携带重要的配置信息。唯一的实际区别是消息类型字段。我们将 ping 和 pong 数据包的总和称为心跳数据包。
通常节点会发送 ping 数据包,这将触发接收者回复 pong 数据包。然而,这并非必然如此。节点也可以只发送 pong 数据包,以便向其他节点发送关于其配置的信息,而无需触发回复。这对于尽快广播新配置等情况很有用。
通常,一个节点每秒会 ping 几个随机节点,因此每个节点发送的 ping 数据包(和接收到的 pong 数据包)总数是一个常数,无论集群中的节点数量如何。
然而,每个节点都会确保 ping 每个在超过 NODE_TIMEOUT
时间一半后没有发送 ping 或接收 pong 的其他节点。在 NODE_TIMEOUT
时间过去之前,节点还会尝试重新连接与其他节点的 TCP 链接,以确保节点不会仅仅因为当前 TCP 连接存在问题而被认为是不可达的。
如果 NODE_TIMEOUT
设置得很小且节点数量(N)非常大,则全局交换的消息数量可能很大,因为每个节点都会尝试每隔 NODE_TIMEOUT
时间的一半就 ping 所有没有新鲜信息的其他节点。
例如,在一个有 100 个节点且节点超时设置为 60 秒的集群中,每个节点每 30 秒将尝试发送 99 个 ping,总共每秒发送 3.3 个 ping。乘以 100 个节点,整个集群每秒有 330 个 ping。
有方法可以降低消息数量,但目前 Valkey Cluster 故障检测所使用的带宽尚未报告任何问题,因此目前采用了显而易见且直接的设计。请注意,即使在上述示例中,每秒交换的 330 个数据包也均匀分布在 100 个不同节点之间,因此每个节点接收的流量是可以接受的。
心跳包内容
Ping 和 pong 数据包包含所有类型数据包通用的头部(例如请求故障转移投票的数据包),以及特定于 Ping 和 Pong 数据包的特殊谣言部分。
通用头部包含以下信息:
- 节点 ID,一个 160 位的伪随机字符串,在节点首次创建时分配,并在 Valkey Cluster 节点的整个生命周期中保持不变。
- 发送节点的
currentEpoch
和configEpoch
字段,用于组建 Valkey Cluster 使用的分布式算法(这将在下一节详细解释)。如果节点是副本,则configEpoch
是其主节点的最后一个已知configEpoch
。 - 节点标志,指示节点是副本、主节点,以及其他单比特的节点信息。
- 发送节点所服务的哈希槽的位图,如果节点是副本,则是其主节点所服务的槽的位图。
- 发送方的 TCP 基本端口,即 Valkey 用于接受客户端命令的端口。
- 集群端口,即 Valkey 用于节点间通信的端口。
- 从发送方角度来看的集群状态(down 或 ok)。
- 如果发送节点是副本,则是其主节点的节点 ID。
Ping 和 pong 数据包还包含一个谣言部分。这部分向接收方展示了发送节点对集群中其他节点的看法。谣言部分只包含发送方已知节点集合中少数随机节点的信息。谣言部分中提及的节点数量与集群大小成比例。
对于谣言部分中添加的每个节点,报告以下字段:
- 节点 ID。
- 节点的 IP 和端口。
- 节点标志。
谣言部分允许接收节点从发送节点的角度获取其他节点的状态信息。这对于故障检测和发现集群中的其他节点都很有用。
故障检测
Valkey Cluster 故障检测用于识别当主节点或副本节点无法被大多数节点访问时,并通过将副本提升为主节点来响应。当副本无法提升时,集群将进入错误状态,停止接收来自客户端的查询。
如前所述,每个节点都维护一个与其他已知节点相关联的标志列表。有两个用于故障检测的标志,分别称为 PFAIL
和 FAIL
。PFAIL
表示潜在故障,是一种未确认的故障类型。FAIL
表示节点正在发生故障,并且此条件已在固定时间内由大多数主节点确认。
PFAIL 标志
当节点在超过 NODE_TIMEOUT
时间后仍不可访问时,另一个节点会将其标记为 PFAIL
。主节点和副本节点都可以将另一个节点标记为 PFAIL
,无论其类型如何。
对于 Valkey Cluster 节点来说,不可达的概念是,我们有一个活跃的 ping(我们发送的 ping,但尚未收到回复)待处理的时间超过了 NODE_TIMEOUT
。为了使此机制正常工作,NODE_TIMEOUT
必须相对网络往返时间而言足够大。为了在正常操作期间增加可靠性,节点将在半个 NODE_TIMEOUT
时间过去而未收到 ping 回复时,立即尝试重新连接集群中的其他节点。这种机制确保连接保持活动状态,因此断开的连接通常不会导致节点之间出现错误的故障报告。
FAIL 标志
单独的 PFAIL
标志只是每个节点关于其他节点的本地信息,不足以触发副本提升。要使一个节点被认为是宕机的,PFAIL
条件需要升级为 FAIL
条件。
正如本文档节点心跳部分所述,每个节点都会向所有其他节点发送谣言消息,其中包括一些随机已知节点的状态。每个节点最终会收到关于其他每个节点的一组节点标志。通过这种方式,每个节点都有一个机制来向其他节点发出它们检测到的故障条件信号。
当满足以下条件时,PFAIL
条件会升级为 FAIL
条件:
- 某个节点(我们称之为 A)将另一个节点 B 标记为
PFAIL
。 - 节点 A 通过谣言部分,从集群中大多数主节点的角度收集了关于 B 状态的信息。
- 大多数主节点在
NODE_TIMEOUT * FAIL_REPORT_VALIDITY_MULT
时间内(在当前实现中,有效性因子设置为 2,所以这只是NODE_TIMEOUT
时间的两倍)发出了PFAIL
或FAIL
条件信号。
如果所有上述条件都为真,节点 A 将:
- 将节点标记为
FAIL
。 - 向所有可达节点发送
FAIL
消息(与心跳消息中的FAIL
条件不同)。
FAIL
消息将强制每个接收节点将该节点标记为 FAIL
状态,无论它是否已将该节点标记为 PFAIL
状态。
请注意,FAIL 标志大部分是单向的。也就是说,节点可以从 PFAIL
变为 FAIL
,但 FAIL
标志只能在以下情况清除:
- 节点已可达且是副本。在这种情况下,
FAIL
标志可以清除,因为副本不会发生故障转移。 - 节点已可达且是未服务任何槽的主节点。在这种情况下,
FAIL
标志可以清除,因为没有槽的主节点实际上不参与集群,正在等待配置以加入集群。 - 节点已可达且是主节点,但经过了很长时间(N 倍
NODE_TIMEOUT
)而没有检测到任何副本提升。在这种情况下,它最好重新加入集群并继续运行。
值得注意的是,虽然 PFAIL
-> FAIL
的转换使用了一种形式的协议,但所使用的协议是弱的:
- 节点在一段时间内收集其他节点的视图,因此即使大多数主节点需要“同意”,实际上这只是我们从不同节点在不同时间收集到的状态,我们不确定,也不要求在给定时刻大多数主节点都已同意。然而,我们会丢弃过时的故障报告,因此故障是在一个时间窗口内由大多数主节点发出的信号。
- 虽然每个检测到
FAIL
条件的节点将通过FAIL
消息强制集群中其他节点接受该条件,但无法确保该消息会到达所有节点。例如,一个节点可能检测到FAIL
条件,但由于分区而无法到达任何其他节点。
然而,Valkey Cluster 故障检测有一个活性要求:最终所有节点都应该就给定节点的状态达成一致。有两种情况可能源自脑裂条件。要么少数节点认为节点处于 FAIL
状态,要么少数节点认为节点不处于 FAIL
状态。在这两种情况下,集群最终都会对给定节点的状态有一个统一的视图:
情况 1:如果大多数主节点已将节点标记为 FAIL
,由于故障检测及其产生的链式效应,最终所有其他节点都将把该主节点标记为 FAIL
,因为在指定的时间窗口内将报告足够的故障。
情况 2:当只有少数主节点将节点标记为 FAIL
时,副本提升将不会发生(因为它使用更正式的算法,确保每个人最终都知道提升),并且每个节点将根据上述 FAIL
状态清除规则清除 FAIL
状态(即,在 NODE_TIMEOUT
的 N 倍时间过去后,如果没有提升)。
FAIL
标志仅用作触发算法安全部分运行的机制,用于副本提升。理论上,副本可以独立行动,在主节点不可达时启动副本提升,并等待主节点在主节点实际上可被多数访问时拒绝提供确认。然而,PFAIL -> FAIL
状态的复杂性增加、弱协议以及 FAIL
消息在集群可达部分以最短时间强制传播状态,都具有实际优势。由于这些机制,通常所有节点将在集群处于错误状态时大约同时停止接受写入。从使用 Valkey Cluster 的应用程序的角度来看,这是一个理想的特性。此外,还避免了由于本地问题(主节点可被大多数其他主节点访问)导致副本无法到达其主节点而发起的错误选举尝试。
配置处理、传播和故障转移
集群当前纪元
Valkey Cluster 使用与 Raft 算法“term”相似的概念。在 Valkey Cluster 中,这个术语被称为“epoch”(纪元),它用于为事件提供增量版本控制。当多个节点提供冲突信息时,其他节点就可以理解哪个状态是最新状态。
currentEpoch
是一个 64 位无符号数。
在节点创建时,每个 Valkey Cluster 节点(包括副本和主节点)都将 currentEpoch
设置为 0。
每当从另一个节点接收到数据包时,如果发送者的纪元(集群总线消息头的一部分)大于本地节点纪元,则 currentEpoch
会更新为发送者的纪元。
由于这些语义,最终所有节点都将就集群中最大的 currentEpoch
达成一致。
当集群状态改变且节点寻求协议以执行某些操作时,此信息会被使用。
目前这只发生在副本提升期间,如下一节所述。基本上,纪元是集群的逻辑时钟,它规定给定信息优于纪元较小的信息。
配置纪元
每个主节点总是在 ping 和 pong 数据包中广播其 configEpoch
,以及一个表示其服务槽位的位图。
当新节点创建时,主节点的 configEpoch
被设置为零。
在副本选举期间会创建一个新的 configEpoch
。尝试替换故障主节点的副本会增加其纪元,并尝试获得大多数主节点的授权。当副本获得授权后,将创建一个新的唯一 configEpoch
,副本使用新的 configEpoch
转换为一个主节点。
如后文所述,configEpoch
有助于解决当不同节点声称存在分歧配置时的冲突(这种情况下可能由于网络分区和节点故障而发生)。
副本节点也在 ping 和 pong 数据包中广播 configEpoch
字段,但在副本的情况下,该字段表示其主节点在上次交换数据包时的 configEpoch
。这使得其他实例能够检测到副本何时拥有需要更新的旧配置(主节点不会将投票授予具有旧配置的副本)。
每当某个已知节点的 configEpoch
发生变化时,所有接收到此信息的节点都会将其永久存储在 nodes.conf 文件中。currentEpoch
值也会发生同样的情况。这两个变量在更新后,节点继续操作之前,保证会被保存并 fsync
到磁盘。
使用简单算法在故障转移期间生成的 configEpoch
值,保证是新的、增量的和唯一的。
副本选举和提升
副本的选举和提升由副本节点处理,并由投票支持副本提升的主节点协助。当主节点从至少一个符合成为主节点条件的副本的角度来看处于 FAIL
状态时,就会发生副本选举。
为了使副本提升为主节点,它需要发起并赢得选举。给定主节点的所有副本都可以在主节点处于 FAIL
状态时发起选举,但只有一个副本将赢得选举并提升为主节点。
当满足以下条件时,副本会开始选举:
- 副本的主节点处于
FAIL
状态。 - 主节点服务的槽位数量非零。
- 副本复制链接与主节点断开连接的时间不超过给定时间,以确保提升的副本数据足够新鲜。此时间由用户配置。
为了被选举,副本的第一步是增加其 currentEpoch
计数器,并向集群中的所有主节点请求投票。
副本通过向集群中的每个主节点广播 FAILOVER_AUTH_REQUEST
数据包来请求投票。然后它等待最多两倍 NODE_TIMEOUT
的时间(但至少 2 秒)以等待回复。
一旦一个主节点回复 FAILOVER_AUTH_ACK
积极地投票给一个给定的副本,它在 NODE_TIMEOUT * 2
期间内不能再投票给同一主节点的另一个副本。在此期间,它将无法回复同一主节点的其他授权请求。这并非严格要求安全保障,但有助于防止多个副本在大致相同的时间被选举(即使 configEpoch
不同),这通常是不希望的。
副本会丢弃任何纪元小于发送投票请求时 currentEpoch
的 AUTH_ACK
回复。这确保它不会计算用于之前选举的投票。
一旦副本收到大多数主节点的 ACK,它就赢得了选举。否则,如果在两倍 NODE_TIMEOUT
(但至少 2 秒)的时间内未达到大多数,则选举中止,并将在 NODE_TIMEOUT * 4
之后(且至少 4 秒)再次尝试新的选举。
副本排名
一旦主节点处于 FAIL
状态,副本会等待一小段时间,然后尝试被选举。延迟的计算方式如下:
DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds +
REPLICA_RANK * 1000 milliseconds.
固定的延迟确保我们等待 FAIL
状态在集群中传播,否则副本可能会在主节点仍未察觉 FAIL
状态时尝试被选举,从而拒绝授予其投票。
随机延迟用于使副本不同步,从而 unlikely 同时开始选举。
REPLICA_RANK
是此副本相对于它从主节点处理的复制数据量的排名。当主节点发生故障时,副本会交换消息以建立一个(尽力而为的)排名:复制偏移量最新的是排名 0 的副本,其次是排名 1,依此类推。通过这种方式,最新鲜的副本会先尝试被选举。
排名顺序并非严格执行;如果排名较高的副本未能当选,其他副本会在短时间内尝试。
一旦副本赢得选举,它将获得一个新的唯一的、递增的 configEpoch
,该纪元高于任何其他现有主节点。它开始在 ping 和 pong 数据包中将自己宣传为主节点,提供服务的槽位集,并使用一个将胜过过去纪元的 configEpoch
。
为了加速其他节点的重新配置,一个 pong 数据包被广播到集群中的所有节点。当前无法访问的节点最终会在从另一个节点接收到 ping 或 pong 数据包时重新配置,或者如果它通过心跳数据包发布的信息被检测到过时,它将从另一个节点接收到 UPDATE
数据包。
其他节点将检测到有一个新的主节点正在服务与旧主节点相同的槽位,但具有更大的 configEpoch
,并将升级它们的配置。旧主节点的副本(或如果故障转移的主节点重新加入集群)将不仅升级配置,还会重新配置以从新的主节点复制。重新加入集群的节点如何配置将在下一节中解释。
主节点回复副本投票请求
在上一节中,我们讨论了副本如何尝试被选举。本节解释了从被请求投票给给定副本的主节点的角度来看会发生什么。
主节点以 FAILOVER_AUTH_REQUEST
请求的形式接收来自副本的投票请求。
要获得投票,需要满足以下条件:
- 主节点在一个给定的纪元中只投票一次,并拒绝为旧纪元投票:每个主节点都有一个
lastVoteEpoch
字段,只要授权请求数据包中的currentEpoch
不大于lastVoteEpoch
,它就会拒绝再次投票。当主节点对投票请求给出肯定回复时,lastVoteEpoch
会相应更新,并安全地存储到磁盘上。 - 主节点仅在副本的主节点被标记为
FAIL
时才投票给副本。 currentEpoch
小于主节点currentEpoch
的授权请求将被忽略。因此,主节点的回复将始终具有与授权请求相同的currentEpoch
。如果同一个副本再次要求投票,并增加了currentEpoch
,则可以保证主节点旧的延迟回复不能被新的投票接受。
不使用规则 3 导致的实际问题示例:
主节点 currentEpoch
为 5,lastVoteEpoch
为 1(这可能发生在几次选举失败后)
- 副本
currentEpoch
为 3。 - 副本尝试以纪元 4 (3+1) 被选举,主节点以带有
currentEpoch
5 的 OK 回复,但回复被延迟。 - 副本稍后将再次尝试以纪元 5 (4+1) 被选举,延迟的回复以
currentEpoch
5 到达副本,并被接受为有效。
- 如果同一主节点的某个副本已被投票,主节点在
NODE_TIMEOUT * 2
时间内不会投票给该主节点的另一个副本。这并非严格要求,因为两个副本不可能在同一个纪元中赢得选举。然而,在实践中,它确保了当一个副本被选举时,它有足够的时间通知其他副本,并避免另一个副本赢得新选举,从而进行不必要的第二次故障转移的可能性。 - 主节点不以任何方式努力选择最佳副本。如果副本的主节点处于
FAIL
状态且主节点在当前周期中未投票,则授予肯定投票。最佳副本最有可能在其他副本之前开始选举并赢得选举,因为它通常能够更早地开始投票过程,因为它具有更高的排名,如上一节所述。 - 当主节点拒绝投票给给定副本时,没有负面响应,请求只是被忽略。
- 主节点不会投票给发送的
configEpoch
小于主节点表中任何configEpoch
的副本,如果这些槽位是副本声称拥有的。请记住,副本发送的是其主节点的configEpoch
,以及其主节点所服务的槽位的位图。这意味着请求投票的副本对于它想要故障转移的槽位,必须拥有比授予投票的主节点更新或相同的配置。
分区期间配置纪元有用性的实际示例
本节说明了如何使用纪元概念使副本提升过程对分区更具抵抗力。
- 一个主节点无限期地无法访问。该主节点有三个副本 A、B、C。
- 副本 A 赢得选举并被提升为主节点。
- 网络分区导致 A 无法被集群中的大多数节点访问。
- 副本 B 赢得选举并被提升为主节点。
- 分区导致 B 无法被集群中的大多数节点访问。
- 先前的分区已修复,A 再次可用。
此时 B 已宕机,A 再次以主节点的角色可用(实际上 UPDATE
消息会迅速重新配置它,但这里我们假设所有 UPDATE
消息都已丢失)。同时,副本 C 将尝试被选举以接管 B。以下是发生的情况:
- C 将尝试被选举并成功,因为对其大多数主节点而言,它的主节点确实已宕机。它将获得一个新的增量
configEpoch
。 - A 将无法声称是其哈希槽的主节点,因为其他节点已经将相同的哈希槽与更高的配置纪元(B 的纪元)相关联,相比 A 发布的纪元。
- 因此,所有节点将升级其表以将哈希槽分配给 C,集群将继续其操作。
正如您将在以下部分中看到的,重新加入集群的陈旧节点通常会尽快收到配置更改通知,因为一旦它 ping 任何其他节点,接收方将检测到其信息已过时并发送一个 UPDATE
消息。
哈希槽配置传播
Valkey 集群的一个重要组成部分是用于传播关于哪个集群节点正在服务给定哈希槽集的信息的机制。这对于新集群的启动以及副本被提升以服务其故障主节点的槽后升级配置的能力至关重要。
同样的机制允许被隔离了不确定时间的节点以合理的方式重新加入集群。
哈希槽配置通过两种方式传播
- 心跳消息。ping 或 pong 数据包的发送方总是添加关于它(或它的主节点,如果它是副本)服务的哈希槽集的信息。
UPDATE
消息。由于每个心跳数据包中都有关于发送方configEpoch
和所服务哈希槽集的信息,如果心跳数据包的接收方发现发送方信息已过时,它将发送带有新信息的数据包,强制陈旧节点更新其信息。
心跳或 UPDATE
消息的接收方使用某些简单规则来更新其哈希槽到节点的映射表。当创建一个新的 Valkey 集群节点时,其本地哈希槽表简单地初始化为 NULL
条目,以便每个哈希槽不绑定或链接到任何节点。这看起来类似于以下内容
0 -> NULL
1 -> NULL
2 -> NULL
...
16383 -> NULL
节点更新其哈希槽表所遵循的第一个规则如下
规则 1:如果一个哈希槽未分配(设置为 NULL
),并且一个已知节点声明拥有它,我将修改我的哈希槽表并将声明的哈希槽与其关联。
因此,如果我们收到来自节点 A 的心跳,声称服务哈希槽 1 和 2,配置世代值为 3,则该表将被修改为
0 -> NULL
1 -> A [3]
2 -> A [3]
...
16383 -> NULL
当创建一个新集群时,系统管理员需要手动(使用 CLUSTER ADDSLOTS
命令,通过 valkey-cli 命令行工具,或通过任何其他方式)将每个主节点服务的槽仅分配给节点本身,并且该信息将迅速在集群中传播。
然而,这个规则还不够。我们知道哈希槽映射可以在两个事件中改变
- 在故障转移期间,副本替换其主节点。
- 槽从一个节点重新分片到另一个节点。
现在让我们关注故障转移。当一个副本对其主节点进行故障转移时,它会获得一个配置世代,该世代保证大于其主节点的配置世代(更普遍地,大于之前生成的任何其他配置世代)。例如,节点 B 是 A 的副本,它可能以配置世代 4 故障转移 A。它将开始发送心跳数据包(首次是集群范围内的广播),并且由于以下第二条规则,接收方将更新其哈希槽表
规则 2:如果一个哈希槽已被分配,并且一个已知节点使用大于当前与该槽关联的主节点的 configEpoch
来宣传它,它将把哈希槽重新绑定到新节点。
因此,在收到 B 发送的、声称服务哈希槽 1 和 2 且配置世代为 4 的消息后,接收方将按以下方式更新其表
0 -> NULL
1 -> B [4]
2 -> B [4]
...
16383 -> NULL
活跃性属性:由于第二条规则,最终集群中的所有节点将一致认为槽的所有者是宣传该槽的节点中 configEpoch
最大的那个。
Valkey 集群中的这种机制称为最后一次故障转移获胜。
重新分片时也会发生同样的情况。当导入哈希槽的节点完成导入操作时,其配置世代会增加,以确保更改将在整个集群中传播。
UPDATE 消息,更深入的了解
考虑到上一节的内容,现在更容易理解更新消息的工作原理。节点 A 可能会在一段时间后重新加入集群。它将发送心跳数据包,声称服务哈希槽 1 和 2,配置世代为 3。所有具有更新信息的接收方将看到相同的哈希槽与具有更高配置世代的节点 B 相关联。因此,它们将向 A 发送一个包含槽新配置的 UPDATE
消息。A 将根据上述规则 2 更新其配置。
节点如何重新加入集群
当节点重新加入集群时,也使用同样的基本机制。继续上面的例子,节点 A 将被通知哈希槽 1 和 2 现在由 B 服务。假设这两个是 A 服务的所有哈希槽,A 服务哈希槽的数量将降至 0!因此 A 将重新配置为新主节点的副本。
实际遵循的规则比这要复杂一些。通常情况下,A 可能会在很长时间后重新加入,在此期间,A 最初服务的哈希槽可能由多个节点服务,例如哈希槽 1 可能由 B 服务,哈希槽 2 由 C 服务。
所以实际的 Valkey 集群节点角色切换规则是:主节点会将其配置更改为复制(成为其副本)偷走其最后一个哈希槽的节点。
在重新配置期间,最终服务的哈希槽数量将降至零,节点将相应地重新配置。请注意,在基本情况下,这仅意味着旧主节点将成为其在故障转移后取代它的副本的副本。然而,在一般形式中,该规则涵盖了所有可能的情况。
副本节点也做同样的事情:它们重新配置以复制偷走了其前主节点最后一个哈希槽的节点。
副本迁移
Valkey 集群实现了称为副本迁移的概念,以提高系统的可用性。其思想是,在一个主从设置的集群中,如果副本和主节点之间的映射是固定的,那么如果发生多个独立的单节点故障,可用性将随着时间的推移而受到限制。
例如,在一个每个主节点只有一个副本的集群中,只要主节点或副本发生故障,集群就可以继续运行,但如果两者同时发生故障则不能。然而,有一类故障是由硬件或软件问题引起的独立单节点故障,这些故障会随着时间的推移而累积。例如
- 主节点 A 有一个副本 A1。
- 主节点 A 故障。A1 被提升为新主节点。
- 三小时后,A1 以独立方式(与 A 的故障无关)发生故障。由于节点 A 仍然宕机,没有其他副本可用于提升。集群无法继续正常操作。
如果主节点和副本之间的映射是固定的,那么使集群对上述情况更具抵抗力的唯一方法是为每个主节点添加副本,但这代价高昂,因为它需要运行更多的 Valkey 实例,更多的内存等等。
另一种方法是在集群中创建不对称性,并让集群布局随时间自动变化。例如,集群可能有三个主节点 A、B、C。A 和 B 各有一个副本,A1 和 B1。然而,主节点 C 则不同,它有两个副本:C1 和 C2。
副本迁移是副本自动重新配置的过程,目的是为了迁移到不再有覆盖(没有工作的副本)的主节点。通过副本迁移,上述场景变为以下内容
- 主节点 A 故障。A1 被提升。
- C2 迁移为 A1 的副本,否则 A1 将没有副本支持。
- 三小时后 A1 也故障。
- C2 被提升为新主节点以替换 A1。
- 集群可以继续操作。
副本迁移算法
迁移算法不使用任何形式的协议,因为 Valkey 集群中的副本布局不属于需要与配置世代保持一致和/或版本化的集群配置。相反,它使用一种算法来避免当主节点没有备份时副本的大规模迁移。该算法保证最终(一旦集群配置稳定)每个主节点都将至少有一个副本支持。
该算法的工作原理如下。首先,我们需要定义在此上下文中什么是良好副本:从给定节点的角度来看,良好副本是一个不处于 FAIL
状态的副本。
算法的执行由检测到至少有一个主节点没有良好副本的每个副本触发。然而,在所有检测到此条件的副本中,只有一部分应该采取行动。除非在特定时刻,不同的副本对其他节点的故障状态有稍微不同的看法,否则这个子集通常只有一个副本。
作用中的副本是那些具有最大数量附加副本的主节点中,不处于 FAIL 状态且节点 ID 最小的副本。
因此,例如,如果有 10 个主节点各有 1 个副本,以及 2 个主节点各有 5 个副本,那么将尝试迁移的副本是——在这 2 个有 5 个副本的主节点中——节点 ID 最低的那个。鉴于没有使用协议,当集群配置不稳定时,可能会发生竞态条件,即多个副本都认为自己是具有较低节点 ID 的非故障副本(实际上这种情况不太可能发生)。如果发生这种情况,结果是多个副本迁移到同一个主节点,这是无害的。如果竞态以使让出副本的主节点没有副本的方式发生,一旦集群再次稳定,算法将再次执行,并将一个副本迁移回原始主节点。
最终,每个主节点都将至少有一个副本支持。然而,正常行为是单个副本从具有多个副本的主节点迁移到孤立主节点。
该算法由一个用户可配置参数控制,称为 cluster-migration-barrier
:副本可以迁移离开之前,主节点必须保留的良好副本数量。例如,如果此参数设置为 2,则副本只有在其主节点仍有两个工作副本时才能尝试迁移。
configEpoch 冲突解决算法
当在故障转移期间通过副本提升创建新的 configEpoch
值时,它们被保证是唯一的。
然而,有两种不同的事件会以不安全的方式创建新的 configEpoch 值,即仅仅增加本地节点的 currentEpoch
并希望同时没有冲突。这两个事件都是系统管理员触发的
- 带有
TAKEOVER
选项的CLUSTER FAILOVER
命令能够在大多数主节点不可用的情况下手动将副本节点提升为主节点。这在多数据中心设置中很有用,例如。 - 为了性能原因,集群重新平衡的槽迁移也会在本地节点内生成新的配置世代,而无需协议。
具体来说,在手动重新分片期间,当一个哈希槽从节点 A 迁移到节点 B 时,重新分片程序将强制 B 将其配置升级到集群中找到的最大世代值再加 1(除非该节点已经是拥有最大配置世代的节点),而无需其他节点的协议。通常,实际的重新分片涉及移动数百个哈希槽(尤其是在小型集群中)。在重新分片期间,为每个移动的哈希槽生成新的配置世代而要求协议是低效的。此外,它每次都需要在每个集群节点中进行 fsync 以存储新配置。然而,由于其执行方式,我们只需要在第一个哈希槽移动时才需要新的配置世代,这在生产环境中效率更高。
然而,由于上述两种情况,最终可能(尽管不太可能)出现多个节点拥有相同的配置世代。系统管理员执行的重新分片操作,以及同时发生的故障转移(加上很多坏运气),如果传播不够快,可能会导致 currentEpoch
冲突。
此外,软件错误和文件系统损坏也可能导致多个节点拥有相同的配置世代。
当服务不同哈希槽的主节点拥有相同的 configEpoch
时,没有问题。更重要的是,故障转移主节点的副本拥有唯一的配置世代。
话虽如此,手动干预或重新分片可能会以不同方式更改集群配置。Valkey 集群的主要活跃性属性要求槽配置始终收敛,因此在任何情况下,我们都希望所有主节点拥有不同的 configEpoch
。
为了强制执行这一点,当两个节点最终拥有相同的 configEpoch
时,将使用冲突解决算法。
- 如果一个主节点检测到另一个主节点以相同的
configEpoch
宣传自己。 - 并且如果该节点的节点 ID 在字典序上小于声称相同
configEpoch
的另一个节点。 - 则它将自己的
currentEpoch
增加 1,并将其用作新的configEpoch
。
如果存在任何一组拥有相同 configEpoch
的节点,除了拥有最大节点 ID 的节点外,所有节点都将向前推进,从而保证最终每个节点都将选择一个唯一的 configEpoch,无论发生了什么。
这种机制还保证在新集群创建后,所有节点都以不同的 configEpoch
开始(即使这实际上没有使用),因为 valkey-cli
确保在启动时使用 CLUSTER SET-CONFIG-EPOCH
。然而,如果由于某种原因节点配置错误,它将自动将其配置更新到不同的配置世代。
节点重置
节点可以进行软件重置(无需重启),以便在不同的角色或不同的集群中重复使用。这在正常操作、测试和云环境中非常有用,在这些环境中,给定节点可以重新配置以加入一组不同的节点,从而扩大或创建一个新集群。
在 Valkey 集群中,节点使用 CLUSTER RESET
命令进行重置。该命令提供两种变体
CLUSTER RESET SOFT
CLUSTER RESET HARD
该命令必须直接发送到要重置的节点。如果未提供重置类型,则执行软重置。
以下是重置操作执行的列表
- 软重置和硬重置:如果节点是副本,则将其转换为主节点,并丢弃其数据集。如果节点是主节点并包含键,则重置操作中止。
- 软重置和硬重置:所有槽都被释放,手动故障转移状态被重置。
- 软重置和硬重置:节点表中所有其他节点都被移除,因此节点不再知道任何其他节点。
- 仅硬重置:
currentEpoch
、configEpoch
和lastVoteEpoch
都设置为 0。 - 仅硬重置:节点 ID 更改为新的随机 ID。
具有非空数据集的主节点无法重置(因为通常您希望将数据重新分片到其他节点)。然而,在适当的特殊条件下(例如,当一个集群被完全销毁并打算创建一个新集群时),在继续重置之前必须执行 FLUSHALL
。
从集群中移除节点
通过将其所有数据重新分片到其他节点(如果它是主节点)并将其关闭,实际上可以从现有集群中移除节点。然而,其他节点仍将记住其节点 ID 和地址,并会尝试与其连接。
因此,当移除节点时,我们还希望从所有其他节点表中移除其条目。这通过使用 CLUSTER FORGET <node-id>
命令来完成。
该命令执行两项操作
- 它从节点表中移除具有指定节点 ID 的节点。
- 它设置一个 60 秒的禁止,阻止具有相同节点 ID 的节点被重新添加。
第二项操作是必需的,因为 Valkey 集群使用 gossip 协议来自动发现节点,因此从节点 A 中移除节点 X,可能会导致节点 B 再次向 A 传播关于节点 X 的信息。由于 60 秒的禁止,Valkey 集群管理工具拥有 60 秒的时间来从所有节点中移除该节点,从而防止由于自动发现而重新添加该节点。
更多信息可在 CLUSTER FORGET
文档中找到。
发布/订阅
在 Valkey 集群中,客户端可以订阅每个节点,也可以发布到其他任何节点。集群将确保已发布的消息按需转发。
客户端可以向任何节点发送 SUBSCRIBE,也可以向任何节点发送 PUBLISH。它将简单地将每个已发布的消息广播到所有其他节点。
Redis OSS 7.0 及更高版本支持分片发布/订阅,其中分片通道通过与将键分配给槽相同的算法分配给槽。分片消息必须发送到拥有分片通道哈希到的槽的节点。集群确保已发布的分片消息会转发到分片中的所有节点,因此客户端可以通过连接到负责该槽的主节点或其任何副本节点来订阅分片通道。
附录
附录 A:ANSI C 中的 CRC16 参考实现
/*
* 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;
}