Redis Cluster原理初探
引言
虽然到现在仍未发现公司内部有团队在使用Redis Cluster,但是这丝毫不影响我们去了解它。
Redis Cluster是一个可以在多个 Redis 节点之间进行数据共享的分布式设施。和以往了解的客户端通过一致性哈希解决redis多节点负载均衡的方式不同,Redis Cluster是在服务端,通过节点之间的特殊协议进行通讯,达到服务端对数据进行负载均衡。对于客户端来说,其负载均衡策略是透明的,客户端不需要自己做分布式和负载均衡。Redis集群为什么要设计成现在的样子,最核心的目标有三个:
* 性能:这是Redis赖以生存的看家本领,增加集群功能后当然不能对性能产生太大影响,所以Redis采取了P2P而非Proxy方式、异步复制、客户端重定向等设计,而牺牲了部分的一致性、使用性。
* 水平扩展:集群的最重要能力当然是扩展,redis官方文档中称可以线性扩展到1000结点。
* 可用性:在Cluster推出之前,可用性要靠Sentinel保证。有了集群之后也自动具有了Sentinel的监控和自动Failover能力。
集群简介
Redis Cluster特性之一是引入了槽的概念。一个redis集群包含16384个哈希槽,集群中的每个redis节点,分配到一部分槽。而集群使用公式 CRC16(key) % 16384 来计算每次请求的键 key 属于哪个槽,通过查询集群配置,便可知道key对应的槽属于哪个redis节点,然后再将请求打到该节点。举个例子, 一个集群可以有三个节点, 其中:
1. 节点 A 负责处理 0 号至 5500 号哈希槽。
2. 节点 B 负责处理 5501 号至 11000 号哈希槽。
3. 节点 C 负责处理 11001 号至 16384 号哈希槽。
通过上述公式,可对key X计算出一个值,该值为0-16383中的一个数,假设为34,根据上面例子,key X属于槽34,也就是节点A负责key X的读写。
这种将哈希槽分布到不同节点的做法使得用户可以很容易地向集群中添加或者删除节点。 比如说:
1. 如果用户将新节点 D 添加到集群中, 那么集群只需要将节点 A 、B 、 C 中的某些槽移动到节点 D 就可以了。
2. 与此类似, 如果用户要从集群中移除节点 A , 那么集群只需要将节点 A 中的所有哈希槽移动到节点 B 和节点 C , 然后再移除空白(不包含任何哈希槽)的节点 A 就可以了。
因为将一个哈希槽从一个节点移动到另一个节点不会造成节点阻塞, 所以无论是添加新节点还是移除已存在节点, 又或者改变某个节点包含的哈希槽数量, 都不会造成集群下线。
Redis Cluster还有一个特性便是去中心化。客户端可以连接集群中的任意一个节点,集群中的任意一个节点都可对外提供服务。节点之间可共享集群配置(如槽的分配)。假设有两个节点A和B,客户端连接了A节点,并发起了一次请求a,A节点计算请求a的key得知该请求应该打到B节点上,然后A节点对请求a返回一个MOVED B,通知客户端重定向到B节点。
集群架构
redis集群架构图
以上图片,蓝色的为redis节点,这里是指master节点,一个master节点可以配置多个slave。绿色为客户端,可以理解为我们的应用。
架构细节:
(1)所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽。
(2)节点的fail是通过集群中超过半数的节点检测失效时才生效。
(3)客户端与redis节点直连,不需要中间proxy层。客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
(4)redis-cluster把所有的物理节点映射到[0-16383]slot上,cluster 负责维护node->slot->value。
集群容错
为了当部分节点失效时,cluster仍能保持可用,Redis 集群采用每个节点拥有 1(主服务自身)到 N 个副本(N-1 个附加的从服务器)的主从模型。类似于master/slave。但是redis cluster却不是强一致性的,在一定的条件下,cluster可能会丢失一些写入的请求命令,因为cluster内部master和slave之间的数据是异步复制的,比如你给master中写入数据,返回ok,但是这时候该数据并不一定就完全的同步到slave上了(采用异步主要是提高性能,主要是在性能和一致性间的一个平衡),如果这时候master宕机了,这部分没有写入slave的数据就丢失了,不过这种可能性还是很小的。
以上是集群选举过程。
(1)选举过程是集群中所有master参与,如果半数以上master节点与当前master节点通信超时(cluster-node-timeout),则认为当前master节点挂掉.
(2)什么时候整个集群不可用(cluster_state:fail)?当集群不可用时,所有对集群的操作做都不可用,所有操作都会收到((error) CLUSTERDOWN The cluster is down)错误。以下是会导致集群不可用的其中两种情况:
a:如果集群任意master挂掉,且当前master没有slave。集群进入fail状态,也可以理解成进群的slot映射[0-16383]不完成时进入fail状态。
b:如果进群超过半数以上master挂掉,无论是否有slave集群进入fail状态。
集群扩展
以往的一致性哈希方案,如果我们移除或者新增节点时,虽然说不会导致全局key的rehash,但是也会影响到部分key的失效。Redis Cluster在可用性和可扩展性上比较重视,如果集群新增一个节点,在给该节点分配槽时,这些槽所属的源节点和该节点会进行一次key的迁移,并且迁移过程中不阻塞集群服务。如果移除一个节点,同理,我们需要将待移除的节点的key迁移到另一个节点上。
那集群是如何做到key迁移不阻塞集群服务的呢?
key迁移过程中,涉及到CLUSTER SETSLOT slot MIGRATING node 命令和 CLUSTER SETSLOT slot IMPORTING node 命令, 前者用于将给定节点 node 中的槽 slot 迁移出节点, 而后者用于将给定槽 slot 导入到节点 node :
(1)、当一个槽被设置为 MIGRATING 状态时, 原来持有这个槽的节点仍然会继续接受关于这个槽的命令请求, 但只有命令所处理的键仍然存在于节点时, 节点才会处理这个命令请求。如果命令所使用的键不存在于该节点, 那么节点将向客户端返回一个 ASK 转向(redirection)错误, 告知客户端, 要将命令请求发送到槽的迁移目标节点。
(2)、当一个槽被设置为 IMPORTING 状态时, 节点仅在接收到 ASKING 命令之后, 才会接受关于这个槽的命令请求。如果客户端没有向节点发送 ASKING 命令, 那么节点会使用 MOVED 转向错误将命令请求转向至真正负责处理这个槽的节点。
上面关于 MIGRATING 和 IMPORTING 的说明有些难懂, 让我们用一个实际的实例来说明一下。
假设现在, 我们有 A 和 B 两个节点, 并且我们想将槽 8 从节点 A 移动到节点 B , 于是我们:
(1)、向节点 B 发送命令 CLUSTER SETSLOT 8 IMPORTING A
(2)、向节点 A 发送命令 CLUSTER SETSLOT 8 MIGRATING B
每当客户端向其他节点发送关于哈希槽 8 的命令请求时, 这些节点都会向客户端返回指向节点 A 的转向信息:
(1)、如果命令要处理的键已经存在于槽 8 里面, 那么这个命令将由节点 A 处理。
(2)、如果命令要处理的键未存在于槽 8 里面(比如说,要向槽添加一个新的键), 那么这个命令由节点 B 处理。
这种机制将使得节点 A 不再创建关于槽 8 的任何新键。
与此同时, 一个特殊的客户端 redis-trib 以及 Redis 集群配置程序(configuration utility)会将节点 A 中槽 8 里面的键移动到节点 B 。
移动key的操作是原子性的,也就是一个key如果从A移动到B,那么移动时,都不会出现key在A和B中同时出现。
内部数据结构
Redis Cluster功能涉及三个核心的数据结构clusterState、clusterNode、clusterLink都在cluster.h中定义。这三个数据结构中最重要的属性就是:clusterState.slots、clusterState.slots_to_keys和clusterNode.slots了,它们保存了三种映射关系:
clusterState:集群状态
nodes:所有结点
migrating_slots_to:迁出中的槽
importing_slots_from:导入中的槽
slots_to_keys:槽中包含的所有Key,用于迁移Slot时获得其包含的Key
slots:Slot所属的结点,用于处理请求时判断Key所在Slot是否自己负责
clusterNode:结点信息
slots:结点负责的所有Slot,用于发送Gossip消息通知其他结点自己负责的Slot。通过位图方式保存节省空间,16384/8恰好是2048字节,所以槽总数16384不是随意定的。
clusterLink:与其他结点通信的连接
以下为这三个数据结构的定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
|
// 集群状态,每个节点都保存着一个这样的状态,记录了它们眼中的集群的样子。
// 另外,虽然这个结构主要用于记录集群的属性,但是为了节约资源,
// 有些与节点有关的属性,比如 slots_to_keys 、 failover_auth_count
// 也被放到了这个结构里面。``
typedefstructclusterState{
...
// 指向当前节点的指针
clusterNode *myself; /* This node */
// 集群当前的状态:是在线还是下线
intstate; /* REDIS_CLUSTER_OK, REDIS_CLUSTER_FAIL, ... */
// 集群节点名单(包括 myself 节点)
// 字典的键为节点的名字,字典的值为 clusterNode 结构
dict *nodes; /* Hash table of name -> clusterNode structures */
// 记录要从当前节点迁移到目标节点的槽,以及迁移的目标节点
// migrating_slots_to[i] = NULL 表示槽 i 未被迁移
// migrating_slots_to[i] = clusterNode_A 表示槽 i 要从本节点迁移至节点 A
clusterNode *migrating_slots_to[REDIS_CLUSTER_SLOTS];
// 记录要从源节点迁移到本节点的槽,以及进行迁移的源节点
// importing_slots_from[i] = NULL 表示槽 i 未进行导入
// importing_slots_from[i] = clusterNode_A 表示正从节点 A 中导入槽 i
clusterNode *importing_slots_from[REDIS_CLUSTER_SLOTS];
// 负责处理各个槽的节点
// 例如 slots[i] = clusterNode_A 表示槽 i 由节点 A 处理
clusterNode *slots[REDIS_CLUSTER_SLOTS];
// 跳跃表,表中以槽作为分值,键作为成员,对槽进行有序排序
// 当需要对某些槽进行区间(range)操作时,这个跳跃表可以提供方便
// 具体操作定义在 db.c 里面
zskiplist *slots_to_keys;
...
}clusterState;
// 节点状态
structclusterNode{
...
// 节点标识
// 使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
// 以及节点目前所处的状态(比如在线或者下线)。
intflags; /* REDIS_NODE_... */
// 由这个节点负责处理的槽
// 一共有 REDIS_CLUSTER_SLOTS / 8 个字节长
// 每个字节的每个位记录了一个槽的保存状态
// 位的值为 1 表示槽正由本节点处理,值为 0 则表示槽并非本节点处理
// 比如 slots[0] 的第一个位保存了槽 0 的保存情况
// slots[0] 的第二个位保存了槽 1 的保存情况,以此类推
unsignedcharslots[REDIS_CLUSTER_SLOTS/8];/* slots handled by this node */
// 指针数组,指向各个从节点
structclusterNode **slaves;/* pointers to slave nodes */
// 如果这是一个从节点,那么指向主节点
structclusterNode *slaveof;/* pointer to the master node */
...
};
/* clusterLink encapsulates everything needed to talk with a remote node. */
// clusterLink 包含了与其他节点进行通讯所需的全部信息
typedefstructclusterLink{
...
// TCP 套接字描述符
intfd; /* TCP socket file descriptor */
// 与这个连接相关联的节点,如果没有的话就为 NULL
structclusterNode *node; /* Node related to this link if any, or NULL */
...
}clusterLink;
|
结合以上数据结构,我们看看客户端请求集群的流程大致如下:
1、检查Key所在Slot是否属于当前Node?
* 1.1 计算crc16(key) % 16384得到Slot
* 1.2 查询clusterState.slots负责Slot的结点指针
* 1.3 与myself指针比较
2、若不属于,则响应MOVED错误重定向客户端
3、若属于且Key存在,则直接操作,返回结果给客户端
4、若Key不存在,检查该Slot是否迁出中?(clusterState.migrating_slots_to)
5、若Slot迁出中,返回ASK错误重定向客户端到迁移的目的服务器上
6、若Slot未迁出,检查Slot是否导入中?(clusterState.importing_slots_from)
7、若Slot导入中且有ASKING标记,则直接操作
8、否则响应MOVED错误重定向客户端