Redis集群规范
Redis集群的详细规范
欢迎来到Redis集群规范。在这里,您将找到关于Redis集群的算法和设计原理的信息。本文档是一个正在进行的工作,因为它与Redis的实际实现持续同步。
设计的主要属性和原理
Redis 集群目标
Redis Cluster 是 Redis 的分布式实现,设计时按重要性顺序有以下目标:
- 高性能和线性扩展性,最多可扩展到1000个节点。没有代理,使用异步复制,并且不对值执行合并操作。
- 可接受的写入安全程度:系统尽力保留来自与大多数主节点连接的客户端的写入。通常存在小的窗口期,其中已确认的写入可能会丢失。当客户端处于少数分区时,丢失已确认写入的窗口期更大。
- 可用性:Redis 集群能够在大多数主节点可达且每个不再可达的主节点至少有一个可达副本的情况下存活。此外,使用副本迁移,不再被任何副本复制的主节点将从被多个副本覆盖的主节点接收一个副本。
本文档中描述的内容在Redis 3.0或更高版本中实现。
已实现的子集
Redis Cluster 实现了非分布式版本 Redis 中所有可用的单键命令。对于涉及集合并集和交集等复杂多键操作的情况,只有在操作中涉及的所有键都哈希到同一个槽时,才会实现这些命令。
Redis 集群实现了一个称为哈希标签的概念,可以用来强制某些键存储在同一个哈希槽中。然而,在手动重新分片期间,多键操作可能会在一段时间内不可用,而单键操作始终可用。
Redis Cluster 不支持像独立版本的 Redis 那样的多个数据库。我们只支持数据库 0
;不允许使用 SELECT
命令。
Redis集群协议中的客户端和服务器角色
在Redis集群中,节点负责保存数据,并维护集群的状态,包括将键映射到正确的节点。集群节点还能够自动发现其他节点,检测失效节点,并在需要时将副本节点提升为主节点,以便在发生故障时继续运行。
为了执行它们的任务,所有集群节点都通过一个TCP总线和一种称为Redis集群总线的二进制协议连接。每个节点都通过集群总线连接到集群中的其他每个节点。节点使用一种八卦协议来传播关于集群的信息,以便发现新节点,发送ping包以确保所有其他节点正常工作,并发送集群消息以信号特定条件。集群总线还用于在整个集群中传播发布/订阅消息,并在用户请求时协调手动故障转移(手动故障转移不是由Redis集群故障检测器启动的,而是由系统管理员直接启动的)。
由于集群节点无法代理请求,客户端可能会被重定向到其他节点,使用重定向错误-MOVED
和-ASK
。理论上,客户端可以自由地向集群中的所有节点发送请求,并在需要时被重定向,因此客户端不需要保持集群的状态。然而,能够缓存键和节点之间映射的客户端可以显著提高性能。
写入安全
Redis 集群在节点之间使用异步复制,并且具有最后故障转移获胜的隐式合并功能。这意味着最后选举的主数据集最终会替换所有其他副本。在分区期间,总是存在一个可能丢失写入的时间窗口。然而,这些时间窗口在连接到大多数主节点的客户端和连接到少数主节点的客户端之间有很大的不同。
Redis Cluster 相比于在少数派一侧执行的写入,会尽力保留由连接到大多数主节点的客户端执行的写入。 以下是导致在故障期间丢失在大多数分区中已确认写入的场景示例:
-
写入可能会到达主节点,但尽管主节点可能能够回复客户端,写入可能不会通过主节点和副本节点之间使用的异步复制传播到副本。如果主节点在没有将写入传播到副本的情况下死亡,并且主节点在足够长的时间内无法访问以至于其副本之一被提升,那么写入将永远丢失。这通常在主节点完全突然故障的情况下很难观察到,因为主节点尝试同时回复客户端(确认写入)和副本(传播写入)。然而,这是一个现实世界中的故障模式。
-
另一种理论上可能的写入丢失的故障模式如下:
- 由于分区,主节点无法访问。
- 它会被其中一个副本接管。
- 一段时间后,它可能再次可达。
- 客户端如果拥有过时的路由表,可能会在集群将其转换为新主节点的副本之前,向旧主节点写入数据。
第二种故障模式不太可能发生,因为主节点无法与大多数其他主节点通信足够长的时间以进行故障转移,将不再接受写入,当分区修复后,写入仍然会在短时间内被拒绝,以允许其他节点通知配置更改。这种故障模式还要求客户端的路由表尚未更新。
针对分区中少数派一侧的写入操作有更大的窗口可能会丢失。例如,Redis 集群在少数主节点和至少一个或多个客户端的分区上会丢失相当数量的写入,因为如果主节点在多数派一侧发生故障转移,所有发送到主节点的写入可能会丢失。
具体来说,要使主节点进行故障转移,它必须在大多数主节点无法访问的情况下至少持续NODE_TIMEOUT
时间,因此如果在该时间之前分区被修复,则不会丢失任何写入。当分区持续时间超过NODE_TIMEOUT
时,少数派一侧在该时间点之前执行的所有写入可能会丢失。然而,Redis集群的少数派一侧在NODE_TIMEOUT
时间过去后,如果没有与多数派联系,将开始拒绝写入,因此存在一个最大窗口期,之后少数派将不再可用。因此,在该时间之后,不会接受或丢失任何写入。
可用性
Redis 集群在分区的少数侧不可用。在分区的多数侧,假设至少有大多数主节点和每个不可达主节点的一个副本,集群在NODE_TIMEOUT
时间加上副本被选举并接管其主节点所需的几秒钟后再次变为可用(故障转移通常在一两秒内完成)。
这意味着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中的值通常非常大;常见的是包含数百万个元素的列表或有序集合。此外,数据类型在语义上也很复杂。传输和合并这类值可能是一个主要的瓶颈,并且/或者可能需要应用程序端逻辑的非平凡参与、额外的内存来存储元数据等等。
这里没有严格的技术限制。CRDTs 或同步复制的状态机可以建模类似于 Redis 的复杂数据类型。然而,这类系统的实际运行时行为不会类似于 Redis 集群。Redis 集群的设计是为了覆盖非集群 Redis 版本的确切用例。
Redis集群主要组件概述
密钥分发模型
集群的键空间被分割成16384个槽,这实际上为集群的大小设置了一个上限,即最多可以有16384个主节点(然而,建议的最大节点数量大约在1000个节点左右)。
集群中的每个主节点处理16384个哈希槽的一个子集。 当集群中没有进行重新配置时(即哈希槽从一个节点移动到另一个节点),集群是稳定的。 当集群稳定时,单个哈希槽将由单个节点提供服务 (然而,服务节点可以有一个或多个副本,在网络分裂或故障时替换它, 并且可以用于扩展读取操作,其中读取过时数据是可接受的)。
用于将键映射到哈希槽的基本算法如下(请阅读下一段以了解此规则的哈希标签例外情况):
HASH_SLOT = CRC16(key) mod 16384
CRC16 的规范如下:
- 名称: XMODEM (也称为 ZMODEM 或 CRC-16/ACORN)
- 宽度:16位
- Poly: 1021 (实际上是 x^16 + x^12 + x^5 + 1)
- 初始化:0000
- 反射输入字节:False
- 反射输出CRC:False
- 对输出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风格模式
接受通配符样式模式的命令,包括KEYS
、SCAN
和SORT
,针对暗示单个槽的模式进行了优化。
这意味着如果所有可以匹配模式的键必须属于特定槽,则仅搜索此槽以查找匹配模式的键。
模式槽优化在Redis 8.0中引入。
当模式满足以下条件时,优化开始生效:
- 模式包含一个标签,
- 在井号之前没有通配符或转义字符,并且
- 花括号内的hashtag不包含任何通配符或转义字符。
例如,SCAN 0 MATCH {abc}*
可以成功识别标签并仅扫描与 abc
对应的槽。
然而,模式 *{abc}
、{a*c}
或 {a\*bc}
无法识别标签,因此需要扫描所有槽。
哈希槽示例代码
添加哈希标签异常,以下是HASH_SLOT
函数在Ruby和C语言中的实现。
Ruby 示例代码:
def HASH_SLOT(key)
s = key.index "{"
if s
e = key.index "}",s+1
if e && e != s+1
key = key[s+1..e-1]
end
end
crc16(key) % 16384
end
C示例代码:
unsigned int HASH_SLOT(char *key, int keylen) {
int s, e; /* start-end indexes of { and } */
/* Search the first occurrence of '{'. */
for (s = 0; s < keylen; s++)
if (key[s] == '{') break;
/* No '{' ? Hash the whole key. This is the base case. */
if (s == keylen) return crc16(key,keylen) & 16383;
/* '{' found? Check if we have the corresponding '}'. */
for (e = s+1; e < keylen; e++)
if (key[e] == '}') break;
/* No '}' or nothing between {} ? Hash the whole key. */
if (e == keylen || e == s+1) return crc16(key,keylen) & 16383;
/* If we are here there is both a { and a } on its right. Hash
* what is in the middle between { and }. */
return crc16(key+s+1,e-s-1) & 16383;
}
集群节点属性
集群中的每个节点都有一个唯一的名称。节点名称是一个160位随机数的十六进制表示,该随机数在节点首次启动时获得(通常使用/dev/urandom)。节点会将其ID保存在节点配置文件中,并且会永久使用相同的ID,或者至少在系统管理员未删除节点配置文件或通过CLUSTER RESET
命令请求硬重置之前使用。
节点ID用于在整个集群中识别每个节点。 对于给定的节点,可以更改其IP地址而无需更改节点ID。集群还能够检测IP/端口的变化,并通过在集群总线上运行的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的节点不被信任。然而,如果发送节点不被视为集群的一部分,接收节点将丢弃所有其他数据包。
一个节点只有在两种情况下才会接受另一个节点作为集群的一部分:
-
如果一个节点通过
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节点之间的映射,这些Redis节点通过端点:端口对来识别。
客户端不需要,但应该尝试记住哈希槽3999由127.0.0.1:6381提供服务。这样一旦需要发出新命令,它可以计算目标键的哈希槽,并有更大的机会选择正确的节点。
另一种方法是,在接收到MOVED重定向时,使用CLUSTER SHARDS
或已弃用的CLUSTER SLOTS
命令来刷新整个客户端集群布局。当遇到重定向时,很可能多个槽位被重新配置,而不仅仅是一个,因此尽快更新客户端配置通常是最佳策略。
请注意,当集群稳定时(配置中没有正在进行的更改),最终所有客户端都将获得一个哈希槽到节点的映射,从而使集群高效运行,客户端可以直接访问正确的节点,而无需重定向、代理或其他单点故障实体。
客户端还必须能够处理-ASK重定向,这些重定向将在本文档后面描述,否则它不是一个完整的Redis集群客户端。
实时重新配置
Redis 集群支持在集群运行时添加和删除节点。添加或删除节点被抽象为相同的操作:将哈希槽从一个节点移动到另一个节点。这意味着可以使用相同的基本机制来重新平衡集群、添加或删除节点等。
- 要向集群添加新节点,需将一个空节点加入集群,并将一些哈希槽从现有节点移动到新节点。
- 要从集群中移除一个节点,分配给该节点的哈希槽将被移动到其他现有节点。
- 为了重新平衡集群,一组给定的哈希槽在节点之间移动。
实现的核心是能够移动哈希槽。 从实际角度来看,哈希槽只是一组键,因此 Redis 集群在重新分片期间真正做的是将键从一个实例移动到另一个实例。移动一个哈希槽意味着移动所有恰好哈希到这个哈希槽的键。
要理解这是如何工作的,我们需要展示用于操作Redis集群节点中的槽位转换表的CLUSTER
子命令。
以下子命令可用(在本例中无用的其他子命令除外):
CLUSTER ADDSLOTS
槽1 [槽2] ... [槽N]CLUSTER DELSLOTS
槽1 [槽2] ... [槽N]CLUSTER ADDSLOTSRANGE
开始槽1 结束槽1 [开始槽2 结束槽2] ... [开始槽N 结束槽N]CLUSTER DELSLOTSRANGE
开始槽1 结束槽1 [开始槽2 结束槽2] ... [开始槽N 结束槽N]CLUSTER SETSLOT
槽位 节点 节点CLUSTER SETSLOT
槽位 迁移 节点CLUSTER SETSLOT
槽位 导入 节点
前四个命令,ADDSLOTS
、DELSLOTS
、ADDSLOTSRANGE
和 DELSLOTSRANGE
,仅用于分配(或移除)Redis节点的槽。分配槽意味着告诉给定的主节点,它将负责存储和提供指定哈希槽的内容。
在哈希槽分配完成后,它们将通过gossip协议在集群中传播,具体内容将在配置传播部分中详细说明。
ADDSLOTS
和 ADDSLOTSRANGE
命令通常在新集群从头创建时使用,用于为每个主节点分配所有可用的16384个哈希槽的子集。
DELSLOTS
和 DELSLOTSRANGE
主要用于手动修改集群配置或调试任务:在实际应用中很少使用。
SETSLOT
子命令用于将槽分配给特定的节点ID,如果使用 SETSLOT
形式。否则,槽可以设置为两种特殊状态 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
命令会被发送到参与迁移的两个节点,以便将槽设置回正常状态。通常,相同的命令也会发送给所有其他节点,以避免等待新配置在集群中的自然传播。
ASK重定向
在上一节中,我们简要讨论了ASK重定向。为什么我们不能简单地使用MOVED重定向?因为MOVED意味着我们认为哈希槽永久由不同的节点提供服务,并且下一个查询应该针对指定的节点进行尝试。ASK意味着只将下一个查询发送到指定的节点。
这是必要的,因为下一个关于哈希槽8的查询可能涉及仍在A中的键,所以我们总是希望客户端先尝试A,然后在需要时尝试B。由于这只发生在16384个可用哈希槽中的一个,对集群的性能影响是可以接受的。
我们需要强制客户端行为,以确保客户端只有在尝试节点A之后才会尝试节点B,节点B只有在客户端在发送查询之前发送ASKING命令时,才会接受设置为IMPORTING的槽的查询。
基本上,ASKING 命令在客户端设置了一个一次性标志,强制节点响应关于 IMPORTING 槽的查询。
从客户端的角度来看,ASK重定向的完整语义如下:
- 如果接收到ASK重定向,仅发送被重定向到指定节点的查询,但继续将后续查询发送到旧节点。
- 使用ASKING命令启动重定向查询。
- 暂时不要更新本地客户端表以将哈希槽8映射到B。
一旦哈希槽8的迁移完成,A将发送一个MOVED消息,客户端可能会永久地将哈希槽8映射到新的端点端口对。请注意,如果存在问题的客户端提前执行了映射,这并不是一个问题,因为它不会在发出查询之前发送ASKING命令,所以B将使用MOVED重定向错误将客户端重定向到A。
插槽迁移在CLUSTER SETSLOT
命令文档中以类似的术语但不同的措辞进行了解释(为了文档的冗余性)。
客户端连接和重定向处理
为了提高效率,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,如果该节点在超过NODE_TIMEOUT
时间的一半内没有发送ping或接收到pong。在NODE_TIMEOUT
时间过去之前,节点还会尝试重新建立与另一个节点的TCP连接,以确保节点不会仅仅因为当前TCP连接存在问题而被认为不可达。
如果NODE_TIMEOUT
设置为一个较小的值并且节点数量(N)非常大,那么全局交换的消息数量可能会相当大,因为每个节点都会尝试对每个没有最新信息的其他节点进行ping操作,每半NODE_TIMEOUT
时间一次。
例如,在一个有100个节点的集群中,节点超时设置为60秒,每个节点将每30秒尝试发送99个ping,每秒总共有3.3个ping。乘以100个节点,整个集群中每秒有330个ping。
有方法可以减少消息的数量,但目前没有报告关于Redis集群故障检测所使用的带宽问题,因此目前采用了明显且直接的设计。请注意,即使在上面的例子中,每秒交换的330个数据包也均匀分布在100个不同的节点上,因此每个节点接收的流量是可接受的。
心跳包内容
Ping和pong数据包包含一个所有类型数据包共有的头部(例如请求故障转移投票的数据包),以及一个特定于Ping和Pong数据包的特殊的gossip部分。
公共头包含以下信息:
- 节点ID,一个160位的伪随机字符串,在节点首次创建时分配,并在Redis集群节点的整个生命周期内保持不变。
- 发送节点的
currentEpoch
和configEpoch
字段用于挂载Redis集群使用的分布式算法(这将在接下来的部分详细解释)。如果节点是一个副本,configEpoch
是其主节点的最后已知configEpoch
。 - 节点标志,指示节点是否为副本、主节点以及其他单比特节点信息。
- 发送节点服务的哈希槽位图,或者如果节点是副本,则为其主节点服务的槽位图。
- 发送方TCP基础端口,即Redis用于接受客户端命令的端口。
- 集群端口,即Redis用于节点间通信的端口。
- 从发送者的角度看集群的状态(down 或 ok)。
- 发送节点的主节点ID,如果它是一个副本。
Ping和pong数据包还包含一个gossip部分。这部分向接收者提供了发送节点对集群中其他节点的看法。gossip部分仅包含发送者已知节点集合中一些随机节点的信息。gossip部分中提到的节点数量与集群大小成正比。
对于在gossip部分添加的每个节点,报告以下字段:
- 节点ID。
- 节点的IP和端口。
- 节点标志。
Gossip 部分允许接收节点从发送者的角度获取有关其他节点状态的信息。这对于故障检测和发现集群中的其他节点都很有用。
故障检测
Redis集群故障检测用于识别当主节点或副本节点不再被大多数节点访问时,然后通过将副本提升为主节点的角色来响应。当无法进行副本提升时,集群将进入错误状态,以停止接收来自客户端的查询。
如前所述,每个节点都会接收一个与其他已知节点相关的标志列表。有两个用于故障检测的标志,分别称为PFAIL
和FAIL
。PFAIL
表示可能的故障,是一种未经确认的故障类型。FAIL
表示节点正在发生故障,并且这种情况在固定时间内得到了大多数主节点的确认。
PFAIL 标志:
当一个节点在超过NODE_TIMEOUT
时间无法访问时,另一个节点会使用PFAIL
标志标记该节点。无论是主节点还是副本节点,都可以将另一个节点标记为PFAIL
,无论其类型如何。
Redis集群节点的不可达概念是指我们有一个活跃的ping(我们发送的ping尚未收到回复)挂起的时间超过了NODE_TIMEOUT
。为了使这个机制有效,NODE_TIMEOUT
必须比网络往返时间大得多。为了在正常操作期间增加可靠性,节点将在NODE_TIMEOUT
的一半时间过去而没有收到ping的回复时,尝试重新连接集群中的其他节点。这个机制确保连接保持活跃,因此断开的连接通常不会导致节点之间的错误故障报告。
失败标志:
单独的PFAIL
标志只是每个节点关于其他节点的本地信息,但不足以触发副本提升。要将一个节点视为下线,需要将PFAIL
条件升级为FAIL
条件。
如本文档的节点心跳部分所述,每个节点都会向其他每个节点发送包含一些随机已知节点状态的八卦消息。每个节点最终都会接收到其他每个节点的一组节点标志。通过这种方式,每个节点都有一个机制来向其他节点发出它们检测到的故障条件的信号。
当满足以下条件集时,PFAIL
状态会升级为 FAIL
状态:
- 某个节点,我们称之为A,将另一个节点B标记为
PFAIL
。 - 节点A通过gossip部分收集了关于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
标志,因为不服务槽的主节点实际上并不参与集群,并且正在等待配置以加入集群。 - 该节点已经可达并且是主节点,但是很长时间(
NODE_TIMEOUT
的N倍)已经过去,没有任何可检测到的副本提升。在这种情况下,最好重新加入集群并继续。
值得注意的是,虽然PFAIL
-> FAIL
的转换使用了一种形式的协议,但所使用的协议是弱的:
- 节点在一段时间内收集其他节点的视图,因此即使大多数主节点需要“同意”,实际上这只是我们在不同时间从不同节点收集的状态,我们不确定也不要求在给定时刻大多数主节点达成一致。然而,我们会丢弃旧的故障报告,因此故障是在一段时间内由大多数主节点报告的。
- 虽然每个检测到
FAIL
条件的节点都会使用FAIL
消息强制将该条件施加到集群中的其他节点上,但无法确保消息会到达所有节点。例如,一个节点可能检测到FAIL
条件,但由于分区而无法到达任何其他节点。
然而,Redis集群的故障检测有一个活跃性要求:最终所有节点应该对给定节点的状态达成一致。有两种情况可能源于脑裂条件。要么少数节点认为节点处于FAIL
状态,要么少数节点认为节点不处于FAIL
状态。在这两种情况下,最终集群将对给定节点的状态有一个统一的视图:
案例 1: 如果大多数主节点因为故障检测及其产生的连锁效应而将某个节点标记为FAIL
,那么其他所有节点最终也会将该主节点标记为FAIL
,因为在指定的时间窗口内会报告足够的故障。
案例 2: 当只有少数主节点将某个节点标记为FAIL
时,副本提升将不会发生(因为它使用了一种更正式的算法,确保最终每个人都知道提升),并且每个节点将根据上述FAIL
状态清除规则清除FAIL
状态(即在NODE_TIMEOUT
的N倍时间过去后不会进行提升)。
FAIL
标志仅用作触发运行算法的安全部分,用于副本提升。理论上,副本可以独立行动,在其主节点不可达时启动副本提升,并等待主节点拒绝提供确认(如果主节点实际上对大多数节点是可达的)。然而,PFAIL -> FAIL
状态的复杂性增加、弱一致性以及FAIL
消息强制在集群的可达部分以最短时间传播状态,具有实际优势。由于这些机制,如果集群处于错误状态,通常所有节点将在大约同一时间停止接受写入。从使用 Redis 集群的应用程序的角度来看,这是一个理想的功能。此外,由于本地问题(主节点对其他大多数主节点是可达的)而无法访问其主节点的副本发起的错误选举尝试也被避免了。
配置处理、传播和故障转移
集群当前纪元
Redis Cluster 使用了一个类似于 Raft 算法中的“任期”概念。在 Redis Cluster 中,这个任期被称为 epoch,它用于为事件提供递增的版本控制。当多个节点提供冲突的信息时,其他节点可以理解哪个状态是最新的。
currentEpoch
是一个64位无符号数字。
在节点创建时,每个Redis集群节点,无论是副本节点还是主节点,都将currentEpoch
设置为0。
每次从另一个节点接收到数据包时,如果发送者的纪元(集群总线消息头的一部分)大于本地节点的纪元,则将currentEpoch
更新为发送者的纪元。
由于这些语义,最终所有节点都会同意集群中最大的currentEpoch
。
当集群状态发生变化且节点寻求一致以执行某些操作时,将使用此信息。
目前这种情况仅在副本提升期间发生,如下一节所述。基本上,epoch 是集群的逻辑时钟,并规定给定信息优先于具有较小 epoch 的信息。
配置纪元
每个主节点总是在ping和pong数据包中宣传其configEpoch
,同时宣传它所服务的槽位的位图。
当创建新节点时,configEpoch
在主机中被设置为零。
在副本选举期间会创建一个新的configEpoch
。试图替换故障主节点的副本会增加其epoch,并尝试从大多数主节点获得授权。当副本获得授权时,会创建一个新的唯一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
)非常有用,这通常是不希望发生的。
一个副本会丢弃任何带有小于发送投票请求时的currentEpoch
的纪元的AUTH_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
请求的形式从副本接收投票请求。
要授予投票权,需要满足以下条件:
- 主节点在给定的epoch中只投票一次,并且拒绝为旧的epoch投票:每个主节点都有一个lastVoteEpoch字段,只要认证请求包中的
currentEpoch
不大于lastVoteEpoch,就会拒绝再次投票。当主节点对投票请求作出肯定回应时,lastVoteEpoch会相应更新,并安全地存储在磁盘上。 - 主节点只有在副本的主节点被标记为
FAIL
时才会投票给副本。 - 带有
currentEpoch
的认证请求,如果其值小于主节点的currentEpoch
,则会被忽略。因此,主节点的回复将始终具有与认证请求相同的currentEpoch
。如果同一个副本再次请求投票,并增加了currentEpoch
,则可以保证主节点的旧延迟回复不会被接受用于新的投票。
未使用规则编号3导致的问题示例:
主节点 currentEpoch
是 5,lastVoteEpoch 是 1(这可能在几次选举失败后发生)
- 副本
currentEpoch
是 3。 - 副本尝试以epoch 4(3+1)进行选举,主节点回复了一个带有
currentEpoch
5的ok,然而回复被延迟了。 - 副本将在稍后时间尝试再次被选举,使用epoch 5(4+1),延迟的回复到达副本时带有
currentEpoch
5,并被接受为有效。
- 主节点在
NODE_TIMEOUT * 2
时间过去之前不会为同一个主节点的副本投票,如果已经为该主节点的副本投过票。这并不是严格要求的,因为在同一个纪元中不可能有两个副本赢得选举。然而,实际上这确保了当一个副本被选举时,它有足够的时间通知其他副本,并避免另一个副本赢得新的选举,从而执行不必要的第二次故障转移。 - 主节点不会以任何方式努力选择最佳的副本。如果副本的主节点处于
FAIL
状态并且主节点在当前任期内没有投票,则会授予一个积极的投票。最佳的副本最有可能在选举中启动并赢得选举,因为它通常能够由于其更高的排名而更早地开始投票过程,如前一节所述。 - 当主节点拒绝为给定的副本投票时,不会有负面响应,请求会被简单地忽略。
- 主节点不会投票给那些发送的
configEpoch
小于主节点表中副本所声明的槽位的任何configEpoch
的副本。记住,副本发送的是其主节点的configEpoch
,以及其主节点所服务的槽位的位图。这意味着请求投票的副本必须拥有一个比授予投票的主节点更新或相等的配置,用于它想要进行故障转移的槽位。
分区期间配置epoch实用性的实际示例
本节说明了如何使用epoch概念使副本提升过程更能抵抗分区。
- 主节点无限期不可达。主节点有三个副本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接收到一个心跳,声称服务于哈希槽1和2,配置纪元值为3,表格将被修改为:
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相关联。因此,他们将向A发送一个UPDATE
消息,包含槽的新配置。由于上述规则2,A将更新其配置。
节点如何重新加入集群
当节点重新加入集群时,使用相同的基本机制。 继续上面的例子,节点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也失败了。
- C2 被提升为新的主节点以替换 A1。
- 集群可以继续操作。
副本迁移算法
迁移算法不使用任何形式的协议,因为Redis集群中的副本布局不是需要一致和/或使用配置纪元进行版本控制的集群配置的一部分。相反,它使用一种算法来避免在主节点没有备份时进行大规模的副本迁移。该算法保证最终(一旦集群配置稳定)每个主节点都将由至少一个副本备份。
这是算法的工作原理。首先,我们需要定义在这个上下文中什么是好的副本:好的副本是从给定节点的角度来看,不处于FAIL
状态的副本。
算法的执行在每个检测到至少有一个主节点没有良好副本的副本中触发。然而,在所有检测到这种情况的副本中,只有一部分应该采取行动。这个子集实际上通常是一个单独的副本,除非不同的副本在给定时刻对其他节点的故障状态有略微不同的看法。
acting replica 是在主节点中具有最多附加副本的副本,该副本不处于 FAIL 状态且具有最小的节点 ID。
例如,如果有10个主节点,每个主节点有1个副本,以及2个主节点,每个主节点有5个副本,那么将尝试迁移的副本是在这2个有5个副本的主节点中,具有最低节点ID的那个。由于没有使用一致性协议,当集群配置不稳定时,可能会出现竞态条件,即多个副本认为自己是具有较低节点ID的非故障副本(这种情况在实践中不太可能发生)。如果发生这种情况,结果是多个副本迁移到同一个主节点,这是无害的。如果竞态条件导致让出主节点的副本为空,一旦集群再次稳定,算法将重新执行,并将一个副本迁移回原始主节点。
最终,每个主节点将由至少一个副本支持。然而,正常的行为是,单个副本从具有多个副本的主节点迁移到一个孤立的主节点。
该算法由一个用户可配置的参数控制,称为
cluster-migration-barrier
:在主节点必须保留的可用副本数量之前,副本可以迁移。例如,如果此参数设置为2,则只有在主节点保留两个工作副本时,副本才能尝试迁移。
configEpoch 冲突解决算法
当在故障转移期间通过副本提升创建新的configEpoch
值时,它们保证是唯一的。
然而,有两个不同的事件会以不安全的方式创建新的configEpoch值,只是增加本地节点的currentEpoch
并希望同时没有冲突。这两个事件都是由系统管理员触发的:
CLUSTER FAILOVER
命令带有TAKEOVER
选项时,能够手动将一个副本节点提升为主节点即使大多数主节点不可用。这在多数据中心设置中非常有用。- 出于性能原因,集群重新平衡时的槽迁移也会在本地节点内生成新的配置纪元,而无需达成一致。
具体来说,在手动重新分片期间,当一个哈希槽从节点A迁移到节点B时,重新分片程序将强制B将其配置升级为集群中找到的最大纪元加1(除非该节点已经是具有最大配置纪元的节点),而不需要其他节点的同意。 通常,现实世界中的重新分片涉及移动数百个哈希槽(特别是在小型集群中)。在重新分片期间,对于每个移动的哈希槽,要求达成一致以生成新的配置纪元是低效的。此外,每次都需要在每个集群节点上进行fsync以存储新配置。由于它的执行方式,我们只需要在第一个哈希槽移动时生成一个新的配置纪元,这使得它在生产环境中更加高效。
然而,由于上述两种情况,有可能(尽管不太可能)以多个节点具有相同的配置纪元结束。系统管理员执行的重新分片操作和同时发生的故障转移(加上很多坏运气)可能会导致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
命令来实现的。
该命令执行两项操作:
- 它从节点表中移除具有指定节点ID的节点。
- 它设置了一个60秒的禁令,防止具有相同节点ID的节点被重新添加。
第二个操作是必要的,因为Redis集群使用gossip来自动发现节点,所以从节点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;
}