Redis分布式-集群详解

Redis集群通过分片来进行数据共享,并提供复制和故障转移功能。本文将对集群的节点、槽指派、命令执行、重新分片、转向、故障转移等各个方面进行介绍。

本文主要内容参考自《Redis设计与实现》

节点

一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中。可以通过CLUSTER MEET命令来连接各个节点,从而构建一个包含多节点集群。命令格式如下:

CLUSTER MEET <ip> <port> 

当一个节点执行CLUSTER MEET命令,就可以将对应ipport的节点加入到自己当前所在的集群中(首先会进行握手,握手成功后才添加进来)。假设现在有三个节点,ip都是127.0.0.1,端口分别是700070017002。当在7000端口上的节点执行CLUSTER MEET 127.0.0.1 7001,就会将7001节点加入到7000节点所在的集群中。在7000端口上的节点再执行CLUSTER MEET 127.0.0.1 7002时,就会将7002节点也加入到7000节点所在的集群中。至此,这三个节点就组成了一个集群。 Redis分布式-集群详解

启动节点

一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动的时候根据cluster-enabled配置项的值来决定是否开启集群模式。如果是yes,开启集群模式成为一个节点;否则,开启单机模式成为一个普通服务器。

节点会继续使用redisServer结构保存服务器状态,使用redisClient结构保存客户端状态,至于那些只在集群模式下才会用到的数据,节点将它们保存在cluster.h/clusterNode结构,cluster.h/clusterLink结构和cluster.h/clusterState结构里面。

集群数据结构

clusterNode结构保存了节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP和端口等等。每个节点都会使用一个clusterNode结构记录自己的状态,并且会为集群中所有其他节点都创建一个相应的clusterNode结构

struct clusterNode {

    // 创建节点的时间
    mstime_t ctime;

    // 节点的名字,由40个十六进制字符组成
    char name[REDIS_CLUSTER_NAMELEN];

    // 节点标识
    // 使用不同的标识值记录节点的角色(比如主节点或者从节点)
    // 以及节点目前所处的状态(比如在线或者下线)
    int flags;

    // 节点当前的配置纪元,用于实现故障转移
    uint64_t configEpoch;

    // 节点的IP地址
    char ip[REDIS_IP_STR_LEN];

    // 节点的端口号
    int port;

    // 保存连接节点所需的有关信息
    clusterLink *link;

    // ...

}; 

clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓存区和输出缓存区:

typedef struct clusterLink {
    // 连接的创建时间
    mstime_t ctime;

    // TCP套接字描述符
    int fd;

    // 输出缓存区,保存着等待发送给其它节点的消息
    sds sndbuf;

    // 输入缓存区,保存着从其他节点接收到的消息
    sds rcvbuf;

    // 与这个连接相关联的节点,如果没有的话为NULL
    struct clusterNode *node;
} clusterLink; 

最后,每个节点都保存着一个RedisState结构,记录了在当前节点视角下,集群目前所处的状态。例如集群是在线还是下线,集群中包含多少个节点,集群当前的配置纪元等。

typedef struct clusterState {
    // 指向当前节点的指针
    clusterNode *myself;

    // 集群当前的配置纪元,用于实现故障转移
    unit64_t currentEpoch;

    // 集群当前的状态:是在线还是下线
    int state;

    // 集群中至少处理着一个槽的节点数量
    int size;

    // 集群节点名单
    dict *nodes;

    // ...
} clusterState; 

以前面介绍的7000、7001和7002为例,下图展示了节点7000创建的clusterState结构,这个结构从7000这个节点角度记录了集群以及集群包含的三个节点的当前状态。 Redis分布式-集群详解

  • currentEpoch的属性值为0,表示集群当前的配置纪元为0
  • size属性值为0,表示集群目前没有任何节点在处理槽,因此stateREDIS_CLUSTER_FAIL,表示节点处于下线状态。
  • nodes属性记录了集群目前包含的三个节点
  • 三个节点的flags属性都是REDIS_NODE_MASTER,表示三个节点都是主节点。

如果你觉得自己学习效率低,缺乏正确的指导,可以加入资源丰富,学习氛围浓厚的技术圈一起学习交流吧!
[Java架构群]
群内有许多来自一线的技术大牛,也有在小厂或外包公司奋斗的码农,我们致力打造一个平等,高质量的JAVA交流圈子,不一定能短期就让每个人的技术突飞猛进,但从长远来说,眼光,格局,长远发展的方向才是最重要的。

槽指派

Redis集群通过分片的方式来保存数据库中的键值对:集群的数据库被分为16384个槽(slot),数据库中的每个键都属于这16384个槽中的一个,集群中的每个节点可以处理0个或者最多16384个槽

当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok),否则处理下线状态(fail)

在上一节中,我们将7000、7001和7002三个节点连接到同一集群,不过此时,这个集群仍然处于下线状态,因为集群中的三个节点都没有在处理任何槽。通过向节点发送CLUSTER ADDSLOTS命令,可以将一个或者多个槽指派(assgin)给节点负责。

CLUSTER ADDSLOTS <slot> [slot ...] 

比如,我们可以将0-5000指派给7000负责,5001-100000指派给7002负责,10001-16383指派给7002负责。当完成指派之后,整个集群就会处于上线状态。

记录节点的槽指派信息

clusterNode结构的slots属性和numslot属性记录了节点负责处理的哪些槽:

struct clusterNode {
    // ...
    unsigned char slots[16384/8];

    int numslots;
} 

slots属性是一个二进制数组,长度为16384/8=2048个字节,共16384位,刚好对应16384个槽。如果二进制位是1,那么该位置的槽就由当前节点处理。numslot属性记录了slots二进制数组中值为1的个数,也就是当前节点处理的槽的数量。

传播节点的槽指派信息

一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslot属性中,还会将其发送给集群中的其它节点。这样的话,集群中的每个节点都会知道数据库中的16384个槽被指派给了哪些节点

记录集群所有的槽指派信息

通过前面的记录节点的槽指派信息和传播节点的槽指派信息,实际上集群中的每个节点都会知道数据库中的16384个槽被指派给了哪些节点。但是具体要知道某个槽对应的节点,上述的数据结构的时间复杂度是O(N)。为了提高效率,clusterState结构中的slots数组记录了所有16384个槽的指派信息。

typedef struct clusterState {

    clusterNode *slots[16384];
} clusterState; 

slots数组包含16384个项,每个项是指向clusterNode的指针。这样的话,要知道某个槽对应的节点,时间复杂度就是O(1)。如果slots[i]指向NULL,就表示这个槽位没有被指派。

在集群中执行命令

在对数据16384个槽进行指派之后,集群就会进入上线状态。这时客户端节能向集群发送命令了。当客户端向节点发送与数据库键有关命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,然后检查这个槽是否指派给了自己:

  • 如果指派给了自己,那么当前节点直接执行这个命令。
  • 如果指派给了其它节点,那么当前节点会向客户端返回MOVED错误(包含这个键所属的节点),客户端再转向正确的节点发送数据命令。

Redis分布式-集群详解

计算键属于哪个槽

节点使用一下算法来计算给定键key属于哪个槽:

def slot_number(key):
    return CRC16(key) & 16383 

使用CLUSTER KEYSLOT <key>命令可以查看给定键属于哪个槽。

命令处理

当计算出键所属的槽i之后,接下来就会到clusterState.slots[i]找到这个槽对应的节点。如果该节点就是当前节点,那么直接执行命令,否则,返回MOVED错误,MOVED错误格式为MOVED <slot> <ip>:<port>。这样,客户端在收到MOVED错误之后,就能将命令转向给正确的节点执行了。

节点数据库实现

节点和单机服务器在数据库方面有一个区别,节点只能使用0号数据库。另外,除了将键值对保存在数据库之外,节点还会用clusterState结构中的slots_to_keys跳跃表来保存槽和键的关系。跳跃表的score是槽号,value是数据库键。通过slots_to_keys跳跃表,节点可以很方便的对某个或者某些槽的数据库键进行批量操作

typedef struct clusterState {

    // ...
    zskiplist *slots_to_keys;
    // ...
} clusterState; 

重新分片

Redis集群的重新分片操作可以将任意数量的已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点移动到目标节点

重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理请求。

比如,我们新启动一个端口为7003的服务器,将原本指派给7002的15001-16383的槽改指派给节点7003。

重新分片实现原理

Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的。redis-trib对集群单个槽slot重新分片步骤如下:

  1. redis-trib对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,让目标节点准备好从源节点导入槽slot的键值对。
  2. redis-trib对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,让源节点准备好将属于槽slot的键值对迁移(migrate)至目标节点。
  3. redis-trib向源节点发送CLUSTER GETKEYSINSLOT <slot> <count>命令,获取最多count个属于槽slot的键名。
  4. 对于步骤三的每个键名,redis-trib都向源节点发送一个MIGRATE <target_id> <target_port> <key_name> 0 <time_out>命令,将该键原子地从源节点迁移到目标节点。
  5. 重新执行步骤三和四,直到槽slot的键值对全部迁移至目标节点。
  6. redis-trib向集群任意一个节点发送CLUSTER SETSLOT <slot> NODE <target_id>命令,将槽slot指派给目标节点。并且指派信息会通过消息发送到整个集群。

Redis分布式-集群详解

如果重新分片涉及多个槽,就对每个槽执行上述步骤。对slot槽进行重新分片过程如下: Redis分布式-集群详解

ASK错误

在重新分片期间,源节点向目标节点迁移一个槽的过程中,可能出现属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。当客户端向源节点发送数据库键相关的命令,并且数据库键恰好就属于正在被迁移的槽时。源节点首先会在自己的数据库里面查找该数据库键,如果找到则直接在源节点执行命令,否则,源节点返回一个ASK错误,指引客户端转向目标节点Redis分布式-集群详解

复制与故障转移

Redis集群中的节点分为主节点和从节点,主节点用于处理槽,从节点用于复制某个主节点,并且在主节点下线时代替主节点继续进行客户端命令。举个栗子,假设有7000、7001、7002、7003四个主节点,其中还有7004和7005作为70000的从节点。当7000节点下线时,会从7004和7005中选举出一个(假设选中了7004)作为代替7000的主节点。当7000重新上线时,7000会作为7004的从节点。 Redis分布式-集群详解

主节点用双圆环表示。

设置从节点

向节点发送CLUSTER REPLICATE <node_id>命令,可以让该节点成为node_id的从节点。

故障检测

集群中每个节点都会定期向其他节点发送PING消息,以此来检测对方是否在线。如果对方节点在规定时间内没有返回PONG消息,那么发送方节点就会将对应的节点标记为疑似下线状态(PFIAL)。如果一个集群内,半数以上主节点都将某个主节点x标记为疑似下线状态,那么这个节点x就会被标记为已下线状态(FAIL)

关键词:心跳检测+过半原则

故障转移

当从节点发现自己复制的主节点进入已下线状态时,从节点就开始对下线主节点进行故障转移,以下是故障转移的执行步骤。

  1. 基于Raft算法,选举出一个从节点。
  2. 被选中的从节点执行SLAVEOF no one,成为主节点。
  3. 新的主节点会撤销已下线主节点的槽指派,并将这些槽全部指派给自己。
  4. 新的主节点向集群广播一条PONG消息,让集群中其它节点知道新的主节点已经代替了已下线主节点。
  5. 新的主节点开始接收和处理自己负责的槽有关的命令请求,故障转移完成。

总结

优势

  1. 无中心架构。
  2. 数据按照 slot 存储分布在多个节点,节点间数据共享,可动态调整数据分布。
  3. 可扩展性,可线性扩展到 1000 个节点(官方推荐不超过 1000 个),节点可动态添加或删除。
  4. 高可用性,部分节点不可用时,集群仍可用。通过增加 Slave 做 standby 数据副本,能够实现故障自动failover,节点之间通过 gossip协议交换状态信息,用Raft投票机制完成SlaveMaster的角色提升。
  5. 降低运维成本,提高系统的扩展性和可用性。

不足

  1. Client实现复杂,驱动要求实现Smart Client,缓存slots mapping信息并及时更新,提高了开发难度,客户端的不成熟影响业务的稳定性。
  2. 节点会因为某些原因发生阻塞(阻塞时间大于 clutser-node-timeout),被判断下线,这种 failover 是没有必要的。
  3. 数据通过异步复制,不保证数据的强一致性。

最后

给大家分享一篇一线开发大牛整理的java高并发核心编程神仙文档,里面主要包含的知识点有:多线程、线程池、内置锁、JMM、CAS、JUC、高并发设计模式、Java异步回调、CompletableFuture类等。

文档地址:一篇神文就把java多线程,锁,JMM,JUC和高并发设计模式讲明白了

码字不易,如果觉得本篇文章对你有用的话,请给我一键三连!关注作者,后续会有更多的干货分享,请持续关注!

上一篇:机器学习入门:7000字详解 Python 环境安装


下一篇:【7000字干货】数据分析师必备的20种分析思维