查看redis集群中的数据_Redis集群(中) —— 集群伸缩

f3b093b09d1a2c2248ae42aeda8a2f0b.png

前言

上篇中我们介绍了Redis集群的数据分布策略、节点路由以及基于Gossip协议的节点通信。本章我们将继续介绍Redis集群的核心内容——集群的伸缩与故障迁移。集群的伸缩功能使得集群能够被更充分的利用起来,比如流量高峰时,我们可以多用几个节点来确保服务被及时的响应。流量低谷时,我们可以少用几个节点来把计算资源让给别的任务。故障转移使得即使有节点故障,整个集群依旧能够在一段时间后恢复正常继续向外提供服务,简单来说,故障转移就是当节点A故障时,我们用节点A的子节点代替A继续提供A上存储的数据服务。以下为整个Redis集群系列文章的目录。

6ffe7bb8b17ca0b7bcaf2fba857bf61b.png
Redis集群目录

集群伸缩

集群的伸缩包括新节点的加入和旧节点退出。新节点时加入时,我们需要把一部分数据迁移到新节点来达到集群的负载均衡,旧节点退出时,我们需要把其上的数据迁移到其他节点上,确保该节点上的数据能够被正常访问。我们发现集群伸缩的核心其实是数据的迁移,而在Redis集群中,数据是以slot为单位的,那么也就是说,Redis集群的伸缩本质上是slot在不同机器节点间的迁移。同时,要实现扩缩容,我们不仅需要解决数据迁移,我们还需要解决数据路由问题。比如A节点正在向B节点迁移slot1的数据,在未完成迁移时,slot1中的一部分数据存在节点A上,一部分数据存在节点B上。那么以下三种情况下我们该如何路由slot1的客户端请求?

  1. 当除了A、B之外的其他节点接收到slot1的数据请求时,其他节点该路由给哪个节点?
  2. 当节点A接收到slot1的数据请求时,A该自己处理还是路由给B节点?
  3. 当节点B接收到slot1的数据请求时,B该自己处理还是路由给A节点?

集群扩容

Redis集群加入新节点主要分为如下几步:(1) 准备新节点 (2) 加入集群 (3) 迁移slot到新节点。即首先启动一个集群模式下的Redis节点,然后通过与任意一个集群中的节点握手使得新的节点加入集群,最后再向新的节点分配它负责的slot以及向其迁移slot对应的数据。由于Redis采用Gossip协议,所以可以让新节点与任意一个现有集群节点握手,一段时间后整个集群都会知道新节点的加入。

我们有如下的集群示例,我们向该集群中新加入一个节点6385。由于我们要追求负载均衡,所以加入后四个节点每个节点负责4096个slots,但是集群中原来的每个节点都负责5462个slots,所以6379、6380、6381节点都需要向新的节点6385迁移1366个slots。需要说明的是,Redis集群并没有一个自动实现负载均衡的工具,把多少slots从哪个节点迁移到哪个节点完全是由用户自己来指定的。

7e7725e53646801fd9519d5abdfe1b65.png
集群size=3,每个节点负责5462个slots

504680e6e04f10bf4a726f07633b5a97.png
加入新的节点6385后向新节点迁移slots

Redis集群的slots迁移步骤大致分为下图所示的5步,如下的过程在6379、6380、6381节点上分别进行。不失一般性,我们以数据迁出节点是6379为例进行介绍。而数据迁入节点自然就是新加入的6385节点。同时我们假定6379节点要向6385节点迁移的slot编号为1,2,3共3个slots。在一个源节点上,slot的迁移是逐个进行的,slot内部的键值是批量迁移的。迁移的伪代码如下,我们后续着重介绍几个关键步骤。

def move_slot(source,target,slot):
    # 目标节点准备导入槽
    target.cluster("setslot",slot,"importing",source.nodeId);
    # 目标节点准备全出槽
    source.cluster("setslot",slot,"migrating",target.nodeId);
    while true :
        # 批量从源节点获取键
        keys = source.cluster("getkeysinslot",slot,pipeline_size);
        if keys.length == 0:
            # 键列表为空时,退出循环
            break;
        # 批量迁移键到目标节点
        source.call("migrate",target.host,target.port,"",0,timeout,"keys",keys);
    # 向集群所有主节点通知槽被分配给目标节点
    for node in nodes:
        if node.flag == "slave":
            continue;
        node.cluster("setslot",slot,"node",target.nodeId);

4c82d8a5cf3077eee894134bb0ba7736.png
Redis集群数据迁移

设置节点迁入迁出状态——解决路由困境

我们上篇介绍过Redis集群的数据路由,每个Redis集群节点的clusterState都会存储整个集群中slot和Redis节点的对应关系用于路由。当6379迁移slot1时,会首先标级该槽属于正在迁移的状态IMGRATING,而同样6385也需要标记slot1属于正在导入的状态IMPORTING。从实现上看,就是分别设置migrating_slots_to 和 importing_slots_from两个数组的对应index的值。迁入迁出的状态设置主要是为了方便数据路由的实现在未完成迁移之前,集群中的所有节点都会将slot1的请求重定向到6379节点。

而当6379把slot1标记为MIGRATING时,该节点会接收所有关于slot1的请求,但只有当请求中的key存在于6379中时该节点才会处理该请求。否则6379会把该请求通过ASK重定向到slot1的迁移目标节点,即6385节点。

而当6385把slot1标记为IMPORTING时,该节点也可以接受关于slot1的请求,但前提时该请求中必须包含ASKING 命令。如果关于slot1的请求中没有ASKING命令,那么6385节点会把该请求通过MOVED重定向到6379节点。

这样我们就解决了上述的三个问题,即:

  1. 当除了A、B之外的其他节点接收到slot1的数据请求时,其他节点该路由给A节点
  2. 当节点A接收到slot1的数据请求时,如果请求的key存在,那么就会处理,不存在就会ASK重定向到B
  3. 当节点B接收到slot1的数据请求时,如果请求中有ASKING命令,那么就会自己处理。如果没有,那么重定向到A。
typedef struct clusterState {
    clusterNode *myself;  /* This node */
    // 当前纪元
    uint64_t currentEpoch;
    // 集群的状态
    int state;            /* CLUSTER_OK, CLUSTER_FAIL, ... */
    // 集群中至少负责一个槽的主节点个数
    int size;             /* Num of master nodes with at least one slot */
    // 保存集群节点的字典,键是节点名字,值是clusterNode结构的指针
    dict *nodes;          /* Hash table of name -> clusterNode structures */
    // 防止重复添加节点的黑名单
    dict *nodes_black_list; /* Nodes we don't re-add for a few seconds. */
    // 导入槽数据到目标节点,该数组记录这些节点
    clusterNode *migrating_slots_to[CLUSTER_SLOTS];
    // 导出槽数据到目标节点,该数组记录这些节点
    clusterNode *importing_slots_from[CLUSTER_SLOTS];
    // 槽和负责槽节点的映射
    clusterNode *slots[CLUSTER_SLOTS];
    // 槽映射到键的有序集合
    zskiplist *slots_to_keys;
    
} clusterState;

获取键值并发送

迁移6379的slot1到6385节点,本质是将6379上slot1内的键值发送给6385节点。所以我们要在6379节点上循环的取出所有的键值然后序列化发送给6385节点,最后在6385节点上restore这些键值即可。为了减少两个节点间的通信,Redis集群使用批量迁移,即一次把多个键值一起序列化发送。需要说明的是,不同的节点迁移key并不是只有集群才能使用的功能,两个非集群模式下的节点也可以使用migrate来进行键的迁移。集群模式下只是多了一个getkeysincluster这样的命令方便取出slot内的所有key。

那么批量传输N个键值是如何做的呢?实际上就是向6385节点发送了N个RESTORE-ASKING命令,即我们向6385节点实际上发送的内容大致如下所示。RESTORE命令的格式是RESTORE key ttl serialized-value [REPLACE]。即key,生存时间,序列化后的值和一个是否覆盖迁入节点的key的标识符。

RESTORE-ASKING KEY1 TTL1 SERIALIZED-VALUE1
RESTORE-ASKING KEY2 TTL2 SERIALIZED-VALUE2
RESTORE-ASKING KEY3 TTL3 SERIALIZED-VALUE3
RESTORE-ASKING KEY4 TTL4 SERIALIZED-VALUE4
...
RESTORE-ASKING KEYN TTLN SERIALIZED-VALUEN

实现部分源码如下:(为方便阅读,进行了一部分的源码删减)

/* Create RESTORE payload and generate the protocol to call the command. */
    for (j = 0; j < num_keys; j++) {
        long long ttl = 0;
        long long expireat = getExpire(c->db,kv[j]);
        //检查键是不是已经过期
        if (expireat != -1) {
            ttl = expireat-mstime();
            if (ttl < 0) {
                continue;
            }
            if (ttl < 1) ttl = 1;
        }
        kv[non_expired++] = kv[j];

        // 集群模式下写入RESTORE-ASKING命令,普通模式下写入RESTORE命令
        if (server.cluster_enabled)
            serverAssertWithInfo(c,NULL,
                rioWriteBulkString(&cmd,"RESTORE-ASKING",14));
        else
            serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE",7));
        // 写入KEY,写入TTL
        serverAssertWithInfo(c,NULL,sdsEncodedObject(kv[j]));
        serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,kv[j]->ptr,
                sdslen(kv[j]->ptr)));
        serverAssertWithInfo(c,NULL,rioWriteBulkLongLong(&cmd,ttl));
        // 写入VALUE以及Redis版本校验码等信息
        createDumpPayload(&payload,ov[j],kv[j]);
        serverAssertWithInfo(c,NULL,
            rioWriteBulkString(&cmd,payload.io.buffer.ptr,
                               sdslen(payload.io.buffer.ptr)));
        
    }

而当RESTORE-ASKING命令发送给6385后,6385节点执行节点的恢复操作。即获取key、解析出value,然后写入数据库。实现源码如下:(代码有删减)

/*RESTORE的命令格式:  RESTORE key ttl serialized-value [REPLACE] */
void restoreCommand(client *c) {
    long long ttl, lfu_freq = -1, lru_idle = -1, lru_clock = -1;
    rio payload;
    int j, type, replace = 0, absttl = 0;
    robj *obj;

   // 从序列化字符串中解析出key对应的值
    rioInitWithBuffer(&payload,c->argv[3]->ptr);
    if (((type = rdbLoadObjectType(&payload)) == -1) ||
        ((obj = rdbLoadObject(type,&payload,c->argv[1])) == NULL))
    {
        addReplyError(c,"Bad data format");
        return;
    }

    /* 如果设置了覆盖标志,则删除本地数据库中同名的键 */
    if (replace) dbDelete(c->db,c->argv[1]);
    /* 向数据库中写入键值,并设置过期时间 */
    dbAdd(c->db,c->argv[1],obj);
    if (ttl) {
        if (!absttl) ttl+=mstime();
        setExpire(c,c->db,c->argv[1],ttl);
    }
}

集群广播消息

当迁移slot1结束后,slot1就不再由6379负责而是交给6385节点负责。但是从其他节点的视角看,slot1仍然由6379节点负责,他们接收到关于slot1的键的请求还是会路由到6379节点。所以迁移结束之后我们要向集群广播slot1由6385节点负责的消息,这样每个节点都会更新内部的路由数据,之后就可以正确的把slot1的键的请求路由到6385节点。需要说明的是,我们可以把上述的更新信息只告诉一个节点,那么随着各个节点信息的交换,一段时间后整个集群的所有节点都会更新路由。但是这样显然更新的延迟会很高,那些还没来得及更新的节点仍然会错误的把slot1的请求路由给6379节点。所以我们需要向每个节点进行广播消息。

集群收缩

集群收缩即让其中一些节点安全下线。所谓的安全下线指的是让一个节点下线之前我们需要把其负责的所有slots迁移到别的节点,否则该节点下线后其负责的slots就没法继续被服务了。节点下线的流程如下图所示:

cc641d053dc5c61608d0494ba646c6e5.png
节点安全下线

在我们上面的扩容完成后,集群中共有四个节点:6379、6380、6381、6385,我们以下线6381为例介绍下线的流程。下线6381节点首先需要把其上负责slots的数据分别迁移到三个节点上,然后通知所有集群中的节点忘记6381节点,最后6381节点关闭下线。

ea202d756e4b26221b8b07909d9462cf.png
下线前先把数据迁移出去

数据迁移部分和上述扩容中的迁移步骤完全一样,所以我们着重介绍一下向集群中的所有节点广播忘记6381节点,即我们需要向每个节点都发送 cluster forget命令。整个步骤的伪代码如下:

def delnode_cluster_cmd(downNode):
    # 下线节点不允许包含slots
    if downNode.slots.length != 0
        exit 1
    end
    # 向集群内节点发送cluster forget
    for n in nodes:
        if n.id == downNode.id:
            # 不能对自己做forget操作
            continue;
        # 如果下线节点有从节点则把从节点指向其他主节点
        if n.replicate && n.replicate.nodeId == downNode.id :
            # 指向拥有最少从节点的主节点
            master = get_master_with_least_replicas();
            n.cluster("replicate",master.nodeId);
        #发送忘记节点命令
        n.cluster('forget',downNode.id)
    # 节点关闭
    downNode.shutdown();

之前我们说过,Redis的元数据在每个节点中都有一份,即每个Redis节点维护者从它的视角看过去集群中所有其他节点的状态。那么当集群中的所有其他节点接收到 CLUSTER FORGET <NODE ID> 命令时会删除自己保存的NODE_ID对应的节点的状态,同时把NODE_ID对应的节点加入到黑名单中60s。把一个节点放入黑名单意味着其他节点不会再去更新自己维护的该节点的信息,也就意味着当我们向集群中的所有节点发送CLUSTER FORGET 6381后,6381节点60s内不能再次加入集群中。至此就完成了集群的缩容。

总结

本节主要介绍了集群的扩缩容,从上面的内容中我们也可以看出,扩缩容最核心的地方还是数据的迁移,扩容和缩容的最大区别除了数据迁移的方向之外,就是扩容添加新节点后,需要向整个集群广播slot被新的节点负责的信息,而集群收缩下线节点时,需要向集群广播让所有节点forget掉下线节点。同时在数据迁移过程中,我们需要解决的很重要的问题就是数据路由问题,Redis通过ASK重定向和MOVED重定向解决了该问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值