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