Redis设计 - 集群原理
前言
Redis Cluster是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。
涉及概念有:节点、槽指派、命令执行、重新分片、转向、故障转移、消息。
1. 节点
一个Redis集群由多个节点(node)组成,每个节点一开始都是独立的,而组件一个Redis集群便是将这些独立的节点连接起来,构成一个包含多个节点的集群。
- 可以通过cluster meet <ip> <port> 命令挨个连接Redis节点
- Redis5.0后可以通过redis-cli --cluster create命令一步连接所有Reids节点创建集群
1.1 启动节点
Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启Redis服务的集群模式。
判断是否开启集群模式在集群模式下运行的节点,除了会继续使用在单机模式时使用的服务器组件(如文件事件处理器、serverCron函数、持久化等)之外,还有集群模式下独有的处理:
- serverCron函数在集群模式下会调用clusterCron函数,负责执行集群模式下的常规操作如:向其它节点发送Gossip消息,检查节点是否断线,检查是否需要对下线节点进行自动故障转移等。
- 对于集群模式下才会用到的数据,会保存到clusterNode、clusterLink、clusterState结构中(下面将会介绍)。
1.2 集群数据结构
clusterNode保存了一个Redis节点的当前状态
struct clusterNode {
// 创建节点时间
mstime_t ctime;
// 节点的名字,40个十六进制字符组成
char name[REDIS_CLUSTER_NAMELEN];
// 节点标识(如主节点/从节点、在线/下线)
int flags;
// 节点当前的配置纪元,用于实现故障转移
unit64_t configEpoch;
// 节点的IP地址
char ip[REDIS_IP_STR_LEN];
// 节点端口
int port;
// 保存节点连接信息(对应的应该时两个节点的连线)
clusterLink *link;
}
clusterLink保存了连接节点所需的有关信息,如套接字描述符、输入缓冲区和输出缓冲区
typedef struct clusterLink {
// 连接的创建时间
mstime_t ctime;
// TCP套接字描述符
int fd;
// 输出缓冲区,保存等待发送给其它节点的消息
sds sndbuf;
// 输入缓冲区,保存从其它节点收到的消息
sds rcvbuf;
// 与这个连接相关联的节点,没有则为NULL
struct clusterNode *node;
} clusterLink;
clusterState记录了当前节点视角下,集群目前的状态,包含了集群上线/下线,集群的节点名单(上面所述的clusterNode正是记录在clusterState)等
typedef struct clusterState {
// 指向当前节点的指针
clusterNode *myself;
// 集群当前的配置纪元,用于故障转移
unit64_t currentEpoch;
// 集群当前状态:在线/下线
int state;
// 集群中至少处理着一个槽的节点的数量
int size;
// 集群节点名单(key为节点名字,value为clusterNode结构)
dict *nodes;
} clusterState;
假设当前Redis集群有3个节点:127.0.0.1:7000、127.0.0.1:7001和127.0.0.1:7002,在端口为7000的节点上的数据结构如下:
7000节点下的clusterState1.3 CLUSTER MEET命令的实现
CLUSTER MEET <ip> <port>
通过 CLUSTER MEET 命令可以将另一个Redis节点添加到当前节点所在的集群里。
在对当前节点A执行 CLUSTER MEET 命令,目标为节点B时,节点A与B将进行通信:
1)节点A为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典。
2)节点A根据 CLUSTER MEET 命令后的IP地址和端口,向节点B发送 MEET消息。
3)节点B接收到节点A的 MEET 消息,节点B也会为节点A创建一个clusterNode结构,并添加到自己的clusterState.nodes字典。
4)节点B向节点A返回 PONG 消息,节点A在接收到后,将向节点B返回一条 PING 消息。
5)节点B收到A的 PING 消息,知道节点A已成功接收到 PONG 消息,握手完成。
节点A与节点B完成握手后,节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点与节点B进行握手过程。
2. 槽指派
Redis集群通过分片的方式来保存数据库中的键值对,集群的整个数据库被分为16384个槽(slot),数据库中的每个键都保存在这16384个槽中的其中一个,集群中的每个节点可以处理0或最多16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处理上线状态(ok);反之,如果有任何一个槽没得到节点处理,集群将处理下线状态(fail)。
可以通过 CLUSTER ADDSLOTS命令,将槽指派给Redis节点,如
// 将0~5000的槽指派给节点
redis> CLUSTER ADDSLOTS 0 1 2 ... 5000
将槽指派后可以通过 CLUSTER INFO 查看集群状态,会展示各个Redis节点的槽分配信息
2.1 槽指派信息的保存
Redis将槽指派信息保存在了clusterNode结构的 slots 和 numslots 属性:
struct clusterNode {
// ...
unsigned char slots[16384/8];
int numslots;
}
slots:是二进制位数组(bit array),长度为16384 / 8 = 2048,一个chart占8个bit,所以该数组包含了16384个二进制位。如果二进制位的值为1,表示节点负责处理该槽位;反之,不处理。
numslots:负责记录处理的槽的数量。
取出或者设置slots数组中的任意一个二进制位,时间复杂度为O(1)
2.2 槽指派信息的传播
Redis节点除了会将自己负责的槽指派信息保存在 clusterNode 结构中,还会将自己的 slots 数组通过消息发送给集群中的其它节点。
其它节点的槽指派信息将保存在 clusterState.nodes 字典对应的 clusterNode 中,因此,集群中的每个节点都会知道数据库的 16384 个槽分别被指派给了谁。
那么为了判断某个槽是否被指派或者被指派给了哪个节点,是否就直接根据 clusterState.dict中保存的各个节点,挨个遍历,最后找到槽是属于哪个Redis节点。答案当然是No
为了高效的判断槽指派给了哪个节点,Redis还在 clusterState 维护了 slots 的指针数组来记录16384个槽的指派信息:
typedef struct clusterState {
// ...
clusterNode *slots[16384];
// ...
} clusterState;
slots指针数组包含16384个项,每个数组项都是指向clusterNode结构的指针
如果slots[i]指向一个clusterNode结构,说明槽 i 已经指派给了clusterNode结构代表的节点;反之,表示槽未指派。
既然clusterState.slots数组已经记录了集群中所有槽的指派信息,那么是否还有必要维护 clusterNode 中的slots数组来记录单个节点的槽指派信息呢?
答案是有必要的,原因如下:
1)要将某个节点的槽指派信息发送给其他节点时,只需要将相应节点的clusterNode.slots数组整个发送出去就可以了。
2)如果不使用clusterNode.slots数组存储单个节点的槽指派信息,就需要遍历整个clusterState.slots数组,检索出属于A节点的槽,再将其槽信息发送出去,效率比较低。
4. CLUSTER ADDSLOTS命令实现
CLUSTER ADDSLOTS <slot> [slot ...]
用伪代码展示其实现逻辑如下:
def CLUSTER_ADDSLOTS(*all_input_slots):
# 遍历所有输入的槽,检查是否都未指派
for i in all_input_slots:
# 存在任何一个槽被指派过,便返回错误
# 这里再次展示了 clusterState.slots 的作用
if clusterState.slots[i] != NULL:
reply_error()
return
# 如果都是未指派的槽,将这些槽指派给当前节点
for i in all_input_slots:
# 设置clusterState的slots数组,slots[i]指针指向当前节点
clusterState.slots[i] = clusterState.myself
# 设置clusterNode中的slots数组,将数组在索引 i 上的二进制位设置为 1
setSlotBit(clusterState.myself.slots, i)
3. 集群中命令的执行
在对数据库的所有槽都做了指派之后,集群会进去上线状态,此时客户端可以像集群中的节点发送数据命令了。
当客户端向节点发送数据库键有关命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:
- 如果键所在的槽正好指派给自己,那么节点直接执行这个命令
- 如果键所在的槽不是当前节点,那么节点想客户端发送一个 MOVED 错误,指引客户端转向正确的节点,并在此发送之前想要执行的命令
3.1 键的槽位计算
def slot_number(key) :
return CRC16(key) & 16383
CRC16(key) 用于计算键的CRC-16校验和,&16383语句用于计算一个 0~16383之间的整数作为槽号。
可以使用 CLUSTER KEYSLOT <key> 命令查看键的槽号,如
redis> CLUSTER KEYSLOT "date"
(integer) 2022
3.2 判断槽位是否当前节点处理
计算出key的槽位后,节点根据 clusterState.slots 数组,判断键所在的槽是由哪个节点负责
1)如果 clusterState.slots[i] == clusterState.myself,说明这个键所在的槽由当前节点负责,可以处理客户端的命令。
2)如果 clusterState.slots[i] ≠ clusterState.myself,说明由其它节点负责,会根据 clusterState.slots[i] 指向的clusterNode记录的节点 IP 和端口号,并向客户端返回 MOVED 错误,指引客户端转向对应槽处理节点。
3.3 MOVED 错误
计算出槽的处理节点之后,可以得出处理的节点,如果非自身节点,需要返回客户端正确的处理节点。
MOVED错误的格式为:MOVED <slot> <ip>:<port>
如:MOVED 100 127.0.0.1:7000,表示槽100 由127.0.0.1:7000这台机器负责
集群模式客户端下隐藏的 MOVED 错误
集群模式的 redis-cli 客户端在接收到 MOVED 错误时,不会直接将 MOVED 错误打印出去,而是对 MOVED 返回的 IP 和端口重新转向到目标节点。
如果使用单机模式的redis-cli,会直接将 MOVED 错误返回:
单机模式客户端MOVED处理3.4 节点数据库实现
集群节点保存键值对和过期时间的方式,与之前介绍的完全相同,唯一的区别是,节点只能使用0号数据库。
除了将键值对保存在数据库里面,节点还会用clusterState结构的 slots_to_keys 跳跃表来保存槽和键的关系,slotstokeys跳跃表每个节点的分值都是槽号,成员都是一个数据库键
typedef struct clusterState {
// ...
zskiplist *slots_to_keys;
// ...
} clusterState;
- 当数据库增加一个新的键值对时,节点就会将这个键以及键的槽号关联到slots_to_keys中。
- 当从数据库删除某个键值对时候,在slots_to_keys中同样要删除这个关联信息。
slots_to_keys跳跃表的作用
通过在slots_to_keys跳跃表中记录各个数据库键所属的槽,节点可以很方便地对属于某个 或某些槽的所有数据库键进行批量操作,例如命令 CLUSTER GETKEYSINSLOT <slot> <count> 命令可以返回最多count个属于槽slot的数据库键,而这个命令就是通过遍历 slots_to_keys跳跃表来实现的。
4. 重新分片
Redis集群的重新分片操作可以将任意数量的槽从旧的节点改为指派给另外一个节点,并且和槽有关的键值对也会被迁移到新的节点。
重新分片可以在线进行且不阻塞请求,集群不需要下线,源服务器和目标服务器都可以继续处理命令。
实现原理
1)对目标节点发送 CLUSTER SETSLOT <slot> IMPORTING <source_id> 命令,让目标节点准备好从源节点导入属于槽的键值对。
2)对源节点发送 CLUSTER SETSLOT <slot> MIGRATING <target_id> 命令,让源节点准备好将属于槽slot 的键值对迁移(migrate)到目标节点。
3)向源节点发送 **CLUSTER GETKEYSINSLOT <slot> <count>命令,获得最多 count 个属于槽slot的键值对的键名。
4)依据上个步骤获取的键名称,依次向源节点发送 MIGRATE <target_ip> <target_port> <key_name> 0 <timeout> 命令,将对应的键原子地从源节点迁移至目标节点。
5)重复执行(3), (4)步骤,直到源节点所有属于槽slot的键值对都被迁移到了目标节点。
6)向集群中的任意一个节点发送 CLUSTER SETSLOT <slot> NODE <target_id> 命令,将槽 slot 指派给目标节点,并且该指派消息会让集群中的所有节点都知道槽slot已被指派给了目标节点。
如果重新分片涉及多个槽,将循环对每个槽执行上述步骤。
重新分片命令
Redis3.x版本使用集群管理工具 redis-trib 进行分片管理,Redis Cluster 在5.0之后取消了ruby脚本 redis-trib.rb的支持(手动命令行添加集群的方式不变),集合到redis-cli里,避免了再安装ruby的相关环境。直接使用redis-cli的参数--cluster 来取代。
使用redis-cli --cluster help查看相关命令
redis-cli --cluster help
Cluster Manager Commands:
reshard host:port #指定集群的任意一节点进行迁移slot,重新分slots
--cluster-from <arg> #需要从哪些源节点上迁移slot,可从多个源节点完成迁移,以逗号隔开,传递的是节点的node id,还可以直接传递--from all,这样源节点就是集群的所有节点,不传递该参数的话,则会在迁移过程中提示用户输入
--cluster-to <arg> #slot需要迁移的目的节点的node id,目的节点只能填写一个,不传递该参数的话,则会在迁移过程中提示用户输入
--cluster-slots <arg> #需要迁移的slot数量,不传递该参数的话,则会在迁移过程中提示用户输入。
--cluster-yes #指定迁移时的确认输入
--cluster-timeout <arg> #设置migrate命令的超时时间
--cluster-pipeline <arg> #定义cluster getkeysinslot命令一次取出的key数量,不传的话使用默认值为10
--cluster-replace #是否直接replace到目标节点
rebalance host:port #指定集群的任意一节点进行平衡集群节点slot数量
--cluster-weight <node1=w1...nodeN=wN> #指定集群节点的权重
--cluster-use-empty-masters #设置可以让没有分配slot的主节点参与,默认不允许
--cluster-timeout <arg> #设置migrate命令的超时时间
--cluster-simulate #模拟rebalance操作,不会真正执行迁移操作
--cluster-pipeline <arg> #定义cluster getkeysinslot命令一次取出的key数量,默认值为10
--cluster-threshold <arg> #迁移的slot阈值超过threshold,执行rebalance操作
--cluster-replace #是否直接replace到目标节点
手动分配
redis-cli --cluster reshard 127.0.0.1:7003 (该地址为目标地址)
自动分配
redis-cli --cluster rebalance --cluster-threshold 1 --cluster-use-empty-masters 127.0.0.1:7000(源节点地址)
查看分配后的哈希槽
redis-cli --cluster check <ip>:<port>