Redis 集群规范

Redis 集群的详细规范

Redis 开源版

欢迎阅读 Redis 集群规范。在此您将找到关于 Redis Cluster 的算法和设计原理的信息。本文档是一份正在进行中的工作,因为它将持续与 Redis 的实际实现同步。

设计的主要特性和原理

Redis Cluster 目标

Redis Cluster 是 Redis 的一个分布式实现,其设计目标按重要性顺序如下

  • 高性能和线性扩展能力,最高可达 1000 个节点。不使用代理,采用异步复制,并且不对值进行合并操作。
  • 可接受程度的写入安全性:系统(尽最大努力)尝试保留所有源自连接到多数主节点客户端的写入。通常存在很小的窗口期可能丢失已确认的写入。当客户端位于少数分区时,丢失已确认写入的窗口期会更大。
  • 可用性:Redis Cluster 能够在一个分区中存活,该分区中的多数主节点可达,且对于每个不再可达的主节点至少有一个可达的副本。此外,通过使用副本迁移,没有副本复制的主节点将从被多个副本覆盖的主节点接收一个副本。

本文档中描述的内容在 Redis 3.0 或更高版本中实现。

已实现的子集

Redis Cluster 实现了非分布式版本 Redis 中所有单键命令。执行复杂多键操作(如集合联合和交集)的命令已实现,用于涉及操作的所有键都哈希到同一个槽的情况。

Redis Cluster 实现了一种称为 哈希标签 的概念,可用于强制将某些键存储在同一个哈希槽中。然而,在手动重新分片期间,多键操作可能会在一段时间内变得不可用,而单键操作始终可用。

Redis Cluster 不支持像独立版 Redis 那样多个数据库。我们仅支持数据库 0SELECT 命令是不允许的。

Redis 集群协议中的客户端和服务器角色

在 Redis Cluster 中,节点负责保存数据,并维护集群状态,包括将键映射到正确的节点。集群节点还能够自动发现其他节点,检测不可用的节点,并在发生故障时提升副本节点为主节点,以便继续运行。

为了执行其任务,所有集群节点使用 TCP 总线和二进制协议连接起来,该协议称为 Redis Cluster Bus。集群中的每个节点都使用集群总线连接到所有其他节点。节点使用流言协议来传播关于集群的信息,以便发现新节点,发送 ping 包以确保所有其他节点正常工作,并发送所需的集群消息以指示特定情况。集群总线还用于在集群中传播 Pub/Sub 消息,并在用户请求时协调手动故障转移(手动故障转移不是由 Redis Cluster 故障检测器触发的,而是由系统管理员直接触发的)。

由于集群节点无法代理请求,客户端可能会使用重定向错误 -MOVED-ASK 被重定向到其他节点。理论上,客户端可以自由地向集群中的所有节点发送请求,并在需要时被重定向,因此客户端不需要维护集群的状态。然而,能够缓存键与节点之间映射关系的客户端可以显著提高性能。

写入安全性

Redis Cluster 在节点之间使用异步复制,并采用 最后一次故障转移获胜 的隐式合并函数。这意味着最后选出的主节点数据集最终会替换所有其他副本。总会存在一个时间窗口,在此期间可能会在分区期间丢失写入。然而,连接到多数主节点的客户端和连接到少数主节点的客户端,其丢失写入的窗口期非常不同。

与在少数端执行的写入相比,Redis Cluster 会更努力地保留由连接到多数主节点的客户端执行的写入。以下是导致在故障期间丢失多数分区中已确认写入的场景示例

  1. 写入可能到达主节点,但主节点在回复客户端的同时,写入可能未通过主节点和副本节点之间使用的异步复制传播到副本。如果主节点在写入未到达副本的情况下崩溃,且主节点在足够长的时间内不可达以致其副本被提升,那么写入将永远丢失。在主节点完全突然故障的情况下,这通常很难观察到,因为主节点会尝试几乎同时回复客户端(确认写入)和副本(传播写入)。然而,这是一种真实世界的故障模式。

  2. 另一种理论上可能导致写入丢失的故障模式如下

  • 主节点因分区而无法访问。
  • 它的一个副本触发了故障转移。
  • 一段时间后,它可能再次可访问。
  • 路由表过时的客户端可能会在旧主节点被集群转换成副本(新主节点的副本)之前向其写入。

第二种故障模式不太可能发生,因为长时间无法与多数其他主节点通信以触发故障转移的主节点将不再接受写入,并且当分区修复后,在短时间内仍然拒绝写入,以允许其他节点告知配置更改。这种故障模式还要求客户端的路由表尚未更新。

针对分区少数端的写入有更大的窗口可能丢失。例如,在一个存在少数主节点和至少一个或多个客户端的分区中,Redis Cluster 会丢失相当数量的写入,因为发送到主节点的所有写入如果主节点在多数端发生故障转移,都可能丢失。

具体来说,要使主节点发生故障转移,它必须在至少 NODE_TIMEOUT 时间内无法被多数主节点访问,因此如果在此时间之前修复分区,则不会丢失写入。当分区持续时间超过 NODE_TIMEOUT 时,在该时间点之前在少数端执行的所有写入都可能丢失。然而,Redis Cluster 的少数端将在与多数端失去联系超过 NODE_TIMEOUT 时间后开始拒绝写入,因此存在一个最大窗口期,在此之后少数端将不再可用。因此,在此之后不会接受或丢失写入。

可用性

Redis Cluster 在分区少数端不可用。在分区多数端,假设存在至少多数主节点以及每个不可达主节点的副本,集群将在 NODE_TIMEOUT 时间加上副本被选举并故障转移其主节点所需的几秒钟后(故障转移通常在 1 或 2 秒内执行)再次变得可用。

这意味着 Redis Cluster 被设计用于在集群中少数节点故障时仍能存活,但它不是需要在大规模网络分裂事件中保持可用性的应用的合适解决方案。

例如,在一个由 N 个主节点组成且每个节点都有一个副本的集群中,只要只有一个节点被分区隔离,多数端集群将保持可用;当两个节点被分区隔离时,其保持可用的概率为 1-(1/(N*2-1))(第一个节点故障后,我们总共剩下 N*2-1 个节点,而唯一的无副本主节点故障的概率为 1/(N*2-1))

例如,在一个包含 5 个节点且每个节点有一个副本的集群中,在两个节点从多数端分区隔离后,集群将不再可用的概率为 1/(5*2-1) = 11.11%

由于 Redis Cluster 的一项称为 副本迁移 的功能,通过将副本迁移到孤立的主节点(不再有副本的主节点),在许多实际场景中提高了集群的可用性。因此,在每次成功的故障事件中,集群可能会重新配置副本布局,以便更好地应对下一次故障。

性能

在 Redis Cluster 中,节点不会将命令代理到负责给定键的正确节点,而是将客户端重定向到服务于给定键空间部分的正确节点。

最终,客户端会获得最新的集群表示以及哪个节点服务于哪个键子集,因此在正常操作期间,客户端会直接联系正确的节点以发送给定命令。

由于使用了异步复制,节点不会等待其他节点对写入的确认(除非使用 WAIT 命令明确请求)。

此外,由于多键命令仅限于邻近的键,数据仅在重新分片时在节点之间移动。

正常操作的处理方式与单个 Redis 实例完全相同。这意味着在一个包含 N 个主节点的 Redis Cluster 中,您可以期待与单个 Redis 实例乘以 N 的性能,因为设计是线性扩展的。同时,查询通常在一个往返中完成,因为客户端通常与节点保持持久连接,所以延迟数据也与单个独立 Redis 节点的情况相同。

实现非常高的性能和可扩展性,同时保留较弱但合理的写入安全性和可用性,是 Redis Cluster 的主要目标。

为何避免合并操作

Redis Cluster 的设计避免了在多个节点中出现同一键值对的冲突版本,因为在 Redis 数据模型中这并不总是可取的。Redis 中的值通常非常大;常见的是包含数百万元素的列表或有序集合。数据类型在语义上也比较复杂。传输和合并这类值可能是主要的瓶颈,并且/或者可能需要非平凡的应用端逻辑参与、额外的内存来存储元数据等等。

这里没有严格的技术限制。CRDTs 或同步复制状态机可以模拟类似于 Redis 的复杂数据类型。然而,这类系统的实际运行时行为与 Redis Cluster 不会相似。Redis Cluster 的设计旨在覆盖非集群版 Redis 的确切使用场景。

Redis 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:否
  • 输出 for "123456789": 31C3
  • 使用 CRC16 输出的 16 位中的 14 位(这就是为什么上面的公式中有一个模 16384 的操作)。

在我们的测试中,CRC16 在将不同类型的键均匀分布到 16384 个槽中表现出色。

注意:所用 CRC16 算法的参考实现可在本文档的附录 A 中找到。

哈希标签

计算哈希槽时有一个例外,用于实现哈希标签。哈希标签是一种确保多个键分配到同一个哈希槽的方法。这用于在 Redis Cluster 中实现多键操作。

为了实现哈希标签,在某些条件下,键的哈希槽计算方式略有不同。如果键包含 "{...}" 模式,则仅对 {} 之间的子字符串进行哈希以获取哈希槽。然而,由于可能存在多个 {},算法由以下规则明确规定

如果键包含 { 字符。

  • 并且如果 { 的右侧有一个 } 字符。
  • 并且如果第一个 { 和第一个 } 之间有一个或多个字符。
  • 那么,不对整个键进行哈希,而是只对第一个 { 和其右侧紧随的第一个 } 之间的内容进行哈希。

示例

{user1000}.following{user1000}.followers 将哈希到同一个哈希槽,因为在计算哈希槽时仅对子字符串 user1000 进行哈希。

  • 对于键 foo{}{bar},整个键将像往常一样进行哈希,因为第一个 { 右侧紧随 } 且中间没有字符。
  • 对于键 foo{{bar}}zap,子字符串 {bar 将被哈希,因为它是第一个 { 和其右侧紧随的第一个 } 之间的子字符串。
  • 对于键 foo{bar}{zap},子字符串 bar 将被哈希,因为算法在第一个有效或无效(中间无字节)匹配的 {} 处停止。
  • 根据算法可知,如果键以 {} 开头,则保证对其整体进行哈希。当使用二进制数据作为键名时,这很有用。
  • Glob-style 模式

接受 Glob-style 模式的命令,包括 KEYSSCANSORT,对仅涉及单个槽的模式进行了优化。这意味着如果所有可能匹配模式的键都必须属于某个特定槽,则只会搜索该槽中匹配模式的键。模式槽优化在 Redis 8.0 中引入。

当模式满足以下条件时,优化生效

模式包含哈希标签,

  • 哈希标签之前没有通配符或转义字符,以及
  • 花括号内的哈希标签不包含任何通配符或转义字符。
  • 例如,SCAN 0 MATCH {abc}* 可以成功识别哈希标签,并且只扫描对应于 abc 的槽。然而,模式 *{abc}{a*c}{a\*bc} 无法识别哈希标签,因此需要扫描所有槽。

哈希槽示例代码

添加哈希标签例外后,以下是 HASH_SLOT 函数在 Ruby 和 C 语言中的实现。

Ruby 示例代码

C 示例代码

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

集群节点属性

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 命令的示例输出。

在上面的列表中,不同字段的顺序是:节点 ID、地址:端口、标志、上次发送 ping 的时间、上次收到 pong 的时间、配置纪元、链接状态、槽位。关于上述字段的详细信息将在我们讨论 Redis Cluster 的特定部分时介绍。

$ 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

集群总线

每个 Redis Cluster 节点都有一个额外的 TCP 端口用于接收来自其他 Redis Cluster 节点的传入连接。该端口将通过数据端口加上 10000 得出,或者可以通过 cluster-port 配置指定。

示例 1

如果 Redis 节点正在监听客户端连接端口 6379,并且您没有在 redis.conf 中添加 cluster-port 参数,则会打开集群总线端口 16379。

示例 2

如果 Redis 节点正在监听客户端连接端口 6379,并且您在 redis.conf 中设置了 cluster-port 20000,则会打开集群总线端口 20000。

节点间通信仅通过集群总线和集群总线协议进行:一个由不同类型和大小的帧组成的二进制协议。集群总线二进制协议未公开文档化,因为它不打算用于外部软件设备使用此协议与 Redis Cluster 节点进行通信。然而,您可以通过阅读 Redis Cluster 源代码中的 cluster.hcluster.c 文件来获取有关集群总线协议的更多详细信息。

集群拓扑

Redis Cluster 是一个全连接网状结构,其中每个节点都使用 TCP 连接与其他所有节点相连。

在一个由 N 个节点组成的集群中,每个节点有 N-1 个出站 TCP 连接和 N-1 个入站连接。

这些 TCP 连接始终保持活动状态,不会按需创建。当一个节点在集群总线上收到 ping 后预期会收到 pong 回复时,在等待足够长时间将其标记为不可达之前,它会尝试通过从头开始重新连接来刷新与该节点的连接。

虽然 Redis Cluster 节点形成一个全连接网状结构,但节点使用流言协议和配置更新机制来避免在正常情况下节点之间交换过多消息,因此交换的消息数量不是指数级的。

节点握手

节点始终接受集群总线端口上的连接,即使 ping 节点不受信任,它们也会回复收到的 ping。然而,如果发送节点不被认为是集群的一部分,接收节点将丢弃所有其他数据包。

节点仅通过两种方式接受另一个节点作为集群的一部分

如果一个节点通过 MEET 消息(CLUSTER MEET 命令)介绍自己。meet 消息与 PING 消息完全相同,但强制接收者接受该节点作为集群的一部分。节点只有在系统管理员通过以下命令请求时才会向其他节点发送 MEET 消息

  • CLUSTER MEET ip port

    如果一个已经受信任的节点八卦(gossip)关于另一个节点,该节点也会将该节点注册为集群的一部分。因此,如果 A 知道 B,并且 B 知道 C,最终 B 会向 A 发送关于 C 的八卦消息。当这种情况发生时,A 会将 C 注册为网络的一部分,并尝试连接 C。

  • 这意味着只要我们将节点以任何连接图的方式连接起来,它们最终都会自动形成一个完全连接图。这意味着集群能够自动发现其他节点,但前提是存在由系统管理员强制建立的信任关系。

这种机制使集群更加健壮,但防止了不同的 Redis 集群在 IP 地址或其他网络相关事件更改后意外混合。

重定向和重新分片

MOVED 重定向

Redis 客户端可以自由地向集群中的任何节点发送查询,包括副本节点。节点将分析查询,如果它是可接受的(即,查询中只提及一个键,或者提及的多个键都属于同一个哈希槽),它将查找负责键或键所属的哈希槽的节点。

Redis 客户端可以自由地向集群中的每个节点发送查询,包括副本节点。节点将分析查询,如果查询可接受(也就是说,查询中只提及单个键,或者提及的多个键都属于同一个哈希槽),它将查找负责该键或这些键所属的哈希槽的节点。

如果哈希槽由该节点提供服务,则查询将直接处理,否则该节点将检查其内部的哈希槽到节点映射表,并向客户端回复一个 MOVED 错误,如下例所示

GET x
-MOVED 3999 127.0.0.1:6381

错误包含键的哈希槽 (3999) 和可以处理该查询的实例的 endpoint:port(端点:端口)。客户端需要将查询重新发送到指定节点的端点地址和端口。端点可以是 IP 地址、主机名,也可以为空(例如 -MOVED 3999 :6380)。空端点表示服务器节点具有未知端点,客户端应将下一个请求发送到与当前请求相同的端点,但使用提供的端口。

注意,即使客户端在重新发送查询之前等待了很长时间,在此期间集群配置发生了变化,如果哈希槽 3999 现在由另一个节点提供服务,目标节点仍将再次回复 MOVED 错误。如果联系的节点没有更新的信息,也会发生同样的情况。

因此,虽然从集群的角度来看,节点是通过 ID 来标识的,但我们试图简化与客户端的接口,仅暴露哈希槽与由 endpoint:port 对标识的 Redis 节点之间的映射关系。

客户端并非必须,但应尝试记住哈希槽 3999 由 127.0.0.1:6381 提供服务。这样,一旦需要发出新命令,它就可以计算目标键的哈希槽,并更有可能选择正确的节点。

另一种方法是,当收到 MOVED 重定向时,使用 CLUSTER SHARDS 命令或已弃用的 CLUSTER SLOTS 命令来刷新整个客户端的集群布局。遇到重定向时,很可能不仅仅是一个槽被重新配置,而是多个槽,因此尽快更新客户端配置通常是最佳策略。

注意,当集群稳定时(配置没有进行中的更改),最终所有客户端都将获得哈希槽到节点的映射,这使得集群高效,客户端可以直接访问正确的节点,而无需重定向、代理或其他单点故障实体。

客户端还必须能够处理本文档后面描述的 -ASK 重定向,否则它不是一个完整的 Redis 集群客户端。

实时重新配置

Redis Cluster 支持在集群运行时添加和移除节点。添加或移除节点被抽象为相同的操作:将一个哈希槽从一个节点移动到另一个节点。这意味着可以使用相同的基本机制来重新平衡集群、添加或移除节点等。

  • 要向集群添加一个新节点,首先将一个空节点添加到集群中,然后将现有节点中的一部分哈希槽移动到新节点。
  • 要从集群中移除一个节点,分配给该节点的哈希槽将移动到其他现有节点。
  • 要重新平衡集群,将在节点之间移动给定的一组哈希槽。

实现的核心是移动哈希槽的能力。从实际角度来看,一个哈希槽只是一组键,因此 Redis Cluster 在进行重新分片 (resharding) 时,实际上是将键从一个实例移动到另一个实例。移动一个哈希槽意味着移动所有恰好哈希到该哈希槽的键。

为了理解这是如何工作的,我们需要展示用于操作 Redis Cluster 节点的槽转换表的 CLUSTER 子命令。

以下子命令是可用的(其中一些在这里不适用)

前四个命令,ADDSLOTS, DELSLOTS, ADDSLOTSRANGEDELSLOTSRANGE,仅用于为一个 Redis 节点分配(或移除)槽。分配一个槽意味着告诉一个给定的主节点,它将负责存储和提供指定哈希槽的内容。

哈希槽分配后,将使用 gossip 协议在整个集群中传播,具体内容将在后面的配置传播部分中详细说明。

ADDSLOTSADDSLOTSRANGE 命令通常用于从零开始创建新集群时,为每个主节点分配所有 16384 个可用哈希槽的一个子集。

DELSLOTSDELSLOTSRANGE 主要用于手动修改集群配置或调试任务:实际上很少使用。

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 Cluster 配置过程中使用的 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 Cluster 中,除了数据库 0 之外无需指定其他数据库,但 MIGRATE 是一个通用命令,可用于不涉及 Redis Cluster 的其他任务。MIGRATE 已优化到尽可能快,即使在移动长列表等复杂键时也是如此,但如果使用数据库的应用程序存在延迟限制,则在存在大键的集群中重新配置集群并非明智之举。

当迁移过程最终完成后,将向参与迁移的两个节点发送 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 映射到新的 endpoint 和 port 对。请注意,如果存在错误的客户端提前执行了映射,这也不是问题,因为它在发出查询之前不会发送 ASKING 命令,因此 B 将使用 MOVED 重定向错误将客户端重定向回 A。

槽迁移的解释在 CLUSTER SETSLOT 命令文档中也使用了类似的术语但措辞不同(为了文档的冗余性)。

客户端连接和重定向处理

为了提高效率,Redis 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 命令告诉 Redis Cluster 副本节点,客户端可以读取可能过时的数据,并且不关心执行写查询。

当连接处于只读模式时,只有当操作涉及不由副本主节点服务的键时,集群才会向客户端发送重定向。这可能发生的原因是:

  1. 客户端发送了一个关于该副本主节点从未服务的哈希槽的命令。
  2. 集群被重新配置(例如重新分片),并且该副本节点不再能够为给定的哈希槽服务命令。

当发生这种情况时,客户端应按照前面章节的说明更新其哈希槽映射。

可以使用 READWRITE 命令清除连接的只读状态。

故障容错

心跳和 gossip 消息

Redis 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。

有一些方法可以减少消息数量,但是目前尚未有报告称 Redis Cluster 故障检测使用的带宽存在问题,因此目前采用了显而易见且直接的设计。请注意,即使在上面的示例中,每秒交换的 330 个数据包也平均分配给 100 个不同的节点,因此每个节点接收的流量是可以接受的。

心跳包内容

Ping 和 pong 数据包包含一个所有类型数据包(例如请求故障转移投票的数据包)共有的头部,以及一个 Ping 和 Pong 数据包特有的 gossip 部分。

通用头部包含以下信息:

  • 节点 ID,一个 160 位的伪随机字符串,在节点首次创建时分配,并在 Redis Cluster 节点的整个生命周期中保持不变。
  • 发送节点的 currentEpochconfigEpoch 字段,用于执行 Redis Cluster 使用的分布式算法(这将在后续章节中详细解释)。如果节点是副本,则 configEpoch 是其主节点最后已知的 configEpoch
  • 节点标志,指示节点是副本、主节点以及其他单比特的节点信息。
  • 发送节点服务的哈希槽的位图,如果节点是副本,则是其主节点服务的槽的位图。
  • 发送方的 TCP 基本端口,即 Redis 用于接受客户端命令的端口。
  • 集群端口,即 Redis 用于节点间通信的端口。
  • 从发送方角度来看的集群状态(down 或 ok)。
  • 发送节点的主节点 ID(如果该节点是副本)。

Ping 和 pong 数据包还包含一个 gossip 部分。此部分向接收方展示了发送节点对集群中其他节点的看法。gossip 部分仅包含发送方已知节点集合中一些随机节点的信息。gossip 部分中提及的节点数量与集群大小成正比。

在 gossip 部分中添加的每个节点都会报告以下字段:

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

Gossip 部分允许接收节点从发送方的角度获取其他节点的状态信息。这对于故障检测和发现集群中的其他节点都很有用。

故障检测

Redis Cluster 故障检测用于识别主节点或副本节点何时对多数节点不可达,然后通过将副本提升为主节点来响应。当副本无法提升时,集群将进入错误状态以停止接收客户端查询。

如前所述,每个节点维护一个与其他已知节点相关的标志列表。用于故障检测的有两个标志,分别称为 PFAILFAILPFAIL 表示可能的故障 (Possible failure),是一种未经确认的故障类型。FAIL 表示节点正在发生故障,并且此条件在固定时间内得到了多数主节点的确认。

PFAIL 标志

当一个节点在超过 NODE_TIMEOUT 时间内不可达时,另一个节点会为其标记 PFAIL 标志。主节点和副本节点都可以将另一个节点标记为 PFAIL,无论其类型如何。

Redis Cluster 节点不可达的概念是,我们有一个活动 ping(已发送但尚未收到回复的 ping)挂起的时间超过 NODE_TIMEOUT。为了使此机制奏效,NODE_TIMEOUT 必须相对于网络往返时间足够大。为了在正常操作期间增加可靠性,节点将在未收到 ping 回复的时间达到 NODE_TIMEOUT 的一半时,立即尝试与集群中的其他节点重新连接。此机制确保连接保持活跃,因此连接中断通常不会导致节点之间出现错误的故障报告。

FAIL 标志

仅凭 PFAIL 标志只是每个节点拥有的关于其他节点的本地信息,不足以触发副本提升。要使一个节点被认为是宕机的,PFAIL 条件需要升级到 FAIL 条件。

如本文档节点心跳部分所述,每个节点都会向其他所有节点发送 gossip 消息,其中包括一些随机已知节点的状态。每个节点最终都会收到关于其他每个节点的一组节点标志。通过这种方式,每个节点都有一个机制来通知其他节点其检测到的故障情况。

当满足以下一组条件时,PFAIL 条件会升级到 FAIL 条件:

  • 某个节点(我们称之为 A)将另一个节点 B 标记为 PFAIL
  • 节点 A 通过 gossip 部分收集了集群中多数主节点对 B 状态的看法信息。
  • 多数主节点在 NODE_TIMEOUT * FAIL_REPORT_VALIDITY_MULT 时间内发出了 PFAILFAIL 条件信号。(当前实现中,有效性因子设置为 2,因此这只是 NODE_TIMEOUT 时间的两倍)。

如果所有上述条件都为真,节点 A 将:

  • 将该节点标记为 FAIL
  • 向所有可达节点发送一条 FAIL 消息(区别于心跳消息中的 FAIL 条件)。

FAIL 消息将强制每个接收节点将该节点标记为 FAIL 状态,无论其之前是否已将该节点标记为 PFAIL 状态。

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

  • 该节点已可达且是副本节点。在这种情况下,FAIL 标志可以清除,因为副本节点不会发生故障转移。
  • 该节点已可达且是未服务任何槽的主节点。在这种情况下,FAIL 标志可以清除,因为没有槽的主节点实际上并不参与集群,并且正在等待配置以便加入集群。
  • 该节点已可达且是主节点,但在长时间(NODE_TIMEOUT 的 N 倍)内没有检测到任何副本提升。在这种情况下,它最好重新加入集群并继续。

值得注意的是,虽然 PFAIL -> FAIL 的转换使用了一种形式的共识,但这种共识是弱的。

  1. 节点在一段时间内收集其他节点的视图,因此即使多数主节点需要“同意”,实际上这只是我们在不同时间从不同节点收集到的状态,我们不确定,也不要求,在某个特定时刻多数主节点已经达成一致。但是我们会丢弃过时的故障报告,因此故障是在一个时间窗口内由多数主节点发出的信号。
  2. 虽然每个检测到 FAIL 条件的节点都会使用 FAIL 消息在集群中的其他节点上强制执行该条件,但无法确保消息会到达所有节点。例如,一个节点可能检测到 FAIL 条件,但由于分区而无法到达任何其他节点。

然而,Redis Cluster 的故障检测具有活性要求:最终所有节点都应该就给定节点的状态达成一致。存在两种可能源自分裂脑的情况。要么少数节点认为该节点处于 FAIL 状态,要么少数节点认为该节点不处于 FAIL 状态。在这两种情况下,最终集群都将对给定节点的状态拥有一个统一视图。

情况 1:如果多数主节点已将某个节点标记为 FAIL,由于故障检测及其产生的链式效应,其他每个节点最终也将该主节点标记为 FAIL,因为在指定的时间窗口内将报告足够的故障。

情况 2:只有少数主节点将某个节点标记为 FAIL 时,副本提升不会发生(因为它使用了一种更正式的算法,确保最终所有节点都知道提升情况),并且每个节点将根据上述 FAIL 状态清除规则清除 FAIL 状态(即,在 NODE_TIMEOUT 的 N 倍时间过去后没有发生提升)。

FAIL 标志仅用作触发器,用于运行副本提升算法的安全部分。理论上,副本可以在其主节点不可达时独立行动并开始副本提升,然后等待主节点在主节点实际上可由多数节点访问时拒绝提供确认。然而,PFAIL -> FAIL 状态的额外复杂性、弱一致性以及 FAIL 消息强制在集群可达部分以最短时间传播状态,都具有实际优势。由于这些机制,如果集群处于错误状态,通常所有节点会在大致相同的时间停止接受写入。这对于使用 Redis Cluster 的应用程序来说是一个期望的特性。此外,由于本地问题(主节点实际上可由多数其他主节点访问)而导致副本无法访问其主节点所引发的错误选举尝试也被避免了。

配置处理、传播和故障转移

集群当前纪元 (epoch)

Redis Cluster 使用了一个类似于 Raft 算法中“任期 (term)”的概念。在 Redis Cluster 中,这个概念称为“纪元 (epoch)”,它用于为事件提供增量版本控制。当多个节点提供冲突信息时,其他节点就可以理解哪种状态是最新的。

currentEpoch 是一个 64 位无符号数字。

在节点创建时,每个 Redis Cluster 节点,包括副本节点和主节点,都将 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),这通常是不可取的。

副本会丢弃任何纪元小于发送投票请求时 currentEpochAUTH_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 状态时尝试当选,从而被拒绝投票。

随机延迟用于解除副本的同步,使它们不太可能同时开始选举。

REPLICA_RANK 是此副本相对于其已从主节点处理的复制数据量的排名。当主节点发生故障时,副本会交换消息以建立(尽力而为的)排名:复制偏移量最新鲜的副本排名为 0,次新鲜的排名为 1,依此类推。通过这种方式,最新鲜的副本会尝试在其他副本之前当选。

排名顺序并非严格执行;如果排名较高的副本未能当选,其他副本很快就会尝试。

副本赢得选举后,将获得一个新的唯一且递增的 configEpoch,该值高于任何其他现有主节点。它开始在 ping 和 pong 数据包中将自身通告为主节点,提供所服务槽集及其 configEpoch,该 configEpoch 将优先于过去的纪元。

为了加快其他节点的重新配置,会将一个 pong 数据包广播到集群的所有节点。当前无法访问的节点最终会在收到来自另一个节点的 ping 或 pong 数据包时进行重新配置,或者如果通过心跳数据包发布的信息被检测为过时,则会收到来自另一个节点的 UPDATE 数据包。

其他节点将检测到一个新的主节点正在服务旧主节点所服务的相同槽,但具有更大的 configEpoch,并将升级其配置。旧主节点的副本(如果故障转移的主节点重新加入集群)不仅会升级配置,还会重新配置以从新的主节点进行复制。重新加入集群的节点如何配置将在后续章节中解释。

主节点回复副本投票请求

在上一节中,我们讨论了副本如何尝试当选。本节从被请求投票给给定副本的主节点的角度解释会发生什么。

主节点收到来自副本的 FAILOVER_AUTH_REQUEST 形式的投票请求。

要获得投票,需要满足以下条件

  1. 主节点在给定纪元内只投票一次,并拒绝投票给旧的纪元:每个主节点都有一个 lastVoteEpoch 字段,只要授权请求数据包中的 currentEpoch 不大于 lastVoteEpoch,它将拒绝再次投票。当主节点对投票请求做出肯定回复时,会相应地更新 lastVoteEpoch,并安全地存储到磁盘上。
  2. 主节点仅在其副本的主节点被标记为 FAIL 时才投票给该副本。
  3. currentEpoch 小于主节点 currentEpoch 的授权请求会被忽略。因此,主节点的回复将始终与授权请求具有相同的 currentEpoch。如果同一个副本再次请求投票,增加了 currentEpoch,则保证不能接受来自主节点的旧的延迟回复作为新投票。

未使用规则 3 导致的问题示例

主节点 currentEpoch 为 5,lastVoteEpoch 为 1(这可能在几次失败的选举后发生)

  • 副本 currentEpoch 为 3。
  • 副本尝试以纪元 4 (3+1) 当选,主节点回复一个带有 currentEpoch 5 的 ok,但是回复延迟了。
  • 副本稍后会再次尝试以纪元 5 (4+1) 当选,延迟的回复到达副本时带有 currentEpoch 5,并被接受为有效。
  1. 如果同一主节点的某个副本已经被投票过,主节点在 NODE_TIMEOUT * 2 时间过去之前不会投票给同一主节点的另一个副本。这不是严格要求,因为在同一纪元内不可能有两个副本赢得选举。然而,在实践中,这确保当一个副本当选时,它有足够的时间通知其他副本,并避免另一个副本赢得新选举,从而执行不必要的第二次故障转移的可能性。
  2. 主节点不做任何努力来选择最佳副本。如果副本的主节点处于 FAIL 状态且主节点在本期没有投票,则会授予肯定投票。最佳副本最有可能在其他副本之前开始选举并赢得选举,因为它通常由于其更高的排名(如前一节所述)能够更早地开始投票过程。
  3. 当主节点拒绝投票给给定的副本时,没有否定响应,请求只是被忽略。
  4. 主节点不会投票给发送的 configEpoch 小于主节点表中该副本声称拥有的槽的任何 configEpoch 的副本。请记住,副本发送的是其主节点的 configEpoch,以及其主节点服务的槽的位图。这意味着请求投票的副本必须对其希望故障转移的槽的配置要比授予投票的主节点的配置更新或相等。

配置纪元在分区期间有用性的实际示例

本节说明了如何使用纪元概念使副本提升过程对分区更具抵抗力。

  • 一个主节点变得无限期地无法访问。该主节点有三个副本 A、B、C。
  • 副本 A 赢得选举并被提升为主节点。
  • 网络分区使得 A 对集群中的大多数节点不可用。
  • 副本 B 赢得选举并被提升为主节点。
  • 分区使得 B 对集群中的大多数节点不可用。
  • 先前的分区得到修复,A 再次可用。

此时 B 已经下线,A 再次可用并担任主节点角色(实际上 UPDATE 消息会立即重新配置它,但这里我们假设所有 UPDATE 消息都丢失了)。与此同时,副本 C 将尝试当选以接管 B。这就是会发生的情况

  1. C 将尝试当选并成功,因为在大多数主节点看来,其主节点实际上已下线。它将获得一个新的递增的 configEpoch
  2. A 将无法声称是其哈希槽的主节点,因为其他节点已经将这些哈希槽与一个更高的配置纪元(B 的纪元)关联起来,该纪元高于 A 发布的值。
  3. 因此,所有节点都将升级其表格以将哈希槽分配给 C,并且集群将继续运行。

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

哈希槽配置传播

Redis Cluster 的一个重要部分是用于传播关于哪个集群节点正在服务给定哈希槽集的信息的机制。这对于新集群的启动以及在副本被提升以服务其故障主节点的槽后升级配置的能力至关重要。

同样的机制允许被分区出去无限期时长的节点以合理的方式重新加入集群。

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

  1. 心跳消息。ping 或 pong 数据包的发送方总是添加关于它(或者如果是副本,则为其主节点)服务的哈希槽集的信息。
  2. UPDATE 消息。由于每个心跳数据包中都包含发送方的 configEpoch 和所服务的哈希槽集信息,如果心跳数据包的接收方发现发送方信息陈旧,它将发送一个包含新信息的数据包,强制陈旧节点更新其信息。

心跳或 UPDATE 消息的接收方使用某些简单规则来更新其将哈希槽映射到节点的表格。创建新的 Redis Cluster 节点时,其本地哈希槽表格简单地初始化为 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 命令,通过 redis-cli 命令行工具,或任何其他方式)每个主节点服务的槽,且只分配给节点本身,信息将迅速在集群中传播。

然而,这条规则还不够。我们知道哈希槽映射在两种事件中会发生变化

  1. 副本在故障转移期间替换其主节点。
  2. 槽从一个节点重新分片到另一个节点。

现在我们聚焦于故障转移。当副本接管其主节点时,它会获得一个配置纪元,该纪元保证大于其主节点的纪元(更普遍地说,大于之前生成的任何其他配置纪元)。例如,节点 B 是 A 的副本,它可能以配置纪元 4 接管 A。它将开始发送心跳数据包(第一次进行集群范围内的广播),由于以下第二条规则,接收方将更新其哈希槽表格

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

因此,在收到来自 B 声称服务哈希槽 1 和 2 并使用配置纪元 4 的消息后,接收方将按照以下方式更新其表格

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

活性属性:由于第二条规则,最终集群中所有节点将同意槽的所有者是通告该槽的节点中 configEpoch 最大者。

Redis Cluster 中的这种机制称为最后一次故障转移获胜

重新分片期间也会发生同样的情况。当一个正在导入哈希槽的节点完成导入操作后,其配置纪元会递增,以确保该更改会传播到整个集群。

UPDATE 消息,更深入的了解

结合上一节的内容,更容易理解更新消息的工作原理。节点 A 可能在一段时间后重新加入集群。它会发送心跳数据包,声称服务哈希槽 1 和 2,配置纪元为 3。所有信息已更新的接收方则会看到相同的哈希槽与具有更高配置纪元的节点 B 相关联。因此,它们会向 A 发送一个包含这些槽新配置的 UPDATE 消息。A 将根据上面的规则 2 更新其配置。

节点如何重新加入集群

节点重新加入集群时使用同样的基本机制。继续上面的例子,节点 A 会被通知哈希槽 1 和 2 现在由 B 服务。假设这两个是 A 服务的唯一哈希槽,则 A 服务的哈希槽数量将降至 0!因此 A 将重新配置为新主节点的副本

实际遵循的规则比这稍微复杂一些。通常情况下,A 可能在很长时间后重新加入,在此期间,原本由 A 服务的哈希槽可能由多个节点服务,例如哈希槽 1 可能由 B 服务,哈希槽 2 由 C 服务。

因此实际的Redis Cluster 节点角色切换规则是:主节点将其配置更改为复制(成为副本)偷走其最后一个哈希槽的节点

在重新配置期间,最终服务的哈希槽数量将降至零,节点将相应地重新配置。请注意,在基本情况下,这仅意味着旧主节点将成为故障转移后替换其的副本的副本。然而,在一般形式下,该规则涵盖所有可能的情况。

副本做的完全一样:它们重新配置以复制偷走其前主节点的最后一个哈希槽的节点。

副本迁移

为了提高系统的可用性,Redis Cluster 实现了一个称为副本迁移的概念。其思想是,在主从设置的集群中,如果副本与主节点之间的映射是固定的,那么随着时间推移发生多个独立节点故障时,可用性将受到限制。

例如,在一个每个主节点只有一个副本的集群中,只要主节点或副本发生故障,集群就可以继续运行,但如果两者同时发生故障则不行。然而,有一类故障是由硬件或软件问题引起的独立节点故障,这些故障会随着时间累积。例如

  • 主节点 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 Cluster 中的副本布局不属于需要与配置纪元保持一致和/或版本化的集群配置的一部分。相反,它使用一种算法来避免在主节点没有备份时发生副本的大规模迁移。该算法保证最终(一旦集群配置稳定)每个主节点都将至少有一个副本支持。

该算法的工作原理如下。首先,我们需要定义在这种情况下什么是好的副本:好的副本是指在给定节点看来未处于 FAIL 状态的副本。

该算法的执行在检测到至少有一个主节点没有好的副本的每个副本中触发。然而,在所有检测到此条件的副本中,只有一部分应该采取行动。除非不同副本在给定时刻对其他节点的故障状态有稍微不同的看法,否则这个子集通常实际上只有一个副本。

执行迁移的副本是在连接副本数量最多的主节点中,未处于 FAIL 状态且节点 ID 最小的副本。

因此,例如,如果有 10 个主节点各有一个副本,以及 2 个主节点各具有 5 个副本,那么将尝试迁移的副本是——在拥有 5 个副本的 2 个主节点中——节点 ID 最小的那一个。鉴于不使用一致性机制,当集群配置不稳定时,可能会发生竞态条件,多个副本都认为自己是未发生故障且节点 ID 较低的副本(这在实践中不太可能发生)。如果发生这种情况,结果是多个副本迁移到同一个主节点,这是无害的。如果竞态导致让出副本的主节点没有副本,那么一旦集群再次稳定,算法将再次执行,并将一个副本迁移回原来的主节点。

最终,每个主节点都将至少有一个副本支持。然而,正常行为是一个副本从有多个副本的主节点迁移到没有副本的主节点。

该算法由一个用户可配置参数 cluster-migration-barrier 控制:在副本可以迁移走之前,主节点必须保留的好的副本数量。例如,如果此参数设置为 2,则只有在其主节点剩下两个正常工作的副本时,副本才能尝试迁移。

configEpoch 冲突解决算法

在故障转移期间通过副本提升创建新的 configEpoch 值时,它们保证是唯一的。

然而,有两种不同的事件会以不安全的方式创建新的 configEpoch 值,只需增加本地节点的本地 currentEpoch,并希望同时没有冲突。这两个事件都是由系统管理员触发的

  1. 带有 TAKEOVER 选项的 CLUSTER FAILOVER 命令能够手动将副本节点提升为主节点,而无需大多数主节点可用。这在多数据中心设置中非常有用。
  2. 出于性能原因,用于集群重新平衡的槽迁移也会在本地节点内部生成新的配置纪元,而无需达成一致。

具体来说,在手动重新分片期间,当哈希槽从节点 A 迁移到节点 B 时,重新分片程序将强制 B 将其配置升级到集群中找到的最大纪元加 1(除非该节点已经是具有最大配置纪元的节点),而无需其他节点达成一致。通常,实际的重新分片涉及移动数百个哈希槽(尤其是在小型集群中)。在重新分片期间,每次移动一个哈希槽都要求达成一致来生成新的配置纪元,这是低效的。此外,它每次都需要在每个集群节点中进行 fsync 以存储新配置。由于其执行方式,我们只需要在移动第一个哈希槽时生成一个新的配置纪元,这使得在生产环境中效率更高。

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

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

当服务不同哈希槽的主节点具有相同的 configEpoch 时,没有问题。更重要的是,接管主节点的副本具有唯一的配置纪元。

话虽如此,手动干预或重新分片可能会以不同的方式改变集群配置。Redis Cluster 的主要活性属性要求槽配置始终收敛,因此在任何情况下,我们都确实希望所有主节点都具有不同的 configEpoch

为了强制执行这一点,在两个节点最终具有相同的 configEpoch 的情况下,会使用冲突解决算法

  • 如果主节点检测到另一个主节点正在以相同的 configEpoch 通告自身。
  • 并且如果该节点的节点 ID 在字典序上小于声称具有相同 configEpoch 的另一个节点。
  • 则它将自己的 currentEpoch 增加 1,并将其用作新的 configEpoch

如果存在任何具有相同 configEpoch 的节点集合,除了节点 ID 最大者之外,所有节点都会向前推进,保证最终每个节点都会选择一个唯一的 configEpoch,无论发生什么。

该机制还保证新集群创建后,所有节点都以不同的 configEpoch 启动(即使实际上不使用这个值),因为 redis-cli 在启动时会确保使用 CLUSTER SET-CONFIG-EPOCH。然而,如果由于某种原因节点配置错误,它将自动将其配置更新到不同的配置纪元。

节点重置

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

在 Redis Cluster 中,节点使用 CLUSTER RESET 命令进行重置。该命令有两种变体

  • CLUSTER RESET SOFT
  • CLUSTER RESET HARD

该命令必须直接发送到要重置的节点。如果没有提供重置类型,则执行软重置。

以下是重置执行的操作列表

  1. 软重置和硬重置:如果节点是副本,它会转变为一个主节点,并且其数据集被丢弃。如果节点是主节点且包含键,重置操作会被中止。
  2. 软重置和硬重置:所有槽都被释放,手动故障转移状态被重置。
  3. 软重置和硬重置:节点表中的所有其他节点都被移除,因此该节点不再知道任何其他节点。
  4. 仅硬重置:currentEpochconfigEpochlastVoteEpoch 设置为 0。
  5. 仅硬重置:节点 ID 更改为一个新的随机 ID。

数据集非空的主节点不能被重置(因为通常您希望将数据重新分片到其他节点)。但是,在特殊条件下,如果这样做是合适的(例如,当完全销毁一个集群并打算创建一个新的集群时),必须在进行重置之前执行 FLUSHALL

从集群中移除节点

实际上可以通过将其所有数据重新分片到其他节点(如果是主节点)并将其关闭来从现有集群中移除节点。然而,其他节点仍然会记住其节点 ID 和地址,并会尝试与其连接。

因此,当一个节点被移除时,我们也希望从所有其他节点的表中移除它的条目。这可以通过使用 CLUSTER FORGET <node-id> 命令来实现。

该命令做两件事

  1. 它从节点表中移除具有指定节点 ID 的节点。
  2. 它设置了一个 60 秒的禁止期,防止具有相同节点 ID 的节点被重新添加。

需要第二个操作是因为 Redis Cluster 使用 gossip 协议来自动发现节点,因此从节点 A 中移除节点 X,可能会导致节点 B 再次向 A 散布关于节点 X 的信息。由于有 60 秒的禁止期,Redis Cluster 管理工具可以在 60 秒内从所有节点中移除该节点,从而防止由于自动发现而重新添加该节点。

更多信息可在 CLUSTER FORGET 文档中找到。

发布/订阅

在 Redis Cluster 中,客户端可以订阅每个节点,也可以发布到其他任何节点。集群会确保发布的消​​息按需转发。

客户端可以向任何节点发送 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;
}
为本页评分
回到顶部 ↑