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 数据包以确保所有其他节点正常工作,以及发送集群消息以发出特定条件的信号。集群总线还用于在集群中传播发布/订阅消息,并在用户请求时协调手动故障转移(手动故障转移不是由 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 个槽中方面表现得非常好。
注意:本文件附录 A 中提供了 CRC16 算法的参考实现。
哈希标签
用于实现哈希标签的哈希槽计算有一个例外。哈希标签是一种确保多个键分配在同一个哈希槽中的方式。这用于在 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 用于识别整个集群中的每个节点。给定节点无需更改节点 ID 即可更改其 IP 地址。集群还能够检测 IP/端口的更改,并使用在集群总线上运行的 Gossip 协议重新配置。
节点 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,即使 pinging 节点不受信任。但是,如果发送节点不被视为集群的一部分,则接收节点将丢弃所有其他数据包。
节点仅通过两种方式接受另一个节点作为集群的一部分
-
如果节点使用
MEET
消息(CLUSTER MEET
命令)显示自身。MEET 消息与PING
消息完全相同,但强制接收者接受该节点作为集群的一部分。节点将向其他节点发送MEET
消息仅当系统管理员通过以下命令请求此操作时CLUSTER MEET ip port
-
如果已经受信任的节点将八卦另一个节点,则节点也将将另一个节点注册为集群的一部分。因此,如果 A 知道 B,B 知道 C,最终 B 会向 A 发送有关 C 的八卦消息。发生这种情况时,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 集群在重新分片期间实际执行的操作是从一个实例向另一个实例移动键。移动哈希槽意味着移动所有恰好哈希到此哈希槽的键。
要了解其工作原理,我们需要展示CLUSTER
子命令,用于操作 Redis 集群节点中的槽转换表。
可以使用以下子命令(包括其他在这种情况下无用的子命令)
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节点。分配槽意味着告诉给定的主节点,它将负责存储和提供指定哈希槽的内容。
分配哈希槽后,它们将使用 gossip 协议在集群中传播,如配置传播部分后面所述。
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,如果客户端在发送查询之前发送 ASKING 命令,则节点 B 将仅接受设置为 IMPORTING 的槽位的查询。
基本上,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
返回数组的每个元素的前两个子元素是范围的开始槽和结束槽。其他元素表示地址端口对。第一个地址端口对是提供槽的主副本,其他地址端口对是提供相同槽的副本。仅当副本不在错误状态(即未设置其 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 或在超过一半的 NODE_TIMEOUT
时间内没有收到 pong。在 NODE_TIMEOUT
经过之前,节点还会尝试重新连接与另一个节点的 TCP 链接,以确保不只是因为当前 TCP 连接中存在问题而认为节点不可达。
如果将 NODE_TIMEOUT
设置为较小的数字,并且节点数量 (N) 非常大,则全局交换的消息数量可能会很大,因为每个节点都将尝试 ping 每个其他节点,而它们没有最新信息,每隔一半的 NODE_TIMEOUT
时间。
例如,在节点超时设置为 60 秒的 100 节点集群中,每个节点都将尝试每 30 秒发送 99 次 ping,总 ping 量为每秒 3.3 次。乘以 100 个节点,即整个集群每秒 330 次 ping。
有办法减少消息数量,但是目前尚未报告 Redis 集群故障检测当前使用的带宽有任何问题,因此现在使用的是显而易见且直接的设计。请注意,即使在上述示例中,每秒交换的 330 个数据包也在 100 个不同的节点之间平均分配,因此每个节点接收的流量是可以接受的。
心跳数据包内容
Ping 和 pong 数据包包含一个所有类型数据包(例如请求故障转移投票的数据包)通用的标头,以及一个特定于 Ping 和 Pong 数据包的特殊 Gossip 部分。
通用标头包含以下信息
- 节点 ID,一个 160 位的伪随机字符串,在节点首次创建时分配,并且在 Redis 集群节点的整个生命周期中保持不变。
- 发送节点的
currentEpoch
和configEpoch
字段,用于装载 Redis 集群使用的分布式算法(将在下一节中详细解释)。如果节点是副本,则configEpoch
是其主节点的最后一个已知configEpoch
。 - 节点标志,指示节点是副本、主节点,以及其他单比特节点信息。
- 发送节点提供的哈希槽位图,或者如果节点是副本,则为其主节点提供的槽位图。
- 发送者 TCP 基本端口,即 Redis 用于接受客户端命令的端口。
- 集群端口,即 Redis 用于节点间通信的端口。
- 从发送者的角度来看的集群状态(关闭或正常)。
- 发送节点的主节点 ID(如果它是一个副本)。
Ping 和 pong 数据包还包含一个 Gossip 部分。此部分向接收者提供发送节点对集群中其他节点的看法。Gossip 部分仅包含发送者已知节点集中一些随机节点的信息。Gossip 部分中提到的节点数量与集群大小成正比。
对于八卦部分中添加的每个节点,将报告以下字段
- 节点 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
状态(即在NODE_TIMEOUT
经过 N 倍后没有提升)。
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
回复,其 epoch 小于在发送投票请求时 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
,该configEpoch
高于任何其他现有主节点的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 答复同意,但答复被延迟。 - 副本稍后会尝试以纪元 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 命令行工具或通过任何其他方式)每个主节点仅向节点本身提供服务的槽,并且该信息将快速传播到整个集群。
但是,这条规则还不够。我们知道哈希槽映射可以在两个事件期间更改
- 副本在故障转移期间替换其主设备。
- 槽从一个节点重新分片到另一个节点。
现在让我们关注故障转移。当副本故障转移其主设备时,它将获得一个配置纪元,该纪元保证大于其主设备的配置纪元(并且通常大于之前生成的任何其他配置纪元)。例如,作为 A 的副本的节点 B 可能会使用配置纪元 4 故障转移 A。它将开始发送心跳数据包(第一次大规模广播集群范围),并且由于以下第二条规则,接收方将更新其哈希槽表
规则 2:如果哈希槽已分配,并且已知节点正在使用大于当前与该槽关联的主设备的 configEpoch
对其进行通告,我将重新将哈希槽绑定到新节点。
因此,在收到 B 的消息后,声称使用配置纪元 4 提供哈希槽 1 和 2,接收方将按以下方式更新其表
0 -> NULL
1 -> B [4]
2 -> B [4]
...
16383 -> NULL
活动性属性:由于第二条规则,集群中的所有节点最终将同意,某个槽的所有者是宣告该槽的所有节点中 configEpoch
最大的节点。
Redis 集群中的此机制称为最后一次故障转移获胜。
重新分片期间也会发生相同的情况。当导入哈希槽的节点完成导入操作时,其配置纪元将增加,以确保更改将在整个集群中传播。
UPDATE 消息,仔细观察
考虑到上一部分,更容易看出更新消息如何工作。节点 A 可能会在一段时间后重新加入集群。它将发送心跳数据包,声称它使用配置纪元 3 提供哈希槽 1 和 2。所有具有更新信息的接收方将看到相同的哈希槽与具有更高配置纪元的节点 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 各有一个副本,即 A1 和 B1。然而,主节点 C 不同,它有两个副本:C1 和 C2。
副本迁移是自动重新配置副本的过程,以便迁移到不再具有覆盖范围(没有工作副本)的主节点。通过副本迁移,上面提到的场景变为以下场景
- 主节点 A 发生故障。A1 被提升。
- C2 迁移为 A1 的副本,否则 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
冲突。
此外,软件错误和文件系统损坏也可能导致多个节点具有相同的配置纪元。
当为不同哈希槽提供服务的服务器具有相同的 configEpoch
时,没有问题。更重要的是,故障转移到服务器的副本具有唯一的配置纪元。
也就是说,手动干预或重新分片可能会以不同的方式更改群集配置。Redis 群集的主要活性属性要求槽位配置始终收敛,因此在任何情况下,我们都确实希望所有主节点具有不同的 configEpoch
。
为了强制执行此操作,冲突解决算法用于在两个节点最终具有相同的 configEpoch
的情况下。
- 如果主节点检测到另一个主节点正在宣告自己具有相同的
configEpoch
。 - 并且如果与宣称具有相同
configEpoch
的其他节点相比,该节点具有字典序更小的节点 ID。 - 那么它会将其
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 集群使用 gossip 来自动发现节点,因此从节点 A 中删除节点 X 可能会导致节点 B 再次向 A gossip 节点 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;
}