Redis专题:详解Redis Cluster数据分片原理(2/3)

2,213 阅读18分钟

微信搜索“码路印记”,点关注不迷路!

通过上一节的内容,我们已经知道了Redis Cluster结构、设计理念以及从无到有创建一个集群,总体上来讲对于Redis Cluster有了一个初步的认识。本节将重点解析Redis Cluster数据分片的更多细节,帮助大家更好的理解与使用。

数据分片机制

数据分片

不同于单机版Redis及Sentinel模式中一个节点负责所有key的管理工作,Redis Cluster采用了类似于一致性哈希算法的哈希槽(hash slot)机制、由多个主节点共同分担所有key的管理工作。

Redis Cluster使用CRC16算法把key空间分布在16384个哈希槽内,哈希槽是按照序号从0~16383标号的,每组主从节点只负责一部分哈希槽管理操作;而且通过集群状态维护哈希槽与节点之间的映射关系,随着集群运行随时更新。如上面我们示例中,哈希槽与节点关系如下:

  • Master[0]负责Slots:0 - 5460
  • Master[1]负责Slots:5461 - 10922
  • Master[2]负责Slots:10923 - 16383

每当我们通过Redis Cluster对某个key执行操作时,接收请求的节点会首先对key执行计算,得到该key对应的哈希槽,然后再从哈希槽与节点的映射关系中找到负责该哈希槽的节点。如果是节点自身,则直接进行处理;如果是其他节点,则通过重定向告知客户端连接至正确的节点进行处理。

HASH_SLOT = CRC16(key) mod 16384

由于数据分片机制的存在,不同的key可能存储在不同的节点上,这就导致普通Redis中的一些多key之间的计算命令无法支持。因为key不同,其对应的哈希槽可能不同,导致这些数据存储在不同的节点上,如果一个命令涉及到多个节点的key,性能较低。所以,Redis Cluster实现了所有在普通Redis版本中的单一key的命令,那些使用多个key的复杂操作,比如set的union、intersection操作只有当这些key在同一个哈希槽时才可用。

但是,实际应用中,我们确实存单个命令涉及多个key的情况,基于此问题Redis Cluster提供了哈希标签在一定程度上满足使用需求。

哈希标签

Redis Cluster提供了哈希标签(Hash Tags)来强制多个key存储到同一个哈希槽内,哈希标签通过匹配key中“{”、“}”之间的字符串提取真正用于计算哈希槽的key。比如:客户端输入{abcd}test,那么将只把abcd用于哈希槽的计算;这样{abcd}test{abcd}prod就会被存储到同一个哈希槽内。但是,客户端输入的key可能存在多个“{”或“}”,此时Redis Cluster将会如下规则处理:

  • key中存在“{”字符,并且“{”的右侧存在“}”;
  • “{”与“}”之间存在一个或多个字符;

满足以上两个条件,Redis Cluster将把“{”与“}”之间的内容作为真正的key进行哈希槽计算,否则还是使用原来的输入执行计算。需要注意:“{”和“}”的匹配遵循最左匹配原则。举例看下:

  • {user1000}.following{user1000}.followers:最终采用user1000

  • foo{}{bar}:最终采用foo{}{bar}

  • foo{{bar}}zap:最终采用{bar

  • foo{bar}{zap}:最终采用bar

重新分片

当集群中节点压力过大时,我们会考虑通过扩容,让新增节点分担其他节点的哈希槽;当集群中节点压力不平衡时,我们会考虑把部分哈希槽从压力较大的节点转移至压力较小的节点。

Redis Cluster支持在不停机的情况下添加或移除节点,以及节点间哈希槽的迁出和导入,这种动态扩容或配置的方式对于我们的生产实践好处多多。比如:电商场景中,日常流量比较稳定,只要按需分配资源确保安全水位即可;当遇到大促时,流量较大,我们可以新增资源,以不停机、不影响业务的方式实现服务能力的水平扩展。以上两种情况我们称之为重新分片(Resharding)或者在线重配置(Live Reconfiguration),我们来分析下Redis是如何实现的。

通过前面了解集群状态的数据结构,我们知道哈希槽的分配其实是一个数组,数组索引序号对应哈希槽,数组值为负责哈希槽的节点。理论上,哈希槽的重新分配实质上是根据数组索引修改对应的节点对象,然后通过状态传播在集群所有节点达到最终一致。如下图中,把负责哈希槽1001的节点从7000修改为7001。 image.png 实际中,为了实现上面的过程,还需要考虑更多方面。

我们知道,哈希槽是由key经过CRC16计算而来的,哈希槽只是为了把key存储到真正节点时一个虚拟的存在,一切的操作还得回归到key上。当把哈希槽负责的节点从旧节点改为新节点时,需要考虑旧节点存量key的迁移问题,也就是要把旧节点哈希槽中的key全部转移至新的节点。

但是,无论哈希槽对应多少个key,key中存储了多少数据,把key从一个节点迁移至另外一个节点总是消耗时间的,同时需要保证原子性;而且,重新分片过程中,客户端的请求并没有停止,Redis还需要正确响应客户端请求,使之不受影响。

接下来,我们利用示例集群做一次重新分片的实践,并且结合源码深入剖析一下Redis的实现过程。以下示例是把7002节点的两个哈希槽迁移至7000节点,过程简述如下:

  • 使用命令redis-cli --cluster reshard 127.0.0.1:7000对集群发起重新分片的请求;
  • redis-cli输出集群当前哈希槽分配情况后,询问迁移哈希槽的数量How many slots do you want to move (from 1 to 16384)?,输入数字2,回车确认;
  • redis-cli询问由哪个节点接收迁移的哈希槽:What is the receiving node ID? ,输入节点7000的ID,回车确认;
  • redis-cli询问迁移哈希槽的来源:输入all代表从其他所有节点中均分,逐行输入节点ID以done结束代表从输入节点迁移哈希槽,这里我输入了7002的节点ID;
  • redis-cli输出本次重新分片的计划,源节点、目标节点以及迁移哈希槽的编号等内容;输出yes确认执行,输入no停止;
  • 输入yes后,redis-cli执行哈希槽迁移;

执行过程截图如下: image.png

以上过程对应的源码为文件redis-cli.cclusterManagerCommandReshard函数,代码比较多,我们关注的是哈希槽是如何在节点间迁移的,所以我们仅贴出哈希槽迁移部分代码进行分析:

static int clusterManagerCommandReshard(int argc, char **argv) {
    /* 省略代码 */
    int opts = CLUSTER_MANAGER_OPT_VERBOSE;    
    listRewind(table, &li);
    // 逐个哈希槽迁移
    while ((ln = listNext(&li)) != NULL) {
        clusterManagerReshardTableItem *item = ln->value;
        char *err = NULL;
        // 把哈希槽从source节点迁移至target节点
        result = clusterManagerMoveSlot(item->source, target, item->slot,
                                        opts, &err);
        /* 省略代码 */
    }    
}

/* Move slots between source and target nodes using MIGRATE.*/
static int clusterManagerMoveSlot(clusterManagerNode *source, clusterManagerNode *target, int slot, int opts,  char**err)
{
    if (!(opts & CLUSTER_MANAGER_OPT_QUIET)) {
        printf("Moving slot %d from %s:%d to %s:%d: ", slot, source->ip,
               source->port, target->ip, target->port);
        fflush(stdout);
    }
    if (err != NULL) *err = NULL;
    int pipeline = config.cluster_manager_command.pipeline,
        timeout = config.cluster_manager_command.timeout,
        print_dots = (opts & CLUSTER_MANAGER_OPT_VERBOSE),
        option_cold = (opts & CLUSTER_MANAGER_OPT_COLD),
        success = 1;
    if (!option_cold) {
        // 设置target节点哈希槽为importing状态
        success = clusterManagerSetSlot(target, source, slot, "importing", err);
        if (!success) return 0;
        // 设置source节点哈希槽为migrating状态
        success = clusterManagerSetSlot(source, target, slot, "migrating", err);
        if (!success) return 0;
    }
    // 迁移哈希槽中的key
    success = clusterManagerMigrateKeysInSlot(source, target, slot, timeout, pipeline, print_dots, err);
    if (!(opts & CLUSTER_MANAGER_OPT_QUIET)) printf("\n");
    if (!success) return 0;
    /* Set the new node as the owner of the slot in all the known nodes. */
    /* 依次通知所有节点:负责这个哈希槽的节点变更了 */
    if (!option_cold) {
        listIter li;
        listNode *ln;
        listRewind(cluster_manager.nodes, &li);
        while ((ln = listNext(&li)) != NULL) {
            clusterManagerNode *n = ln->value;
            if (n->flags & CLUSTER_MANAGER_FLAG_SLAVE) continue;
            // 向节点发送命令:CLUSTER SETSLOT
            redisReply *r = CLUSTER_MANAGER_COMMAND(n, "CLUSTER SETSLOT %d %s %s", slot, "node", target->name);
            /* 省略代码 */
        }
    }
    /* Update the node logical config */
    if (opts & CLUSTER_MANAGER_OPT_UPDATE) {
        source->slots[slot] = 0;
        target->slots[slot] = 1;
    }
    return 1;
}

clusterManagerCommandReshard函数首先根据集群中哈希槽分配情况及迁移计划,找到需要迁移的哈希槽列表,然后使用clusterManagerMoveSlot函数逐个哈希槽进行迁移,它是迁移哈希槽的核心方法,主要包含几个步骤。大家可以结合示意图和文字说明了解一下(每幅图上面为源节点,下面为目标节点): image.png 上图是把哈希槽1000,从7000节点迁移至7001节点的集群状态变化过程,步骤说明:

  • 修改源节点和目标节点的迁移状态,对应第一幅图,其中:
    • 通知目标节点,把指定slot设置为importing状态;
    • 通知源节点,把指定哈希槽设置为migrating状态;
  • 迁移源节点slot中的key到目标节点,对应第二、三幅图(这一步可能会耗时,key迁移过程中节点的命令处理线程是被占用的)
    • 使用命令CLUSTER GETKEYSINSLOT <slog> <pipeline>从源节点查询slot中所有的keys;
    • 使用MIGRATE程序把keys从源节点迁移至目标节点,逐个迁移key,每个key的迁移是原子操作,期间会锁定双方节点。
  • 通知所有节点,把负责slot的节点设置为最新节点,同时移除源节点、目标节点中的importing、migrating状态,对应第四幅图。

好了,重新分片的过程就介绍完了。

重定向

数据分片使得所有的key分散存储在不同的节点,而且随着重新分片或者故障转移,哈希槽与节点之间的映射关系会发生改变,那么当客户端发起对一个可以的操作时,集群节点与客户端是如何处理的呢?我们解析来了解一下两种重定向机制。

MOVED重定向

由于数据分片机制,Redis集群中每个节点仅负责一部分哈希槽,也就是一部分key的存储及管理工作。客户端可以随意向集群中任何一个节点发起命令请求,此时节点会计算当前请求key对应的哈希槽,并通过哈希槽与节点的映射关系查询负责该哈希槽的节点,根据查询结果Redis会有如下操作:

  • 如果是当前节点负责该key,那么节点就会立即执行命令;
  • 如果是其他节点负责该key,那么节点就会向客户端返回一个MOVED错误。

举个例子来看,首先通过常规方式使用redis-cli连接至7000端口节点,然后执行get TestKey命令,如下所示:

redis-cli -p 7000               
127.0.0.1:7000> GET TestKey
(error) MOVED 15013 127.0.0.1:7002

返回结果告诉我们,TestKey对应的哈希槽为15013,应该由7002节点负责。客户端可以根据返回结果中的MOVED错误信息,解析出负责该key的节点ip和端口,并与之建立连接,然后重新执行即可。做下测试,效果如下:

redis-cli  -p 7002
127.0.0.1:7002> GET TestKey
(nil)

为什么会这样呢?

因为Redis Cluster每个节点都保存了哈希槽与节点的映射关系,当客户端请求的key不在当前节点的负责范围之内时,节点不会充当目标节点的代理,而是以错误的方式告知客户端在它看来应该由那个节点负责该key。当然,如果正好赶上哈希槽迁移,节点返回的信息不一定准确,客户端可能还会收到MOVED或ASK错误。

所以,这就要求客户端具备这种重定向的能力,及时连接之正确的节点重新发起命令请求。如果客户端与节点之间总是通过重定向的方式处理命令,性能必然不如普通Redis模式高。

怎么办呢?Redis官方提出了两种可选的缓存办法:

  • 执行请求前,客户端首先根据输入的key计算哈希槽。若当前连接对应的节点可以处理该请求,则把哈希槽与节点(ip和端口)映射关系保存起来;若发生重定向,则连接至新的节点,重新请求,直到可以执行成功,最后把哈希槽与节点的关系保存起来。这样,当客户端就可以在先查询缓存,再执行请求,提高效率。
  • 通过命令CLUSTER NODES查询集群节点状态,从中获取哈希槽与节点的映射关系,在客户端本地缓存起来。每次请求时,先计算key的哈希槽,再查询节点,最后执行请求,更加高效。

在集群稳定运行期间,当然大部分时间也是稳定运行的,以上方式都能够大大提高命令执行的效率。但是,由于集群运行期间可能发生重新分片,客户端维护的信息就会变得不准确,所以当客户端哈希槽对应的节点发生改变时,客户端应该及时修正。

自5.0版本起,redis-cli已经具备了MOVED重定向能力。再以集群客户端的方式连接至7000节点,执行上述命令,效果图如下:

redis-cli -c -p 7000
127.0.0.1:7000> GET TestKey
-> Redirected to slot [15013] located at 127.0.0.1:7002
(nil)
127.0.0.1:7002> 

虽然向7000节点发起请求,但是客户端在接收到7000的返回结果后,自动连接至7002并重新执行了请求。

结合以上示例,在重新分片的过程中,客户端向节点请求key(CRC16=1000)命令,会不会有影响呢?带着这个问题,我们一起来看下ASK重定向。

ASK重定向

在重新分片时,源节点向目标节点迁移哈希槽的过程中,该哈希槽所存储的key有的在源节点,有的已经迁移至目标节点。此时客户端向源节点发起命令请求(尤其是多key的情况),MOVED重定向就无法正常的工作了。下图为此时集群的状态示意图,我们来分析下: image.png

为了全面完整的说明ASK重定向过程,本部分所阐述的对节点发起的命令中将包含多个具有相同哈希槽的key,比如{test}1、{test}2,用复数keys表示,并假设test对应的哈希槽为1000。

如前文所述,按照“MOVED重定向”原理,当客户端向节点发起keys的请求时,会首先计算CRC16得到keys对应的哈希槽,然后通过哈希槽与节点的映射关系找到负责该哈希槽的节点,最后决定时立即执行还是返回MOVED错误。

但是,如果集群正处于重新分片过程中,客户端请求的keys可能还未迁移,也可能已经迁移,我们看下会发生什么?

  • 未迁移:客户端直接或者通过MOVED重定向请求至7000节点,7000节点检查后需要自己处理请求,并且keys存储在自己节点内,可以正常处理请求;
  • 已迁移:客户端直接或者通过MOVED重定向请求至7000节点,7000节点检查后需要自己处理请求,但是此时keys已经被完全或部分迁移至7001节点,所以执行时无法找到keys,无法正常处理请求;

因此,在这种情况下MOVED重定向是不适用的。为此,Redis Cluster引入了ASK重定向,我们来看下ASK重定向的工作原理。

客户端根据本地缓存的哈希槽与节点的映射关系,向7000节点发起keys请求,根据keys的迁移进度,7000节点的执行流程如下:

  • keys对应的哈希槽slot是由7000节点负责,如果:
    • 哈希槽1000不在迁移过程中(migrating),则当前请求由7000节点执行并返回执行结果;
    • 哈希槽1000在迁移过程中(migrating),但是keys对应的key都未迁移走,说明此时7000节点可以执行当前请求,则当前请求由7000节点处理并返回执行结果;
    • 哈希槽1000在迁移过程中(migrating),但是keys对应的key已经完全或部分迁移至7001,则以ASK重定向错误告知客户端需要请求的节点,格式如下:
(error) -ASK <slot> <ip>:<port>
# 对应示例结果为:
(error) -ASK 1000 127.0.0.1:7001
  • 客户端接收到ASK重定向错误信息后,将为该哈希槽(1000)设置强制指向新的节点(7001)的一次性标识,然后执行以下操作:
    • 向7001节点发送ASKING命令,并移除一次性标识;
    • 紧接着向7001节点发送真正需要请求的命令;
  • 7001节点接收客户端ASKING请求后,如果:
    • 哈希槽1000正在导入中(importing),当前请求的keys对应的key已经全部导入完成,则7001节点执行该请求并返回执行结果;
    • 哈希槽1000正在导入中(importing),当前请求的key对应的key未完全导入完成,则返回重试错误(TRYAGAIN);

这样,如果客户端请求的keys处于迁移过程,节点将以ASK重定向错误的方式返回客户端,客户端再向新的节点发起请求。当然,会有一定的概率由于keys未迁移完成而导致请求失败,此时节点将回复“TRYAGAIN”,客户端可以稍后重试。

一旦哈希槽迁移完成,客户端将收到节点回复的MOVED重定向错误,意味着哈希槽的管理权已经转移至新的节点,此时客户端可修改本地的哈希槽与节点映射关系,采用“MOVED重定向”逻辑向新节点发起请求。

MOVED重定向与ASK重定向

通过前面部分的介绍,相信大家已经对两者的区别有了一定的了解,简单总结一下。

  • 两者都是以错误的方式告知客户端应该向其他节点发起目标请求;
  • MOVED重定向:告知客户端当前哈希槽是由哪个节点负责,它是以哈希槽与节点的映射关系为基础的。如果客户端接收到此错误,可以直接更新本地的哈希槽与节点的映射关系缓存。这是一种相对稳定的状态。
  • ASK重定向:告知客户端,它所请求的keys对应的哈希槽当前正在迁移至新的节点,当前节点已经无法完成请求,应该向新节点发起请求。客户端接收到此错误,将会临时(一次性)重定向,以询问(ASKING)的方式向新节点发起请求尝试。该错误不会影响接下来客户端对相同哈希槽的请求,除非它再次收到ASK重定向错误。

集群扩容或缩容期间可以正常提供服务吗?

这个是面试中经常遇到的问题,如果你理解问题的本质,这个问题就不难回答了。我们来分析一下:

  • 集群扩容:如果增加主节点:增加主节点后,刚开始它是不负责任何哈希槽的。为了能够分摊系统压力,我们要进行重新分片,把一部分哈希槽转移到新加入的节点,所以这实质上是一个重新分片的过程。如果增加从节点,只需要与指定的主节点进行主从复制过程。
  • 集群缩容:缩容意味着从集群中摘除节点。如果摘除主节点,正常情况下,主节点负责一部分哈希槽的读写,若要安全摘除,需要先把该哈希槽负责的节点转移至其他节点,这也是一个重新分片过程。如果摘除从节点,直接摘除即可。

对主节点的扩容或者缩容本质上是一个重新分片的过程,重新分片涉及哈希槽迁移,也就是哈希槽内key的迁移。Redis Cluster提供了ASK重定向来告知客户端目前集群发生的状况,以便客户端进行调整:ASKING重定向或者重试。

所以,整体上来讲,扩容或者缩容期间,集群是可以正常提供服务的。

总结

数据分片是Redis Cluster动态收缩,具备可扩展性的根基,虽然本文内容写的比较啰嗦,但是原理还是比较简单的。大家重点理解扩容的过程与本质,就可以以不变应万变。

后续内容更精彩~