Redis奇幻之旅(四)4. Redis Cluster

4. Redis Cluster

​ 《4.1.1.2 集群解决的第二个问题》中提到我们去做集群实际上是为了解决机器的性能瓶颈。通俗来讲:假设我们单台机器可以支持100QPS,当我们的业务量级达到了110QPS的时候,单台机器就处理不了这么多的请求。于是我们就用两台机器来承接业务,理论上两台机器有200的QPS,这样就满足了业务需求。这种思路其实就是我们Mysql中经常说到的分表分库,用个更常见的词这叫做分片(sharding),有许多技术都是这样来支持横向扩展的,比如:Kafka、ELasticsearch、MongoDB。

​ 分片完之后会导致数据被散列在多个机器上,那么客户端如何去CRUD想要的那个数据呢?一般分为三种情况:

  • 客户端路由

    顾名思义,就是在客户端写代码来向不同的Redis进行CRUD。

  • 中间代理路由

    在客户端和多个Redis数据库中间加一层代理,可以由这个代理来分派具体的指令在哪个Redis数据库来处理,常见的方案有:Codis、Twemproxy。

  • 服务端路由

    将数据和Redis数据库的Mapping关系记录在Redis数据库这边,客户端可以随便访问集群中的某台机器,这台机器如果发现这个数据不在自己这里,则会告知客户端这个数据在哪台机器,引导客户端去相应的机器处理数据。

​ Redis Cluster就用的”服务端路由“这种方式来解决数据扩容问题,下面我们就来详细讨论一下具体的实现。

4.1 槽位

​ Redis Cluster没有使用一致性hash,而是引入了哈希槽的概念。Redis Cluster有16384个哈希槽(旧版只有4k个),每个key通过CRC16校验后对16384取模来决定放置哪个槽。集群的每个节点负责一部分hash槽。

​ 虽然理论上Redis支持每个槽分派一个Redis节点,然而建议的最大节点数量设置在1000这个数量级上。因为集群节点过多会导致Gossip消息触达不及时,引发数据不一致时间过长。

​ 关于取模映射的算法:

HASH_SLOT = CRC16(key) mod 16384

​ 生产过程中,我们可能会需要将某些key放在同一个槽位,不用担心,Redis Cluster支持我们给某个key打上标签,如果一个键包含一个 “{…}” 这样的模式,只有”{”和“}”之间的字符串会被用来做哈希,这样就能保证某些数据被精准的映射到某个槽位。

​ 我们可以使用CLUSTER ADDSLOTS命令将槽位指派在某个节点上,当Redis Cluster的16384个槽位都被指派给响应的节点后,集群才会进入上线状态。

4.2 节点

​ Redis config文件中参数cluster-enabled配置成yes的时候,启动Redis就开启了服务器的集群模式,但这个时候Redis只是一个独立的节点,想要这个节点与其他节点进行关联就得使用CLUSTER MEET命令。具体格式如下:

CLUSTER MEET  

​ 假如我们有A和B两台Redis服务器,客户端向A发送一条命令叫A去MEET一下B。这时候A就会和B进行握手,当握手成功时,B就会被添加到A所在的集群中来。我们可以用CLUSTER NODES命令来查看当前集群下有哪些节点。

​ 一个Redis服务器单机启动和集群启动差异不大。需要注意的差异是,集群启动只能使用0号数据库且时间事件处理器会有所不同,因为这时的serverCron函数会调用集群模式特有的clusterCron函数,来完成一些集群特有的操作。比如:检查其他节点是否下线,是否需要进行自动故障转移,向其他节点发送Gossip消息等。除此之外,还有一些集群特有的数据,Redis会将其存储在clusterNode结构、clusterLink结构、clusterState结构里,他们的关系是clusterState中的dict *nodes中放着clusterNode,clusterNode中的clusterLink *link放着clusterLink。这三个结构的具体内容如下:

typedef struct clusterNode {
    mstime_t ctime; /* Node object creation time. */
    char name[CLUSTER_NAMELEN]; /* Node name, hex string, sha1-size */
    int flags;      /* CLUSTER_NODE_... */
    uint64_t configEpoch; /* Last configEpoch observed for this node */
    unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
    int numslots;   /* Number of slots handled by this node */
    int numslaves;  /* Number of slave nodes, if this is a master */
    struct clusterNode **slaves; /* pointers to slave nodes */
    struct clusterNode *slaveof; /* pointer to the master node. Note that it
                                    may be NULL even if the node is a slave
                                    if we don't have the master node in our
                                    tables. */
    mstime_t ping_sent;      /* Unix time we sent latest ping */
    mstime_t pong_received;  /* Unix time we received the pong */
    mstime_t data_received;  /* Unix time we received any data */
    mstime_t fail_time;      /* Unix time when FAIL flag was set */
    mstime_t voted_time;     /* Last time we voted for a slave of this master */
    mstime_t repl_offset_time;  /* Unix time we received offset for this node */
    mstime_t orphaned_time;     /* Starting time of orphaned master condition */
    long long repl_offset;      /* Last known repl offset for this node. */
    char ip[NET_IP_STR_LEN];  /* Latest known IP address of this node */
    int port;                   /* Latest known clients port of this node */
    int cport;                  /* Latest known cluster port of this node. */
    clusterLink *link;          /* TCP/IP link with this node */
    list *fail_reports;         /* List of nodes signaling this as failing */
} clusterNode;

typedef struct clusterLink {
    mstime_t ctime;             /* Link creation time */
    connection *conn;           /* Connection to remote node */
    sds sndbuf;                 /* Packet send buffer */
    sds rcvbuf;                 /* Packet reception buffer */
    struct clusterNode *node;   /* Node related to this link if any, or NULL */
} clusterLink;

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 */
    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];
    uint64_t slots_keys_count[CLUSTER_SLOTS];
    rax *slots_to_keys;
    /* The following fields are used to take the slave state on elections. */
    mstime_t failover_auth_time; /* Time of previous or next election. */
    int failover_auth_count;    /* Number of votes received so far. */
    int failover_auth_sent;     /* True if we already asked for votes. */
    int failover_auth_rank;     /* This slave rank for current auth request. */
    uint64_t failover_auth_epoch; /* Epoch of the current election. */
    int cant_failover_reason;   /* Why a slave is currently not able to
                                   failover. See the CANT_FAILOVER_* macros. */
    /* Manual failover state in common. */
    mstime_t mf_end;            /* Manual failover time limit (ms unixtime).
                                   It is zero if there is no MF in progress. */
    /* Manual failover state of master. */
    clusterNode *mf_slave;      /* Slave performing the manual failover. */
    /* Manual failover state of slave. */
    long long mf_master_offset; /* Master offset the slave needs to start MF
                                   or zero if stil not received. */
    int mf_can_start;           /* If non-zero signal that the manual failover
                                   can start requesting masters vote. */
    /* The followign fields are used by masters to take state on elections. */
    uint64_t lastVoteEpoch;     /* Epoch of the last vote granted. */
    int todo_before_sleep; /* Things to do in clusterBeforeSleep(). */
    /* Messages received and sent by type. */
    long long stats_bus_messages_sent[CLUSTERMSG_TYPE_COUNT];
    long long stats_bus_messages_received[CLUSTERMSG_TYPE_COUNT];
    long long stats_pfail_nodes;    /* Number of nodes in PFAIL status,
                                       excluding nodes without address. */
} clusterState;

​ 我们会使用CLUSTER ADDSLOTS命令将槽位指派在某个节点,这时这个节点会将槽位信息存储在clusterNode结构中的unsigned char slots[CLUSTER_SLOTS/8]中(CLUSTER_SLOTS=16384)。slots是一个二进制位数组,一共2048个字节(16384 / 8 = 2048),以每个位来记录槽点是否指派在节点之中,这种设计是bitmap的基本思想,减少了存储的空间,也让这个数据被传播的时候能够更加节省网络带宽。当一个节点槽位发生变动的时候,这个节点会将slots信息通过Gossip消息传播给其他节点,让其他节点能有一个全局的槽点分派关系数据。这个全局的槽点分派关系数据就记录在clusterState结构体的clusterNode *slots[CLUSTER_SLOTS]中。

4.3 交互实现

​ 模拟一个场景,现在我们的集群中有三个Redis节点A、B、C。A负责处理槽0至槽5000;B负责处理槽5001至10000;C负责处理槽10001至16383。

> step1:客户端向A发送一条SET key语句
>
> step2:A收到消息,并计算key是在槽位6000中,发现这个key不属于自己处理
>
> step3:A返回MOVED错误,告知客户端处理槽位6000的Redis节点B
>
> step4:客户端将SET key语句发送给B
>
> step5:B发现这个key是自己处理,并且执行语句
>
> step6:B返回客户端执行成功

​ 这里粗略讲了客户端和集群之间的交互,通过这个交互,我们就知道了大概的请求流程,并且知道了为什么每个节点都需要存储一个全局的槽点分派关系数据。当然还有一种特殊情况需要说明,那就是槽位迁移也叫重新分片。

​ 槽位迁移,顾名思义就是将已经分派给节点的槽位进行重新分配,这样就会有一部分槽位的数据从旧节点迁移到新的节点中。Redis的槽位迁移是使用redis-trib负责执行的。执行迁移的步骤如下:

step1:redis-trib对目标节点发送“CLUSTER SETSLOT IMPORTING ”命令, 让目标节点准备好从源节点导入(import)属于槽slot的键值对

step2:redis-trib对源节点发送“CLUSTER SETSLOT MIGRATING ”命令,让源节点准备好将属于槽slot的键值对迁移(migrate)至目标节点

step3:redis-trib向源节点发送“CLUSTER GETKEYSINSLOT ”命令,获得最多count个属于槽slot的键值对的键名(key name)

step4:对于步骤3获得的每个键名,redis-trib都向源节点发送一个“MIGRATE 0 ”命令,将被选中的键原子地从源节点迁移至目标节点

step5:重复执行步骤3和步骤4,直到源节点保存的所有属于槽slot的键值对都被迁移至目标节点为止。每次迁移键的过程如下图所示

step6:redis-trib向集群中的任意一个节点发送CLUSTER SETSLOTNODE 命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所 有节点都会知道槽slot已经指派给了目标节点

​ 在进行迁移的过程中,可能刚好碰见客户端访问一个被正在迁移的数据。这时候,旧节点会先查自己的数据库,如果找到了就直接执行客户端命令,如果没找到就会向客户端返回一个ASK错误,这个错误中会指向自己正在迁移的目标节点,让客户端去这个目标节点去执行。

​ 这里就会涉及到一个问题,因为在槽迁移完成之前,在集群的全局槽点分派关系数据中,这个槽位还是属于旧节点,如果客户端拿着这个槽内的key去访问目标节点,目标节点将会给一个MOVED错误,告知客户端应该去旧节点中执行。这样就产生了一个死循环。为了解决这个死循环,Redis做了个规定,如果节点的clusterState.importing_slots_from[CLUSTER_SLOTS]显示节点正在导入该槽,并且发送命令的客户端带有REDIS_ASKING标识,那么目标节点将破例执行一下客户端发来的这次命令。

4.4 故障转移

​ 由于Redis Cluster中也有可能出现单点故障的情况,所以集群中可对每个主节点设置一些从节点。设置从节点的方法很简单只需要向一个节点发送命令:CLUSTER REPLICATE 就可以让接收命令的节点成为node_id所指定节点的从节点。指定完从属关系之后,从节点就会复制主节点的数据,和普通的主从复制不同的是集群中的从节点还会有一些集群相关属性的修改,比如:clusterState.myself.flags属性会打开REDIS_NODE_SLAVE标识。

​ 我们上面说到的clusterCron函数会周期性的检查其他节点的上下线状态,如果发送的PING消息在一定时间没有被目标节点回复,那么发信息的一方会将这个目标节点标记为疑似下线(probable fail,PFAIL)。如果集群中有半数以上的主节点都将目标节点标记为PFAIL,那么目标节点将会被标记成已下线(FAIL)。源节点会向集群广播目标节点FAIL的消息,接到广播的节点会将目标节点标记成FAIL状态。

​ 如果一个主节点被集群标记成FAIL状态,并且这个FAIL的主节点有从节点能继续承担对外的服务,那么就会产生故障转移,否则整个集群将进入不可用状态。

​ 故障转移是由当前在线的所有主节点在FAIL节点的从节点中选举出一个主节点来实现的。具体的选举规则和Sentienl的选举很类似,都是基于Raft算法的领头选举来实现的。大致思路就是,集群会维护一个纪元计数器,每个纪元中所有主节点都有一次投票的权利。备选的从节点会广播自荐成为主节点,先到达拥有投票权主节点的那个备选从节点就会被承认。当集群在某个纪元有过半都选举某个备选从节点,那么这个从节点将成为主节点代替FAIL掉的主节点。

4.5 Gossip消息

​ Redis Cluster的通信使用Gossip协议来实现的,并且满足“各个节点通过发送和接收信息来进行通信”这一规则。集群中有五条消息命令,分别是

  • MEET:请求目标节点加入到自己的集群中
  • PING:每隔一秒会随机PING五个集群中的节点,做心跳检测
  • PONG:收到MEET或者PING消息的时候的回执消息,也在从升主的时候广播PONG消息告知其他节点
  • FAIL:当目标节点不可达时,源节点会广播一条目标节点FAIL的消息
  • PUBLISH:当节点收到PUBLISH消息时,会执行这个命令并向集群广播PUBLISH消息

​ 消息头结构源码如下:

typedef struct {
    char sig[4];        /* Signature "RCmb" (Redis Cluster message bus). */
    uint32_t totlen;    /* Total length of this message */
    uint16_t ver;       /* Protocol version, currently set to 1. */
    uint16_t port;      /* TCP base port number. */
    uint16_t type;      /* Message type */
    uint16_t count;     /* Only used for some kind of messages. */
    uint64_t currentEpoch;  /* The epoch accordingly to the sending node. */
    uint64_t configEpoch;   /* The config epoch if it's a master, or the last
                               epoch advertised by its master if it is a
                               slave. */
    uint64_t offset;    /* Master replication offset if node is a master or
                           processed replication offset if node is a slave. */
    char sender[CLUSTER_NAMELEN]; /* Name of the sender node */
    unsigned char myslots[CLUSTER_SLOTS/8];
    char slaveof[CLUSTER_NAMELEN];
    char myip[NET_IP_STR_LEN];    /* Sender IP, if not all zeroed. */
    char notused1[34];  /* 34 bytes reserved for future usage. */
    uint16_t cport;      /* Sender TCP cluster bus port */
    uint16_t flags;      /* Sender node flags */
    unsigned char state; /* Cluster state from the POV of the sender */
    unsigned char mflags[3]; /* Message flags: CLUSTERMSG_FLAG[012]_... */
    union clusterMsgData data; /* 消息正文 */
} clusterMsg;

你可能感兴趣的:(Redis奇幻之旅(四)4. Redis Cluster)