本文基本上是 redis cluster 规范 的翻译(意译),希望能对您理解 redis cluster 的原理有所帮助。若发现哪里说的不明白,或者有错误,希望能够在评论里指出,不胜感激。
Redis Cluster 是 Redis 的一个分布式实现。它具有以下设计重点:
集群的节点作用:
集群内通过 Redis Cluster Bus 协议来交互。每一个节点都维护同其他所有节点的 TCP 连接,而节点之间通过 gossip 协议来交互集群的信息。
集群节点不能代理请求,而是通过向 client 返回重定向信息来告知 client 重定向到其他节点,client 再向其他节点发送请求. client 可以维护自身的 key -> node 映射来提升性能.
异步备份;last failover wins - 最后一个成为 master 的机器上的数据生效。
Redis Cluster 不保证 100% 的写安全。在 partition (可以认为集群被切割成互不相连的几部分) 发生时,会有一个短暂的时间窗口会丢失 write 请求写入的数据。而按照 client 连接的 partition 的情况,又分为两种情况。
第一种情况,client 连接到具有多数 master 节点的部分:
在这种情况下尚未同步到 slave 的数据丢失了:
第二种情况,client 连接到具有少数 master 节点的部分。
在这种情况下,master 拒绝之前的写操作的内容都丢失了。因此丢失窗口更大。
除此之外,还有一种理论上可能有的情况:
这种情况一般不会发生,因为 master 被 failover,意味着已经过了一定时间,它也已经开始拒绝写请求了。同时这种情况也要求 client 维护的信息是旧的才可能发生。
分隔的少部分是不会达到可用状态的。
如果集群大部分 master 都可达,并且每个不可达的 master 都至少有一个可达的 slave。则在 NODE_TIMEOUT 时间段后,会开始 slave failover master 的操作。操作完成之后集群达到可用状态。
比如,若一个集群有 N 个 master 节点,每个 master 节点都有唯一一个 slave 节点。则任意一个节点失败都不会使集群达到不可用状态。若有两个节点失败,则集群有 1/(2*N - 1) 的可能性集群达到不可用状态。
Redis Cluster 一个名为 replica migration 的新 feature 会迁移没有备份的 master 的备份,因此在一定程度上可以提高可用性。
Redis Cluster 为提升性能做了一些事情:
Redis Cluster 设计的时候,将高性能、扩展性放在首位;提供弱但合理的数据安全和可用性。
merge 操作用于解决冲突,如 git 中的 merge。
Redis Cluster 不提供 merge 操作,主要是因为 redis 的应用场景通常没有这个需求。而实现 merge 操作又会比较繁琐~。
将键空间划分为 16384 (2^14)个 slot,这也就意味着 redis cluster 最多支持 16384 个 master。
每个主节点处理若干个 slot;在稳定状态,每个 slot 由唯一 master 节点提供服务。
稳定状态 - cluster 不处于重新配置过程中。
哈希 slot 计算方法如下(hash tag 是个例外):
HASH_SLOT = CRC16(key) mod 16384
CRC16 实际上是循环冗余校验算法,是数据通讯中常用的一种校验算法。这里用它来计算哈希槽。
多键操作时,需要多个键位于同一 slot 中。这是通过 hash tags 来实现。
如果key 包含 “{…}” 这样的模式,则只有 “{” 和 “}” 之间的内容会用来计算 hash slot。由于key 里面可能包含多个 “{” 或 “}”,因此计算规则如下:
看几个例子
node ID : 一个 160 位随机数的16进制表示,显示出来是40个字符. node ID 保存在节点的配置文件中,并会永久使用.
node ID 是集群用来区分不同节点的依据。IP 和端口可能变化,但只要 node ID不变,集群就会识别为同一个节点。
node ID 是节点的唯一全局一致的信息。
每个节点还会维护其知道的集群中其他节点的如下信息:
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
上面显示顺序为: node id, address:port, flags, last ping sent, last pong received, configuration epoch, link state, slots
集群的节点使用一个额外的 TCP 端口进行集群节点之间的内部通讯。端口号同接受 client 连接所使用的端口号关联。比如,若 redis 使用 6379 端口接受 client 连接,则 6379 + 10000,即 16379 端口就是节点用于集群内通讯的端口。
集群之间的通讯使用集群总线和集群总线协议。这是一个内部协议,目前没有开放文档。不过可以通过源码来看。
redis cluster 是一个全连接的网状结构,每个节点同其他所有节点之间都有 TCP 连接。
比如,若集群有 N 个节点,则每个节点都有 N-1 个外向的TCP 连接,同时还有 N-1个内向的TCP连接。
集群的节点间通过 gossip 协议和一个配置更新策略来保证正常状态下节点交换的数据不随着节点数指数增加。
集群中节点对消息的处理:
节点仅在两种情况下接受另一节点为本集群节点:
因此,只要向集群中添加节点,他们之间最终都会自动形成一个全连接图。
client 可以向集群中的所有节点发送 query 请求. 节点收到请求后,首先分析请求并解析哪个机器为相应的 slots 提供服务。若是本节点提供服务,则直接返回结果给 client;否则,则会返回一个 MOVED 错误,并将相应的 slot 和对应机器返回给 client。如下:
GET x
-MOVED 3999 127.0.0.1:6381
集群内部节点之间是通过 node ID 标识的,但为了方便,对 client 的接口仍然使用 IP+port.
client 收到 MOVED 请求后,应该讲 SLOT 跟 IP+port 的对应关系存储起来,以便下次使用。
client 收到 MOVED 请求后,也可以通过 CLUSTER NODES 命令或 CLUSTER SLOTS 命令更新整个集群的分布信息。这主要是由于重新配置的时候,经常会有多个 slot 的配置同时发生变更。
完整的集群 client 实现还应该实现 ASK 重定向。我们后文会具体讲到。
新增、删除节点,以及集群的再平衡都使用了一个操作:将 slot 从一个节点移动到另一个节点。
集群中的 slots 迁移可能用到以下命令:
ADDSLOTS 命令经常在集群创建时使用,指定某个节点服务哪些 slots。
DELSLOTS 常用语手动修改集群配置,或用语调试任务。实际中很少使用。
SETSLOT 将某个 slot 分配给某个 master 节点。
我们下面详细说一下 slot 迁移,这个过程使用了 MIGRATING 和 IMPORTING 命令。如将 slot 从节点 A 迁移到 B:
slot 的迁移一般是通过 redis-trib 程序来执行的。该程序首先执行 CLUSTER GETKEYSINSLOT slot count 来获取若干个 key,然后遍历向 A 发送 MIGRATE 命令:
MIGRATE target_host target_port key target_database id timeout
MIGRATE 时 A 会连接到目标主机,发送相应 key 的信息,一旦收到 OK code 返回值,则从本地库中将该 key 删除。从外部 client 的视角来看,一个key 只能存在 A 或者 B 其中之一上。
迁移过程完成之后,SETSLOT NODE 命令会被发送到这两个节点上。一般来说也会发送到集群上所有节点上,从而使集群的新配置尽快生效,而不必等待自然传播来将配置同步到整个集群。
已经有了 MOVED 重定向,为什么还需要 ASK 重定向呢?MOVED 意味着我们认为请求的 hash slot 会永久的由另一个节点提供服务;而 ASK 则仅仅意味着下一个请求需要发送给指定的节点。
由于下一个到该 slot 的请求数据可能仍在 A 上,因此每次对该 slot 的请求都要先访问 A,若A 上没有则通过 ASK 重定向到 B。由于只有一个 slot 会发生这种情况,因此性能不成问题。
B 节点需要保证只接受尝试访问过 A 之后的针对该 slot 的请求。因此访问 B 节点该 slot 数据前,需要先发送 ASKING 命令。若一个 client 有bug,没有先发送 ASKING,则 B 会拒绝服务并 MOVED 重定向到 A,而集群本身不会出问题。
迁移完成之后,再次请求 A 数据的该 slot 将会使用 MOVED 重定向到 B。
集群 client 的实现是可以不储存 slot 配置信息的,但是这样做会非常低效。
一般来讲 client 都需要保存 slot 的配置信息,但不要求实时性。如果配置过期,client 请求的时候会受到 MOVED 重定向信息,触发更新本地的 slot 配置信息。
CLUSTER SLOTS 命令可以获取 slot 配置信息。
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
第一行 5461 表示起始的 slot,第二行代表结束 slot(包含)。第三部分代表 master 节点信息。第四部分代表 slave,可以用来做 readonly。
若使用 hash tag,则可以自由使用多键操作。
需要注意多键操作在 resharding 的时候可能不可用。更进一步讲,若 resharding 的时候造成部分迁移,则多键操作当前不可用,访问时会返回 TRYAGAIN 错误,客户端可以过一会儿再访问。若resharding 的时候所有key 都在同一个节点,则多键操作也还是可用的。
正常情况下, slave 若收到请求则会重定向到相应的 master。但是可以通过 READONLY 命令指定 slave 提供读请求服务。
READONLY 意味着可能读到过期数据,并且不会操作写。若业务不敏感,可以开启 READONLY 模式。
集群内的节点会连续交换 ping pong 包。这两种包具有相同的结构,都包含重要的配置信息。本质上只是消息类型不同。ping pong 包合在一起也叫做心跳包。
正常情况下,节点向另外一个节点发送 ping 包,触发相应节点返回 pong 包。不过这也不是绝对的,节点也可以只发送 pong 包。这在广播新配置变更的时候比较有用。
节点会在几种情况下发送ping 包:
若 NODE_TIMEOUT 设置的太小而集群中节点数量太多,则交互的消息数量是可调的,从而保证在半个 NODE_TIMEOUT 时间之内所有节点都有一次交互以防止过期。
举个例子,若 NODE_TIMEOUT 设置为 60秒,集群大小为 100。这就要求在 30秒内每个节点都要同其他99个节点进行一次交互,即平均 3.3 ping/s。整个集群就有 330 ping/s。
消息数量还有其他方式进一步降低。不过一般不会遇到集群带宽问题,因此使用了这个直接的方案。
ping 包和 pong 包包含两个部分: 所有消息类型共有的 header 部分;心跳包独有的 gossip 部分。
header 部分包含以下信息:
ping pong包还包含 gossip 部分。这部分内容包含了该节点认为的集群中其他节点的状态,不过不是全部,而是随机选择的一部分。对于包含的每个节点,都有以下信息:
gossip 部分的内容对于错误检测和节点发现很有用。
当集群中的某个 master 或者 slave 不能被集群中的大部分 master 触达时,需要通过错误检测来识别。识别出之后将一个 slave 提升为 master。若不能提升,则集群进入错误状态,不再对外提供服务。
如上文所述,每个节点都维护其他节点的 flag 状态,其中 PFAIL 和 FAIL 状态对于错误检测很有用。PFAIL 表示“可能失败”,FAIL 表示节点的 FAIL 状态经过集群中大部分 master 确认过。
若某个节点 NODE_TIMEOUT 超时仍不可达,则将其标记为 PFAIL 状态。
节点不可达,意味着我们发了一个 ping,但是到 NODE_TIMEOUT 超时也没有收到回复。这也意味着 NODE_TIMEOUT 要比 round trip time 要大得多。
PFAIL 信息仅仅是一个局部的信息,代表本机认为目标机器不可达。但是仅有 PFAIL 状态不足以触发 slave 提升为 master 的操作。需要将 PFAIL 提升为 FAIL。
当满足下面条件时 PFAIL 提升到 FAIL:
若这些条件满足,则 A:
收到 FAIL 消息的节点也会强制将 B 标记为 FAIL 状态。
FAIL 状态基本是单向的,只能由 PFAIL 提升到 FAIL 。但在下列情况下 FAIL 标记可以被清除:
PFAIL 到 FAIL 的状态提升使用了一个弱协议:
但是集群的错误检测有一个要求: 最终所有节点都要在某个给定节点的状态上达成一致。若发生 split brain 问题,会发生两种情况:
FAIL 标记仅仅作为运行算法安全部分的触发器。理论上讲,每个 slave 在发现 master 不可达之后都可以自行将自身提升为 master,若master 仍被多数可达则等待其他 master 的拒绝消息。但是新增的 PFAIL->FAIL,错误传播机制也有其作用:
redis 集群使用了类似 Raft 算法中 “term” 的概念,只不过在 redis 集群中叫 epoch。
currentEpoch 是一个 64 位无符号数字。
集群节点创建时, currentEpoch 设置为 0.
每次节点收到一个包,若发送者的 epoch 比当前节点的大,则 currentEpoch 更新为发送者的 epoch。
最终,集群在节点的最大 configEpoch 上达成一致。
master 会在 ping 或 pong 包里包含其 configEpoch 信息。
节点新创建时, configEpoch 为0.
在 slave selection 过程中,获得授权的 slave 会创建新的 configEpoch 并用其转变为 master。
configEpoch 可用于解决分支问题。
configEpoch 发生变化时,会永久保存在所有节点的 nodes.conf 文件上。
Slave 选举由 slave 节点在投票给 slave 节点的 master 节点的帮助下完成。
Slave 若想提升为 master,需要发起选举并赢得选举。 slave 发起选举的条件如下:
选举操作过程如下:
slave 发现 master 进入 FAIL状态后,要等一段时间才发起选举。等待时间 delay 为
DELAY = 500 ms + random delay between 0-500 ms + SLAVE_RANK * 1000 ms
delay 分为几部分:
slave 一旦被选中,它就获得一个新的当前最大的 configEpoch。它立马会使用该 configEpoch 发送 pong 包广播给集群中所有节点。后续ping 和 pong 包中也会携带该 configEpoch 信息。
其他节点收到通知后,会用较大 configEpoch 的节点作为处理 slot 的master。而原来的 master 若收到,还会建立到新 master 的备份,将自己转变为新 master 的一个 slave。
master 若收到 slave 发起的 FAILOVER_AUTH_REQUEST 请求,在下列条件满足的情况下将会返回 ACK确认:
master 执行选举由以下策略:
举个列子:
此时 A 作为master,同时 C 试图选举并提升为 master。那么 C 可以赢得选举提升为 master 吗?
假设另一种情况,没有 B 的存在。在这种情况下 A 断开一小段时间重新恢复,A 有可能可以提升成功。A 提升成功的情况下 C 就不能选举成功;反之亦然。
主要有两种方式:
hash slot的更新有两个规则:
在重新配置过程中,某个 master 的所有节点都会由原来的 slave 提供服务。如之前的例子。master 的多个 slot 可能由 A 或 B 提供服务。
因此当 master 重新加入集群后,它需要将自身变为某个节点的一个 slave。那它要变为 A 的slave 还是 B 的slave 呢?切换规则为: master 节点重新加入后会变为从它拿走最后一个 slot 的那个节点的 slave.
备份迁移用于提升系统可用性。
我们先看没有备份迁移的情况:
备份迁移方案下的例子。A、B、C 为master,A1 和 B1 分别是 A、B 的 slave;C1 和 C2 是 C 的slave:
备份迁移算法主要用于决定哪个 slave 来做迁移操作。
acting slave: 具有最多关联 slave 的 master 的 slave,并且取 id 最小的那一个。
需要注意,当配置不稳定的时候,可能会有多个 slave 认为自己是 acting slave,都去执行迁移操作。这不是大问题,因为当配置稳定之后,若原来的 master 下没有 slave,则slave 还是会迁移回来的。最终每个 master 都会有 slave。而且绝大部分情况下不会发生这种事情。
slave 自己生成的 configEpoch 是可以保证唯一的。但是管理员可以触发一些操作,从而使集群内产生同样的 configEpoch。但集群希望任何情况下每个 master 都有唯一的 configEpoch,因此需要 configEpoch 冲突解决算法来强制保证这一点。具体算法为:
可以在不重启节点的情况下对其 reset,从而另做他用。重置命令为:
命令直接发给要重置的节点。重置将执行以下操作:
删除节点只需要将其所有 slot 迁移到其他 master ,并关机即可。不过其他节点仍然记得它的 Node ID 并会尝试重连。
因此希望将该节点的记录从其他节点的节点表删除。这通过 CLUSTER FORGET 命令完成。这条命令主要做下面工作:
客户端可以从任意节点订阅内容,也可以发布信息到任意节点。redis 集群会保证相应信息被转发。
当前的实现仅仅是将每个发布的消息广播到其他所有节点。以后会通过 Bloom Filter 或其他算法优化。
/*
* 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;
}