背景

原文链接

—–分割线—–

Redis 集群规范

欢迎来到Redis集群规范。这里你将找到Redis集群的算法和设计逻辑信息。这篇文章是与Redis实现持续同步的文档。

设计的主要特性和原理

Redis集群目标

Redis集群是基于如下目标的Redis分布式实现,设计中的重要顺序如下:

  • 高性能线性扩展到1000个节点。没有代理,使用异步复制,并且不在值上执行合并操作。
  • 可接受的写安全度:系统尝试(尽最大努力)接受与来自大多数主节点的链接的客户端的写请求。通常有很小的时间窗口写的反馈会丢失。丢失写的窗口当客户端在最小分区链接时会变大。
  • 可用性:Redis集群能够在大多数主节点都可访问且每个不再可用的主节点上至少有一个可访问的从节点的分区中幸存下来。此外使用__副本迁移__,没有从节点的主节点,将从有多个从节点的主节点中接收一个从节点。

文档中描述的是Redis 3.0或以上的版本。

实现的子集

Redis集群实现了Redis没有分布式版本的所有单Key命令。类似集合类型的交并集这样复杂的多Key操作,只要键值都哈希到一个槽中也是被实现的。 Redis集群实现了一个叫做hash tag的概念,能够将给定的键值强制存储到指定的哈希槽中。然而在手动分片过程中,多Key操作可能一段时间变为不可用,而单Key操作却一直可用。 Redis集群不像单机的Redis版本一样支持多数据库。集群仅有0数据库,SELECT命令是不被允许的。(阿里提供的redis集群是能够使用select命令的)

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

在Redis集群中节点负责保存数据,获取集群状态,包括映射键值到对的节点。集群节点同样可以自动发现其他节点,探测不工作的节点,并在需要时将从节点升为主节点,以便发生故障时继续运行。

去执行这些计划所有的集群节点都通过TCP使用二进制协议链接在一起叫做Redis Cluster Bus。集群中的每个节点都使用集群总线连接到其他节点。节点使用一种gossip协议传播集群的信息,目的是发现新的节点,发送ping包保证所有其他节点工作正常,特定情况下发送集群信息。集群总线同样被用在整个集群的传播Pub/Sub消息和用户请求的手动故障转移(手动故障转移不是Redis集群故障转移检查器发起的,而是由系统管理员直接发起的)。

由于集群节点不能代理请求,客户端必须使用重定向错误-MOVED和-ASK。理论上客户端可以很自由的发送请求给集群中的所有节点,如果需要进行重定向,所以客户端不必要掌握集群的状态,然而客户端可以缓存键值和节点的映射以合理的提高性能。

写安全

Redis集群在节点之间使用异步复制,最后胜出的故障转移隐式合并功能。这意味着最后被选举出来的主节点数据集最终代替所有其他的副本。分区过程中经常会有窗口时间丢失写。但是这个窗口时间因为客户端链接的大多数主节或少数主节点会有很大不同。

与在少数主机上执行的写操作相比,Redis集群会更努力地保留与大多数主服务器连接的客户端执行的写操作。以下是导致故障期间导致大多数分区中收到的已确认写入丢失的方案示例:

  • 由于网络划分主节点不可达。
  • 被他的一个从节点故障转移。
  • 一段时间后之前的主节点又可以到达。
  • 具有过时的路由表的客户端在集群将他转换为新的主节点的从节点之前,将数据写入到了老的节点。

第二种错误的模式基本上不太可能发生,因为只有有足够的时间主节点不能和大部分其他的主节点通信时才会发生故障转移,将不再会接受写请求,当分区修复之后,仍然有一小段时间不允许写,这段时间保障其他节点去通知配置改变。这种错误发生的情况同时需要客户端路由表没有被更新。

写到少数分区的情况,有一个更大的窗口时间丢失写。例如,Redis集群在有少数主节点和至少一个或多个客户端的分区上会丢失大量的写,由于所有的发到主节点的写可能丢失,如果主节点z在大部分分区发生了故障转移。

特别的,针对一个将要发生故障转移的主节点,他必须被大部分节点不可达至少NODE_TIMEOUT,因此如果分区在该时段之前被修复了,不会丢失写。当分区丢失时长大于NODE_TIMEOUT时,所有在那个时间点之前的少数端的写入可能会丢失。当少数分区与大多数分区失联到NODE_TIMEOUT时,Redis集群中的少数分区将拒绝写入,所以当少数分区中的节点不可用之前有一个最大时间窗口。因此在那之后没有写入被接受或丢失。

可用性

Redis集群在少数分区不可用。在分区的多数方,假设有至少大部分的主节点和至少一个每个不可达主节点的从节点,集群在NODE_TIMEOUT再多加几秒的时间内重新变为可用。多出来的这几秒让从节点进行故障转移,被选举为主节点(故障转移通常在1,2秒内执行)。

这意味着,Redis集群旨在承受集群中几个节点的故障,但是在大的网络拆分情况下需要保证可用性的应用程序他并不适用。

在由N个主节点每个主节点有一个从节点组成的Redis集群中,当个节点分区丢失后,集群的大部分端将保持可用,当两个节点被分隔开时集群将继续保持1-(1/(N2-1))的可用性(第一个节点失败后我们总共剩余N2-1个节点,没有副本的唯一的主节点出现故障的概率是1/(N*2-1))。

例如在每个主节点有一个从节点的5个节点的集群中,两个主节点被分隔开时,集群有1/(5*2-1) = 11.11%的概率不可用。

感谢Redis集群叫做副本迁移的特性,在真实世界的使用情况下,集群的可用性被提高了,副本迁移到孤儿节点(主节点不再有副本)。因此在每次的成功失败事件中,集群将要重新配置从节点的布局,以更好的对抗下次失败。

性能

在Redis集群中节点并不会针对指定的键值代理命令到其负责的节点。但是他们重定向客户端到正确的节点,该节点负责服务指定键值空间。

最终客户端获得了集群的最新展示,以及哪些节点服务哪些键值的子集,因此在正常操作客户端可以直接联系正确的节点以便发送指定的命令。

由于异步复制的使用,节点并不等待其他节点写入的反馈(如果没有显示的调用WAIT命令)。

同样由于multi-key命令仅限制为 near keys,数据从不在节点之间移动除非重新分片。

普通操作的处理方式与单个Redis实例完全相同。这意味着在有N个主节点的Redis集群中,随着线性扩展的设计你可以期待单个Redis实例的N倍的性能。同时查询在一次往返中执行,由于客户端通常保持与节点的持久链接,所以延迟和单实例Redis节点情况相同。

Redis集群设计的主要目标是在保持微弱但是合理的数据安全性和可用性的同时,具有很高的性能和可伸缩性。

为什么避免合并操作

Redis集群的设计避免了多个节点中相同键值对的版本冲突,因为对于Redis数据模型来说这并不是可取的。Redis中的值通常很大;我们很容易看到百万元素的list或sorted sets。同时数据类型也是语义复杂的。传输/合并是主要的瓶颈,交并需要用户侧很多的逻辑,额外的内存去存储元数据等等。

这里没有严格的技术限制。CRDTs或同步状态复制机(synchronously replicated state machines)可以建模和Redis类似的复杂数据模型。但这些系统的实际运行时状态行为和Redis集群不同。Redis集群被设计为覆盖非集群Redis版本的确切用例。

Redis集群主要部件概述

键值布局模型

键值空间被拆分为16384个槽,有效的集群最大主节点的大小限制是16384(但是建议最大节点大小约为1000个节点)。

集群中的每个主节点处理16384个槽中的子集。当没有集群正在重新配置(例如,哈希槽从一个节点移动到另外一个节点)的时候,集群是稳定的。当集群稳定时,单个哈希槽将被单个节点服务(但是这个正在服务的节点可以有一个或多个从节点,如果主节点失败或发生网络分区,从节点将代替主节点,同时当读取陈旧数据可以被接受时,从节点可以扩展读操作)。

映射键值和哈希槽的基本算法如下(hash tag是个特例,读取下面的章节了解hash tag):

HASH_SLOT = CRC16(key) mod 16384

CRC16指定如下:

  • 名称:XMODEM(同样ZMODEM或者CRC-16/ACRON)
  • 宽度:16位
  • Poly:1021(实际上是x^16+x^12+x^5+1)
  • 初始值:0000
  • Reflect Input byte:False
  • Reflect Output byte:False
  • Xor constant to Output CRC:0000
  • Output for “123456789”:31C3

CRC16的16位输出中使用了14位(这也是上面的公式有对16384求余的操作的原因)。在我们的测试中,CRC16在16384个哈希槽中均匀分布各种键值表现优异。 注意:本文档的附录中提供了一个可用的CRC16算法的参考实现。

键值哈希标签

计算hash槽时有一个例外目的是实现hash tags。hash tags保证多个键值分配到同一个哈希槽。hash tags被用作在Redis集群中实现多key操作。

为了实现hash tags,在某些情况下,键值对应的哈希槽计算方式略有不同。如果键值中包含”{…}”形式,仅仅在{和}之间的字串会被用作计算哈希槽。但是由于{和}有可能出现多次,计算哈希的算法被如下规则明确指定:

  • 键值中包含{字符。
  • 同时有在{后有}字符。
  • 同时在第一个{和第一个}之间有一个或多个字符。

然后使用第一个出现的{和第一个出现的}之间的子串代替键值做哈希计算。

例子:

  • {user1000}.following和{user1000}.follower将被哈希到同样的哈希槽。因为仅仅括号内的子串user1000会被用来做哈希计算。
  • foo{}{bar}键值将会按照整个键值做哈希计算,以为第一个出现了{和}之间没有字符。
  • 针对键值foozap子串{bar将会被哈希,因为其是第一个{和}之间的内容。
  • 针对键值foo{bar}{zap}子串bar将会被哈希,以为算法会在{和}的第一个有效或无效(内部没有字符)匹配处停止。
  • 该算法得出的结论是如果键值以{}开始,他会被保证为一个整体。当键值使用二进制数据作为键值名称时有用。

添加哈希标签的特殊情况,下面是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位随机数字的16进制表示,节点第一次启动的时候获得(通常使用/dev/urandom)。节点在他的配置文件中保存他的ID,只要系统管理员不删除这个配置文件或通过CLUSTER RESET命令 hard reset 这个节点,节点将会永远使用这个ID。

节点ID用来标识整个集群的每个节点。指定节点改变IP地址时不需要改变节点ID。集群同样可以基于集群总线使用gossip协议探测到IP/端口变化。

节点ID并不是和每个节点关联的仅有的信息,但他是经常全球保持一致的。每个节点还有如下关联的信息集。特定节点的集群配置详细信息,最终在整个集群中保持一致。一些其他的信息,比如节点被ping的最后时间,他被存储在本地。

每个节点都维护有关集群中其他节点的一下信息:节点ID,节点的IP和端口,一系列标志,主节点(如果该节点被标记为从节点)是谁,该节点最后一次被ping的时间,以及收到pong的最后时间,节点的当前 配置时代(configuration epoch) (本规范后面解释),链接状态和最终服务的哈希槽集合。

一份详细的节点字段解释集群节点文档中描述。

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应该加到平常的命令端口。例如如果Redis节点在6379端口监听客户端链接,集群总线端口16379将被打开。

节点间通信仅使用集群总线和集群总线协议:一种又不同类型和大小组成帧的二进制协议。集群总线二进制协议没公开记录,因为他并不希望外部软件设备使用该协议访问Redis集群节点。但你可以通过阅读集群源码中的cluster.h和cluter.c来获取更多详细信息。

集群拓扑结构

Redis集群是一个全网格,每个节点都使用TCP链接同其他节点相连。在由N个节点组成的集群中,每个节点都有N-1个外向TCP链接,和N-1个内向TCP链接。

这些TCP链接始终保持活动状态,并不会按需创建。当节点期望响应集群总线中的ping进行pong响应时,在等待足够长的时间以将该节点标记为不可访问之前,它将尝试通过从头重新连接来刷新与该节点的连接。

当Redis集群组成一个全网格,在正常情况下节点使用gossip协议和配置更新机制去避免交换过多的消息,因此交换的消息数量不是指数增长的。

节点握手

节点通常在集群总线端口接受链接,甚至会回复pong给不被信任的发送ping的节点。但是如果发送节点是不被集群的一部分,接收节点会丢弃该节点发送的所有其他的包。

一个节点仅在两种方式下接受另一个节点作为集群的一部分:

  • 如果一个节点使用MEET消息表达自己。meet消息和PING消息很像,但是强制接收者去接受这个节点作为集群的一部分。节点当且仅当系统管理员发送如下请求命令时才发送MEET消息:
    CLUSTER MEET ip port
    
  • 一个节点将会同样会注册另外一个节点作为集群的一部分,如果这个节点是被已经信任的节点发送gossip协议。所以如果A知道B,同时B知道C,最终B将发送关于C的gossip消息给A。当这种情况发生时,A将注册C作为网络的一部分,同时将尝试链接C。

这意味着只要我们在任何链接的图中加入节点,我们最终会自动组成一个全连接的图。这意味着集群能够自动发现其他节点,但前提是系统管理员强制建立了受信任关系。

这个机制使集群更加健壮,可以防止由于IP地址更改或其他网络事件后Redis集群意外混合。

重定向和重新分片

MOVED 重定向

一个Redis客户端可以自由的发送查询给集群中的每个节点,包括从节点。节点将会分析这个查询,如果该查询是可以接受的(也就是说,仅有一个键值在查询中涉及,或者涉及到的多个键值似使用了同一个hash tag),该节点将会查找哪个节点负责这个键值或多个具有相同hash tag的键值的哈希槽。如果这个哈希槽被这个节点服务,查询简单的执行,否则这个节点将会他的内部哈希槽和节点的映射,同时将会回复客户端一个MOVED错误,像下面的例子:

GET x
-MOVED 3999 127.0.0.1:6381

这个错误包含这个键值的哈希槽(3999)和可以服务这条查询的Redis实例的ip和端口。客户端需要重新补发这个查询到指定节点的IP和端口。注意即使客户端在补发查询前等了很长时间,在这期间,集群配置改变了,如果哈希槽(3999)被另外一个节点服务了,该实例将回复一个MOVED错误,如果联系的节点没有更新信息则会发生相同的情况。

所以从集群的观点看,节点被ID标记,我们尝试与客户端简化我们的接口仅仅暴露哈希槽和使用IP和端口对的Redis节点的映射。

客户端没有要求去这样做,但是应该试着记住哈希槽3999被127.0.0.1:6381服务。这种方式下,一旦一个新的链接需要被发布,他能计算目标键值的哈希槽,同时有很大的机会选择正确的节点。

一种替代方法是当收到MOVED重定向后,使用CLUSTER NODESCLUSTER SLOTS命令去刷新整个客户端侧的集群布局。当重定向发生时,可能需要重新配置多个哈希槽而不是一个,所以尽可能快的更新客户端配置经常是最好的策略。

注意当集群稳定时(配置中没有正在发生改变),最终所有的客户端将会收到一个哈希槽->节点的映射,使集群更加高效,客户端直接寻址到正确的节点,而不通过重定向,代理或者其他单点故障实体。

一个客户端必须同样实现 -ASK重定向,本文后面会描述,否则不是一个完整的Redis集群客户端。

集群实时重新配置

Redis集群提供集群正在运行时添加和删除节点的能力。添加和删除节点被抽象为一个同样的操作:从一个节点到另外一个节点移动哈希槽。这意味着为了平衡集群可以使用基本机制,添加删除节点等等。

  • 添加一个空节点到集群,一些哈希槽的集合从现在的节点移动到新节点。
  • 从集群中移除一个哈希槽被分配给其他存在的节点的节点。
  • 平衡集群,给定的一些哈希槽在节点间移动。

该实现的核心是移动哈希槽的能力。从实际的角度来看,哈希槽就是键值的集合,所以Redis集群在重新分片时就是将键值从一个实例移动到另外一个实例。移动哈希槽意味值移动所有的散列到该哈希槽的键值。

为了理解这个是怎样工作的,我们需要查看CLUSTER子命令,该命令用于操作Redis集群节点内的哈希槽转换表。

下面的子命令是可用的(其中有些在这种情况下无用):

前两个命令,ADDSLOTS和DELSLOTS,只是用作分配(或删除)一个Redis节点的哈希槽。分配一个哈希槽意味着告诉一个给定主节点,他将存储和服务指定哈希槽。 哈希槽被分配之后他们将使用gossip协议被传播到整个集群,将在后面的 配置传播章节涉及。

ADDSLOTS命令经常在新集群从草图创建,分配每个主节点16384个哈希槽的子集的时候使用。

DELSLOTS命令经常在手动修改集群配置以调试任务的时候使用:生产中很少使用。

SETSLOT子命令在使用SETSLOT NODE 格式时分配一个槽给指定的节点ID。除此之外槽可以被设置为两个特定的状态MIGRATING和IMPORTING。这两个状态被用在将哈希槽从一个节点迁移到另外一个节点。

  • 当一个节点被设置为MIGRATING,如果相关键值存在这个节点将会接受关于该哈希槽的所有查询,除此之外,查询通过使用-ASK重定向命令被转发到迁移的目标节点。

  • 当一个节点被设置为IMPORTING。如果一个请求被ASKING命令执行这个节点将会接收关于这个哈希槽的所有查询。如果客户端没有给到ASKING命令,查询将会向平常一样使用-MODED错误重定向到真实的哈希槽的拥有者。

让我们使用一个哈希槽迁移的例子更加清楚的描述。假设我们有两个Redis主机点,A和B。我们想要把哈希槽8从A迁移到B,因此我们像下面一样发起命令:

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

所有的其他节点将继续将客户端引导到A,当客户端查询一个属于哈希槽8的键值的查询的时候,因此发生了什么:

  • 所有关于存在键值的查询都被”A”执行。
  • 所有在”A”中不存在的键值都被”B”执行,因为”A”将重定向客户端到”B”。

这种情况下我们不在”A”中创建新的键值。同时,一个叫做redis-trib的特殊程序将在重新分片和Redis集群配置过程中,从A到B迁移哈希槽8中存在的键值。使用如下命令执行:

CLUSTER GETKEYSINSLOT slot count

上述命令将返回指定哈希槽中的count个键值。针对每个返回的键值,redis-trib发送”A”一个MIGRATE命令,该命令将原子的将特定的键值从A迁移到B,(两个实例都将短暂的被锁定一个短的时间(通常很短)去迁移键值,以避免竞争)。下面是MIGRATE怎样工作的:

MIGRATE target_host target_port key target_database id timeout

MIGRATE将连接到目标实例,发送键值的序列化版本,同时一旦收到OK反馈,键值将被从老的数据集中删除。外部客户端从这个观点来看键值要么在A要么在B。

在Redis集群中不需要指定除0之外的数据库,但是MIGRATE是一个可以不只在集群上使用的通用命令。MIGRATE已经尽可能的优化了,即使移动类似长的list的复杂键值,但在Redis集群中重新配置有大的键值的集群并不是一个明智的选择,在使用这个数据库对延迟敏感的程序。

当迁移程序最终完成,SETSLOT NODE 命令被发送到执行迁移的两个节点,目的是设置槽到他们的正常模式。相同的命令通常被发送到所有的其它节点以避免等待集群中的新配置的传播。

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到新的ip和端口的映射表。注意如果一个有问题的客户端过早的发送发布一个查询这并不会造成问题,因为在发送前并没有发送ASKING命令,所以B将使用MOVED错误重定向查询到A。

哈希槽迁移在CLUSTER SETSLOT命令文档中使用的相同的条款但是不同的语言描述(本篇不再赘述)。

客户但第一次链接同时处理重定向

Redis集群客户端可以实现为不在内存中记录哈希槽配置(哈希槽和服务于该哈希槽的节点地址的映射),同时仅使用发送命令给随机节点,然后重定向,这样的客户端会非常低效。

Redis集群客户应该试着足够聪明的记录哈希槽的配置。但是目前为止,并不是强制要求这样做的。由于联系到错误的节点将仅仅导致重定向,应该触发一个客户端视图的更新。

客户端通常需要拿到哈希槽的完整列表,在两种不同的情况下映射节点地址:

  • 启动时目的填充初始槽配置。
  • 当收到MOVED重定向。

注意客户端可能通过更新表中发生moved的槽来应对MOVED重定向,但这通常不是有效的应对方式,因为往往是多个槽同时发生配置变动(例如从节点被提升为主节点,所有被老的主节点服务的槽都将重新映射)。所以简单有效的做法是收到MOVED错误从草图中拉取全量的槽和节点的映射。

为了找回槽的配置,Redis集群提供了一个另类的方式,CLUSTER NODES命令,不要解析,提供客户端需要的信息。

叫做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初试化填充槽配置映射,如果用户执行了没有分配的槽的键值,应该返回一个错误。

在返回调用者由于没有分配导致的错误的时,客户端应该重新去取一遍槽配置去检验集群是否已经正确配置。

多键值操作

使用hash tags,客户端可以自由的使用多键值操作,例如如下操作时可用的:

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

多键值操作可能在键值所属的槽正在发生分片时不可用。

更具体的,在重新分片期间,多键值操作的键值都存在,并仍然哈希到同一个槽中(源或者目标节点)并仍然可用。

对不存在或在重新分片期间源节点和目标节点拆分的键值进行操作将生成-TRYAGAIN错误。客户端可以稍后重试或者报告该错误。

只要特定的哈希槽迁移结束,针对该哈希槽的所有多键值操作都可用。

使用从节点扩展读

通常,从节点会将重定向客户端到给定命令涉及的哈希槽的权威主节点,但是客户端可以使用READONLY命令使用从节点从而扩展读。

READONLY命令告诉Redis集群从节点客户端可以读取陈旧数据,但是不能执行写操作。

当链接是readonly模式,集群在操作涉及的键值不被从节点的主节点服务的情况下会发送重定向给客户端。这种情况发生的原因为:

  1. 客户端发送了一个关于哈希槽的命令,而这个哈希槽从没有被从节点的主节点所服务。
  2. 集群被重新配置(例如重新分片),从节点不再为指定的哈希槽服务。

当这些发生时,客户端应该更新哈希槽的映射,就像前面提到的一样。 链接的readonly状态可以被READWRITE命令清除。

容错能力

心跳和gossip消息

Redis集群节点持续交换ping包和pong包。这两个包有相同的数据结构,并且都包含重要的配置信息。仅有的实际差别是消息类型字段。我们将ping包和pong包的数据总和叫做 心跳数据包 。 通常节点发送ping包会触发接收方发送pong包。但发送pong包的情况并不总是这样。节点可以仅通过发送pong包告诉其他节点配置信息,而不触发一个回复。这很有用,例如尽快广播一个新的配置。

通常节点每秒将ping一些随机节点,这样每个节点发送的总ping包数(接收到的总pong包数)是恒定的,而不会因为集群节点数量而改变。

但是每个节点要确保在NODE_TIMEOUT的一半的时间内ping了其他的每个一个节点,并收到了回复。NODE_TIME流逝之前节点将和另外的一个节点尝试重连,以保证并不是节点不可达,而是由于当前的tcp链接有问题。

如果NODE_TIMEOUT设置了一个小的数,而节点数量(N)又非常大,全局交换的消息可能会比较大,因为每个节点将对NODE_TIMEOUT一半时间内没有新信息的节点发送ping消息。

例如,由100个节点组成的集群,节点超时时长设置为60秒,每个节点每30秒将试着发送99个ping包,平均每秒发送3.3个。乘以100个节点,整个集群就是每秒330个ping包。

有方法降低消息的数量,到目前为止Redis集群探测错误的这种方式并没有报告带宽相关的问题,所以现在使用明显而直接的设计。注意,即便在上述例子中,每秒330个ping包也是分拆给了100个节点,每个节点的网络流量是可接受的。

心跳包内容

ping包和pong包包含一个所有类型包(例如请求故障转移投票的数据包)的通用头,已经专门针对ping包和pong包的专门的gossip段。

公共头有如下信息:

  • 节点ID,160bit的伪随机字符串,节点第一次创建的时间被分配并伴随Redis集群节点生命周期保持一致。
  • 发送节点的currentEpoch和configEpoch字段,被用作安装被Redis集群使用的分布式算法(下一章节详细解释)。如果节点是从节点,configEpoch是他知道的其主节点的最后的configEpoch。
  • 节点标志,表明节点是从节点或主节点以及其他single-bit节点信息。
  • 被发送节点服务的哈希槽的位图,如果节点是从节点则是被他的主节点服务的槽的位图。
  • 发送方的TCP的基本端口(被Redis用来接收命令的端口,加10000的端口是集群总线端口)。
  • 从发送方视角看到的集群状态(down 或者 ok)。
  • 发送节点的主节点ID,如果发送方是从节点的话。

ping包和pong包同样包含一个gossip段。这节提供给接收者集群中发送者认为的其他节点的视图。gossip段仅包含该发送者知道的节点中的一些随机节点。在gossip段中提到的节点的数量和集群的大小相关。

在gossip段中每个节点的如下信息被报告:

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

gossip段允许接收节点得到发送节点视角得到的其他节点的状态信息。这有利于失败探测和发现集群中其他节点。

失败探测

Redis集群失败探测用来发觉主节点或从节点不能被大多数节点到达的情况,然后提升一个从节点为主节点。当从节点提升不能完成集群进入一个错误状态,停止从客户端接收查询。

就像已经提过的,每个节点携带了其他已知节点的标志列表。有两个标志用来标志错误探测PFAIL和FAIL。PFAIL意味着 可能失败 ,一个未确认的错误类型。FAIL意味着节点已经失败同时这个情况已经被大多数节点在固定时间内确认。

PFAIL flag:

一个节点标志另外一个节点为PFAIL表明这个节点已经多于NODE_TIMEOUT时间不可达。主节点和从节点都可以标志另外的节点为PFAIL无论他是什么节点类型。 Redis集群节点的不可达概念是,我们有一个活动的ping(我们发送的ping但是我们没有收到回复)等待了多余NODE_TIMEOUT。这种机制下,NODE_TIMEOUT要相比网络往返时间大。为了在正常情况下增加可靠性,只要NODE_TIMEOUT一半的时间还没哟收到ping的回复。节点将重新链接目标节点。这个机制保证保持存活,因此端开的链接通常不会造成节点间报告错误。

FAIL flag:

仅PFAIL标志只是每个节点关于其他节点的本地信息,但这不足以触发一个从节点提升。对于一个节点考虑被认定为错误,PFAIL情况需要被升级为FAIL情况。 在本文的节点心跳章节提到过,每个节点发送gossip消息给其他节点携带了一些随机已知的节点的状态。每个节点最终收到了每个其他节点的节点标志集合。这种方式下每个节点有一个标志探测到其他节点的失败情况的机制。

当满足以下条件时一个PFAIL情况升级为FAIL情况:

  • 有些节点,我们叫他A,标志其他节点B为PFAIL。
  • 节点A通过gossip段收集集群大部分主节点关于节点B的状态。
  • 大多数主节点在NODE_TIMEOUT*FAIL_REPORT_VALIDITY_MULT(当前实现中,有效因子被设置为2,所以就是两倍NODE_TIMEOUT时长)时间内发出PFAIL或FAIL信号。

如果上述所有条件都满足,节点A将:

  • 标记节点为FAIL
  • 发送FAIL消息给所有可到达的节点

FAIL消息将强制所有接收的节点标志该节点为FAIL状态,而不论是否该节点已经将该节点标志为PFAIL状态。

注意 FAIL标志基本上是单向的 。也就是说,一个节点可以从PFAIL转变为FAIL,但是FAIL标志仅能在如下情况下被清除:

  • 节点已经可达,同时是一个从节点。这种情况下FAIL标志可以被清除,因为从节点不会故障转移。
  • 节点已经可达,同时节点不服务于任何哈希槽。这种情况下FAIL可以被清除,因为没有哈希槽的主节点并不真实的参与到集群中,同时等待被配置,以便重新加入集群。
  • 节点已经可达,同时是主节点,但是长时间(N倍NODE_TIMEOUT)后没有探测到任何从节点提升。最好重新加入集群,并在这种情况下继续。

值得注意的是,当从PFAIL->FAIL转变过程中,使用一种形式的协议,但是使用的这种协议很弱:

1.节点在一定时间内收集其他节点的视图,因此即便大部分主节点需要”同意”,实际上这仅能表明我们在不同时间从不同的节点收集到的信息同时我们不确定,也不需要给定时间大多数主节点同意。然而我们丢弃老的错误报告,因此被大多数主节点示意的错误是在一个时间窗口发出的。 2.尽管每个检测到FAIL条件的节点都将使用FAIL消息将该条件强加给集群中的其他节点,没有方法保证消息会被送达所有节点。例如一个节点可能探测到FAIL条件,由于分区原因不能送到任何其他节点。

然而Redis集群错误探测有一个灵活要求:最终所有的节点应该同意给定节点的状态。有两种分裂的情况:要么一小部分节点相信节点在FAIL状态,或者一小部分节点相信节点没有在FAIL状态。在这两种情况下,集群最终都将具有给定节点的单一视图:

情况1:如果大多数主节点标志节点为FAIL,由于错误探测和他产生的 连锁效应 每个其他节点将最终标志主节点为FAIL,因为在特定的时间窗口内,会报告足够的错误。 情况2:当仅有一小部分主节点标志节点为FAIL,从节点提升将不会发生(由于他用了一个更加正式的算法确保每个人最后知道这个提升),并且每个节点会按照上面的FAIL清除规则清除FAIL标志(例如,N倍NODE_TIMEOUT时间后,仍没有从节点提升)。

FAIL标志仅用作运行从节点提升算法的安全部分的触发器。理论上从节点在他的主节点不可达的时候可以独立启动从节点提升,如果主节点被大多数主节点可达这些主节点会提供拒绝的确认。但是添加的PFAIL->FAIL的复杂状态的转变,弱同意机制以及在短时间内,集群可达部分强制传播FAIL消息有实际的好处。由于这些机制,当节点处于错误状态的时所有的节点同时将拒绝写入。从使用Redis集群的程序的角度看这是可取的特性。同时由于从节点本地的错误(主节点被大多数其他主节点可达)而发起的选举尝试被避免。

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

集群当前时代

Redis集群使用了一个和Raft相似的算法。在Redis集群中这个术语叫做epoch,他用于为事件提供增量版本控制。当多个节点提供了冲突的信息,他能使另外的节点理解哪个状态是最新的。

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

在每个Redis集群节点创建时,从节点和主节点设置currentEpoch为0。

每次从其他节点收到包时,如果发送方(集群总线消息头的部分)的epoch比本地节点的epoch大,currentEpoch更新到发送方的epoch。

由于这些操作,集群中的所有节点最终都会一直成为最大的currentEpoch。

当集群状态改变,同时节点寻求协议以执行某些操作时将使用该信息。

当前仅当从节点提升时(下个章节会描述)发生。基本上epoch是集群的一个逻辑时钟,他决定一个给定的消息赢了另一个带着更小epoch的消息。

配置epoch

每个主节点经常在在ping和pong包中宣传他的configEpoch,伴随宣传的还有他服务的槽的bitmap。

当一个新节点创建时configEpoch被设置为0。

在从节点选举过程中,一个新的configEpoch被创建。从节点试着取代失败的主节点,增加他的epoch并尝试从大多数主节点中得到认证。当从节点被认证,一个新的不同的configEpoch被创建,从节点使用新的configEpoch转变为主节点。

在下一节描述的configEpoch帮助解决处理冲突,当不同节点声明不同的配置的时候(这种情况发生在网络分区或节点失败的情况下)。

从节点也在ping和pong包中宣传这个configEpoch字段,但是如果是从节点,这个字段表示他和主节点交换的包的主节点的configEpoch。这样允许其他实例去探测一个从节点有一个老的配置需要升级(主节点不会授权一个具有老的配置时代的从节点投票权)。

每次针对一些已知节点configEpoch发生改变,将会被所有接收到这个信息的节点永久存储在nodes.conf文件中。针对currentEpoch同样适用。在节点继续操作之前要确保这两个变量更新存储并同步到磁盘。

configEpoch的值使用一个简单的算法在故障转移的过程中,保证这个值是一个新的,增加的独一无二的值。

从节点选举和提升

从节点选举和提升被从节点掌控,在主节点投票从节点提升。从至少一个从节点的角度来看,当主节点在FAIL状态情况下,是从节点选举的必要条件,当主节点处于FAIL状态时,会发生从节点提升。

为了让从节点提升自己到主节点,他需要启动一个选举,并胜出。如果主节点处于FAIL状态,指定主节点的左右从节点可以启动一个选举,但是仅有一个从节点将要胜出选举,并提示自己到主节点。

当如下情况都发生时,从节点启动一个选举:

  • 从节点的主节点在FAIL状态。
  • 主节点服务了一个不为零的槽。
  • 从节点的备份连接和主节点端开连接不超过给定的时间,目的是保证提升的从节点的数据是合理的新鲜的。时长是用户配置的。

为了去当选,从节点的第一步是增加他的currentEpoch计数,同时从主节点实例请求投票。

从节点广播一个FAILOVER_AUTH_REQUEST包给集群中的每个主节点要求投票。然后他等待最多两倍的NODE_TIMEOUT回复(通常至少2秒)。

一旦一个主节点投票给了指定的从节点,会积极的回应一个FAILOVER_AUTH_ACK,在两倍的NODE_TIMEOUT时间内他不能再投票给另外的一个从节点了。这不保证保险,但是却对防止在大约同一时间多个从节点当选有帮助(即使存在不同的configEpoch),当然同一时间多个configEpoch是经常出现,但不是想要的。

在投票要求被发送的时候,从节点丢弃的任何epoch小于currentEpoch的AUTH_ACK回复。这保证他不会将之前的投票计数计算在内。

一旦从节点接收到了大部分主节点的ACK,他将赢得这场选举。除非大部分主节点在两倍的NODE_TIMEOUT(但是至少2秒)内不可达,这次选举就会被舍弃,同时一个新的在NODE_TIMEOUT*4(通常至少4秒)将会重试。

从节点等级

一旦主节点在FAIL状态,从节点在选举前等待一小段时间。这段等待时间按照如下计算:

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

固定的延迟保证等待FAIL状态在集群中传播,否则从节点可能尝试启动选举,而主节点并不知道FAIL状态,拒绝投票。

随机的延迟被用作同步从节点,这样他们不会同时启动一个选举。

SLAVE_RANK是从节点的等级。关于从节点从主节点处理处理的备份数据的数量。

当主节点发生故障时,从节点交换消息来(最大努力)来建立一个等级:复制偏移量最新的从节点的等级是0,第二新的等级是1 ,以此类推。这种方式下最新的从节点能在其他节点之前尝试当选。

等级顺序并未完全严格执行;如果更高等级的从节点选举失败,其他从节点将会尽快尝试。

一旦一个从节点赢得了选举,他将得到一个新的不同的,增加的configEpoch,这个configEpoch将被任何其他现存的主节点高。他开始通过ping包和pong包传播自己,为提供服务的插槽集提供configEpoch,他将胜过过去的插槽。

为了加速其他节点的重新配置,一个pong包广播到集群的所有节点。当前不可达节点将最终被重新配置。当前不可达节点将通过其他节点接收到ping包或pong包最终被重新配置,或者将从别的节点收到一个更新包,当他发送的心跳包被探测为过期的情况下。

其他节点将探测到有一个新的主节点服务相同的哈希槽,但是比之前的主节点的configEpoch更大,同时将升级他的配置。老的主节点(或发生故障转移的主节点,重新加入集群)不仅会升级配置,同时会重新配置从主节点备份。下一章节将解释节点怎样重新加入集群被配置。

主节点回复从节点投票请求

前面的章节讨论了从节点怎样被选举。这个章节解释从主节点的观点看指定从节点请求投票将要发生的事情。

主节点从从节点以FAILOVER_AUTH_REQUEST请求的方式收到投票请求。

针对一个授权的投票如下情况需要同时具备:

  1. 一个主节点给指定epoch仅投票一次,同时拒绝给更老的epoch投票:每个主节点有个一个lastVoteEpoch字段同时将拒绝给从节点auth请求中currectEpoch小于lastVoteEpoch字段的节点投票。当一个主节点对一次投票请求给予了肯定,相应的lastVoteEpoch将被更新,同时被安全的存储在硬盘。

2.一个主节点投票给一个从节点,仅当该从节点的主节点被标记为FAIL。

3.auth请求中携带的currentEpoch小于当前主节点的currentEpoch将被忽略。由于这个主节点的回复将和auth的请求有一样的currentEpoch。如果相同的从节点再次要求投票,增加currentEpoch,确保新的投票不能接受来自主节点旧延迟答复。

如果没有使用规则3可能引起问题的情况:

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

  • 从节点currentEpoch是3。
  • 从节点使用4(3+1)epoch尝试去被选举,主节点使用currentEpoch 5返回ok,然而这个回复延迟了。
  • 一段时间后,从节点将使用epoch 5(4+1)试着再次被选举,这个延迟的回复携带currentEpoch 5到达从节点,同时被接收为可用。