Redis Cluster

Redis Cluster是Redis官方在Redis 3.0版本正式推出的高可用以及分布式的解决方案。

Redis Cluster由多个Redis实例组成的整体,数据按照槽(slot)存储分布在多个Redis实例上,通过Gossip协议来进行节点之间通信。

image.png
Redis Cluster实现的功能:

• 将数据分片到多个实例(按照slot存储);

• 集群节点宕掉会自动failover;

• 提供相对平滑扩容(缩容)节点。

Redis Cluster分片实现

一般分片(Sharding)实现的方式有list、range和hash(或者基于上述的组合方式)等方式。而Redis的实现方式是基于hash的分片方式,具体是虚拟槽分区。

虚拟槽分区

槽(slot):使用分散度良好的hash函数把所有数据映射到一个固定范围的整数集合中,这个整数集合就是槽。
Redis Cluster槽: Redis Cluster槽的范围是0 ~ 16383。槽是集群内数据管理和迁移的基本单位。

分片的具体算法

Redis Cluster使用slot = CRC16(key) %16384来计算键key属于哪个slot。(Redis先对key使用CRC16算法计算出一个结果,然后再把结果对16384求余,得到结果即跟Redis Cluster的slot对应,也就是对应数据存储的槽数。)

(注: CRC16算法——循环冗余校验(Cyclic Redundancy Check/Code),Redis使用的是CRC-16-CCITT标准,即G(x)为:x16+ x12+ x5+ 1。)

Redis Cluster中的每个分片只需要维护自己的槽以及槽所映射的键值数据。


image.png
Hash标签

哈希标签(hash tags),在Redis集群分片中,可以通过哈希标签来实现指定两个及以上的Key在同一个slot中。只要Key包含“{…}”这种模式,Redis就会根据第一次出现的’{’和第一次出现的’}’之间的字符串进行哈希计算以获取相对应的slot数。如上Redis源码实现。

所以如果要指定某些Key存储到同一个slot中,只需要在命令Key的之后指定相同的“{…}”命名模式即可

unsigned int keyHashSlot(char *key, int keylen) {
    int s, e; /* start-end indexes of { and } */

    for (s = 0; s < keylen; s++)
        if (key[s] == '{') break;

    /* No '{' ? Hash the whole key. This is the base case. */
    if (s == keylen) return crc16(key,keylen) & 0x3FFF;

    /* '{' found? Check if we have the corresponding '}'. */
    for (e = s+1; e < keylen; e++)
        if (key[e] == '}') break;

    /* No '}' or nothing betweeen {} ? Hash the whole key. */
    if (e == keylen || e == s+1) return crc16(key,keylen) & 0x3FFF;

    /* If we are here there is both a { and a } on its right. Hash
     * what is in the middle between { and }. */
    return crc16(key+s+1,e-s-1) & 0x3FFF;
}

集群节点和槽

我们现在已经知道,Redis Cluster中的keys被分割为16384个槽(slot),如果一个槽一个节点的话,那Redis Cluster最大的节点数量也就是16384个。官方推荐最大节点数量为1000个左右。

why redis-cluster use 16384 slots?

CRC16算法产生的hash值有16bit,该算法可以产生2^16-=65536个值。换句话说,值是分布在0~65535之间。那作者在做mod运算的时候,为什么不mod65536,而选择mod16384?
作者原版回答如下:
The reason is:

Normal heartbeat packets carry the full configuration of a node, that can be replaced in an idempotent way with the old in order to update an old config. This means they contain the slots configuration for a node, in raw form, that uses 2k of space with16k slots, but would use a prohibitive 8k of space using 65k slots.
At the same time it is unlikely that Redis Cluster would scale to more than 1000 mater nodes because of other design tradeoffs.
So 16k was in the right range to ensure enough slots per master with a max of 1000 maters, but a small enough number to propagate the slot configuration as a raw bitmap easily. Note that in small clusters the bitmap would be hard to compress because when N is small the bitmap would have slots/N bits set that is a large percentage of bits set.

我们回忆一下Redis Cluster的工作原理!
这里要先将节点握手讲清楚。我们让两个redis节点之间进行通信的时候,需要在客户端执行下面一个命令

127.0.0.1:7000>cluster meet 127.0.0.1:7001

如下图所示


image.png

意思很简单,让7000节点和7001节点知道彼此存在!
在握手成功后,每个节点之间会定期发送ping/pong消息,交换数据信息,如下图所示。


image.png

在这里,我们需要关注三个重点。

(1)交换什么数据信息
(2)数据信息究竟多大
(3)定期的频率什么样

到底在交换什么数据信息?
交换的数据信息,由消息体和消息头组成。
消息体无外乎是一些节点标识啊,IP啊,端口号啊,发送时间啊。

// 用来表示集群消息的结构(消息头,header)
typedef struct {
    char sig[4];        /* Siganture "RCmb" (Redis Cluster message bus). */
    // 消息的长度(包括这个消息头的长度和消息正文的长度)
    uint32_t totlen;    /* Total length of this message */
    uint16_t ver;       /* Protocol version, currently set to 0. */
    uint16_t notused0;  /* 2 bytes not used. */

    // 消息的类型
    uint16_t type;      /* Message type */

    // 消息正文包含的节点信息数量
    // 只在发送 MEET 、 PING 和 PONG 这三种 Gossip 协议消息时使用
    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. */

    // 消息发送者的名字(ID)
    char sender[REDIS_CLUSTER_NAMELEN]; /* Name of the sender node */

    // 消息发送者目前的槽指派信息
    unsigned char myslots[REDIS_CLUSTER_SLOTS/8];

    // 如果消息发送者是一个从节点,那么这里记录的是消息发送者正在复制的主节点的名字
    // 如果消息发送者是一个主节点,那么这里记录的是 REDIS_NODE_NULL_NAME
    // (一个 40 字节长,值全为 0 的字节数组)
    char slaveof[REDIS_CLUSTER_NAMELEN];

    char notused1[32];  /* 32 bytes reserved for future usage. */

    // 消息发送者的端口号
    uint16_t port;      /* Sender TCP base 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;

type表示消息类型。
另外,消息头里面有个myslots的char数组,长度为16383/8,这其实是一个bitmap,每一个位代表一个槽,如果该位为1,表示这个槽是属于这个节点的。

到底数据信息究竟多大?
在消息头中,最占空间的是myslots[CLUSTER_SLOTS/8]。这块的大小是:
16384÷8÷1024=2kb
那在消息体中,会携带一定数量的其他节点信息用于交换。
那这个其他节点的信息,到底是几个节点的信息呢?
约为集群总节点数量的1/10,至少携带3个节点的信息。
这里的重点是:节点数量越多,消息体内容越大。

那定期的频率是什么样的?
redis集群内节点,每秒都在发ping消息。规律如下

(1)每秒会随机选取5个节点,找出最久没有通信的节点发送ping消息
(2)每100毫秒(1秒10次)都会扫描本地节点列表,如果发现节点最近一次接受pong消息的时间大于cluster-node-timeout/2 则立刻发送ping消息
因此,每秒单节点发出ping消息数量为

数量=1+10*num(node.pong_received>cluster_node_timeout/2)

(1)如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。

如上所述,在消息头中,最占空间的是myslots[CLUSTER_SLOTS/8]。
当槽位为65536时,这块的大小是:
65536÷8÷1024=8kb
因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。

(2)redis的集群主节点数量基本不可能超过1000个。
如上所述,集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者,不建议redis cluster节点数量超过1000个。
那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。

(3)槽位越小,节点少的情况下,压缩比高
Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。
如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。

综上所述,作者决定取16384个槽,不多不少,刚刚好!

image.png

1当Redis Cluster中的16384个槽都有节点在处理时,集群处于上线状态(ok);

如果Redis Cluster中有任何一个槽没有得到处理(或者某一分片的最后一个节点挂了),那么集群处于下线状态(fail)。(info cluster中的:cluster_state状态)。那整个集群就不能对外提供服务。

Redis-3.0.0.rc1加入cluster-require-full-coverage参数,默认关闭,打开集群容忍部分失败。

但是如果集群超过半数以上master挂掉,无论是否有slave集群进入fail状态。

节点ID

Redis Cluster每个节点在集群中都有唯一的ID,该ID是由40位的16进制字符组成,具体是节点第一次启动由linux的/dev/urandom生成。具体信息会保存在node.cnf配置文件中(该文件有Redis Cluster自动维护,可以通过参数cluster-config-file来指定路径和名称),如果该文件被删除,节点ID将会重新生成。(删除以后所有的cluster和replication信息都没有了)或者通过Cluster Reset强制请求硬重置。

节点ID用于标识集群中的每个节点,包括指定Replication Master。只要节点ID不改变,哪怕节点的IP和端口发生了改变,Redis Cluster可以自动识别出IP和端口的变化,并将变更的信息通过Gossip协议广播给其他节点。

ClusterNode
struct clusterNode {

    // 创建节点的时间
    mstime_t ctime; /* Node object creation time. */

    // 节点的名字,由 40 个十六进制字符组成
    // 例如 68eef66df23420a5862208ef5b1a7005b806f2ff
    char name[REDIS_CLUSTER_NAMELEN]; /* Node name, hex string, sha1-size */

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

    // 节点当前的配置纪元,用于实现故障转移
    uint64_t configEpoch; /* Last configEpoch observed for this node */

    // 由这个节点负责处理的槽
    // 一共有 REDIS_CLUSTER_SLOTS / 8 个字节长
    // 每个字节的每个位记录了一个槽的保存状态
    // 位的值为 1 表示槽正由本节点处理,值为 0 则表示槽并非本节点处理
    // 比如 slots[0] 的第一个位保存了槽 0 的保存情况
    // slots[0] 的第二个位保存了槽 1 的保存情况,以此类推
    unsigned char slots[REDIS_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 */

    // 最后一次发送 PING 命令的时间
    mstime_t ping_sent;      /* Unix time we sent latest ping */

    // 最后一次接收 PONG 回复的时间戳
    mstime_t pong_received;  /* Unix time we received the pong */

    // 最后一次被设置为 FAIL 状态的时间
    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 */

    // 这个节点的复制偏移量
    long long repl_offset;      /* Last known repl offset for this node. */

    // 节点的 IP 地址
    char ip[REDIS_IP_STR_LEN];  /* Latest known IP address of this node */

    // 节点的端口号
    int port;                   /* Latest known port of this node */

    // 保存连接节点所需的有关信息
    clusterLink *link;          /* TCP/IP link with this node */

    // 一个链表,记录了所有其他节点对该节点的下线报告
    list *fail_reports;         /* List of nodes signaling this as failing */

};

Master 节点维护这一个16384/8字节的位序列,Master节点用bit来标识对于某个槽自己是否拥有。(判断索引是不是为1即可)

slots属性是一个二进制位数组(bit arry),这个数组的长度为16384/8 = 2048个字节,共包含16384个二进制。

Redis Cluster对slots数组中的16384个二进制位进行编号:从0为起始索引,16383为终止索引。

根据索引i上的二进制位的值来判断节点是否负责处理槽i:

•slots数组在索引i上的二进制位的值为1,即表示该节点负责处理槽i;
•slots数组在索引i上的二进制位的值为0,即表示该节点不负责处理槽i;

示例1:(如下节点负责处理slot0-slot7)


image.png

即在Redis Cluster中Master节点使用bit(0)和bit(1)来标识对某个槽是否拥有,而Master只要判断序列第二位的值是不是1即可,时间复杂度为O(1)。

numslots属性记录节点负责处理的槽的数量,也就是slots数组中值为1的二进制位的数量。上图中节点处理的槽数量为8个。

ClusterState

集群中所有槽的分配信息都保存在ClusterState数据结构的slots数组中,程序要检查槽i是否已经被分配或者找出处理槽i的节点,只需要访问clusterState.slots[i]的值即可,时间复杂度为O(1)。

slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针:

•如果slots[i]指针指向null,那么表示槽i尚未指派给任何节点;

•如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点。

// 集群状态,每个节点都保存着一个这样的状态,记录了它们眼中的集群的样子。
// 另外,虽然这个结构主要用于记录集群的属性,但是为了节约资源,
// 有些与节点有关的属性,比如 slots_to_keys 、 failover_auth_count 
// 也被放到了这个结构里面。
typedef struct clusterState {

    // 指向当前节点的指针
    clusterNode *myself;  /* This node */

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

    // 集群当前的状态:是在线还是下线
    int state;            /* REDIS_CLUSTER_OK, REDIS_CLUSTER_FAIL, ... */

    // 集群中至少处理着一个槽的节点的数量。
    int size;             /* Num of master nodes with at least one slot */

    // 集群节点名单(包括 myself 节点)
    // 字典的键为节点的名字,字典的值为 clusterNode 结构
    dict *nodes;          /* Hash table of name -> clusterNode structures */

    // 节点黑名单,用于 CLUSTER FORGET 命令
    // 防止被 FORGET 的命令重新被添加到集群里面
    // (不过现在似乎没有在使用的样子,已废弃?还是尚未实现?)
    dict *nodes_black_list; /* Nodes we don't re-add for a few seconds. */

    // 记录要从当前节点迁移到目标节点的槽,以及迁移的目标节点
    // migrating_slots_to[i] = NULL 表示槽 i 未被迁移
    // migrating_slots_to[i] = clusterNode_A 表示槽 i 要从本节点迁移至节点 A
    clusterNode *migrating_slots_to[REDIS_CLUSTER_SLOTS];

    // 记录要从源节点迁移到本节点的槽,以及进行迁移的源节点
    // importing_slots_from[i] = NULL 表示槽 i 未进行导入
    // importing_slots_from[i] = clusterNode_A 表示正从节点 A 中导入槽 i
    clusterNode *importing_slots_from[REDIS_CLUSTER_SLOTS];

    // 负责处理各个槽的节点
    // 例如 slots[i] = clusterNode_A 表示槽 i 由节点 A 处理
    clusterNode *slots[REDIS_CLUSTER_SLOTS];

    // 跳跃表,表中以槽作为分值,键作为成员,对槽进行有序排序
    // 当需要对某些槽进行区间(range)操作时,这个跳跃表可以提供方便
    // 具体操作定义在 db.c 里面
    zskiplist *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. */

    // 如果值为 1 ,表示本节点已经向其他节点发送了投票请求
    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. */

    /* 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. */
    // 指示手动故障转移是否可以开始的标志值
    // 值为非 0 时表示各个主服务器可以开始投票
    int mf_can_start;           /* If non-zero signal that the manual failover
                                   can start requesting masters vote. */

    /* The followign fields are uesd by masters to take state on elections. */
    /* 以下这些域由主服务器使用,用于记录选举时的状态 */

    // 集群最后一次进行投票的纪元
    uint64_t lastVoteEpoch;     /* Epoch of the last vote granted. */

    // 在进入下个事件循环之前要做的事情,以各个 flag 来记录
    int todo_before_sleep; /* Things to do in clusterBeforeSleep(). */

    // 通过 cluster 连接发送的消息数量
    long long stats_bus_messages_sent;  /* Num of msg sent via cluster bus. */

    // 通过 cluster 接收到的消息数量
    long long stats_bus_messages_received; /* Num of msg rcvd via cluster bus.*/

} clusterState;

示例2:

  1. slots[0]至slots[4999]的指针都指向端口为6381的节点,即槽0到4999都由节点6381负责处理;

  2. slots[5000]至slots[9999]的指针都指向端口为6382的节点,即槽5000到9999都由节点6382负责处理;

  3. slots[10000]至slots[16383]的指针都指向端口为6383的节点,即槽10000到16384都由节点6383负责处理。


    image.png

数组 clusterNode.slots和clusterState.slots:

• clusterNode.slots数组记录了clusterNode结构所代表的节点的槽指派信息(每个节点负责哪些槽)。

• clusterState.slots数组记录了集群中所有槽的指派信息。

• 如果需要查看某个节点的槽指派信息,只需要将相应节点的clusterNode.slots数组整个发送出去即可。

• 但是如果需要查看槽i是否被分配或者分配给了哪个节点,就需要遍历clusterState.nodes字典中所有clusterNode结构,检查这些结构的slots数组,直到遍历到负责处理槽i的节点为止,这个过程的时间复杂度为O(N),N是clusterState.nodes字典保存的clusterNode结构的数量。

• 引入clusterState.slots ,将所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽i是否已经被指派,或者查看负责处理槽i的节点,只需要访问clusterState.slots[i]的值即可,这个操作的时间复杂度为O(1)。

• 如果只使用clusterState.slots数组(不引入clusterNode.slots),如果要将节点A的槽指派信息传播给其他节点时,必须先遍历整个clusterState.slots数组,记录节点A负责处理哪些槽,然后再发送给其他节点。比直接发送clusterNode.slots数组要低效的多。

Redis Cluster节点通信

Redis Cluster采用P2P的Gossip协议,Gossip协议的原理就是每个节点与其他节点间不断通信交换信息,一段时间后节点信息一致,每个节点都知道集群的完整信息。

Redis Cluster通信过程:

(1)集群中的每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口上加10000;

(2)每个节点在固定周期内通过特定规则选择几个节点发送ping消息;

(3)接收到ping消息的节点用pong消息作为响应。

集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,

只要这些节点彼此可以正常通信,最终它们会达到一致的状态。当节点出故障、新节点加入、主从角色变化、槽信息变更等事件发生时,通过不断的ping/pong消息通信,经过一段时间后所有的节点都会知道整个集群全部节点的最新状态,从而达到集群状态同步的目的。

Gossip消息

Gossip协议的主要职责就是信息交换,信息交换的载体就是节点彼此发送的Gossip消息,常用的Gossip消息可分为:

• meet消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换;

• ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息发送封装了自身节点和部分其他节点的状态数据。

• pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。

• fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。

消息格式:

所有的消息格式划分为:消息头和消息体。

消息头包含发送节点自身状态数据,接收节点根据消息头就可以获取到发送节点的相关数据。

消息格式数据结构

消息头:包含自身的状态数据,发送节点关键信息,如节点id、槽映等节点标识(主从角色,是否下线)等。

// 用来表示集群消息的结构(消息头,header)
typedef struct {
    char sig[4];        /* Siganture "RCmb" (Redis Cluster message bus). */
    // 消息的长度(包括这个消息头的长度和消息正文的长度)
    uint32_t totlen;    /* Total length of this message */
    uint16_t ver;       /* Protocol version, currently set to 0. */
    uint16_t notused0;  /* 2 bytes not used. */

    // 消息的类型
    uint16_t type;      /* Message type */

    // 消息正文包含的节点信息数量
    // 只在发送 MEET 、 PING 和 PONG 这三种 Gossip 协议消息时使用
    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. */

    // 消息发送者的名字(ID)
    char sender[REDIS_CLUSTER_NAMELEN]; /* Name of the sender node */

    // 消息发送者目前的槽指派信息
    unsigned char myslots[REDIS_CLUSTER_SLOTS/8];

    // 如果消息发送者是一个从节点,那么这里记录的是消息发送者正在复制的主节点的名字
    // 如果消息发送者是一个主节点,那么这里记录的是 REDIS_NODE_NULL_NAME
    // (一个 40 字节长,值全为 0 的字节数组)
    char slaveof[REDIS_CLUSTER_NAMELEN];

    char notused1[32];  /* 32 bytes reserved for future usage. */

    // 消息发送者的端口号
    uint16_t port;      /* Sender TCP base 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内部采用clusterMsgData结构声明:

union clusterMsgData {

    /* PING, MEET and PONG */
    struct {
        /* Array of N clusterMsgDataGossip structures */
        // 每条消息都包含两个 clusterMsgDataGossip 结构
        clusterMsgDataGossip gossip[1];
    } ping;

    /* FAIL */
    struct {
        clusterMsgDataFail about;
    } fail;

    /* PUBLISH */
    struct {
        clusterMsgDataPublish msg;
    } publish;

    /* UPDATE */
    struct {
        clusterMsgDataUpdate nodecfg;
    } update;

};

通信消息处理流程

当接收到ping、meet消息时,接收节点会解析消息内容并根据自身的识别情况做出相应处理:

接收节点收到ping/meet消息时,执行解析消息头和

消息体流程:

• 解析消息头过程:消息头包含了发送节点的信息,如果发送节点是新节点且消息是meet类型,则加入到本地节点列表;如果是已知节点,则尝试更新发送节点的状态,如槽映射关系、主从角色等状态。

• 解析消息体过程:如果消息体的clusterMsgDataGossip数组包含的节点是新节点,则尝试发起与新节点的meet握手流程;如果是已知节点,则根据clusterMsgDataGossip中的flags字段判断该节点是否下线,用于故障转移。

消息处理完后回复pong消息,内容同样包含消息头和消息体,发送节点接收到回复的pong消息后,采用类似的流程解析处理消息并更新与接收节点最后信息时间,完成一次消息通信。

image.png
通信规则

Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。由于内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。

• 通信节点选择过多可以让信息及时交换,但是成本过高;

• 通信节点选择过少会降低集群内所有节点彼此信息交换频率,从而影响故障判定、新节点发现等需求的速度。

image.png

节点选择

消息交换的成本主要体现在单位时间选择发送消息的节点数量和每个消息携带的数据量。

(1)选择发送消息的节点数量

集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选择5个节点找出最久没有通信的节点发送ping消息,用于Gossip信息交换的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最后一次接受pong消息的时间大于cluster_node_timeout/2,则立刻发送ping消息,防止该节点信息太长时间未更新。根据以上规则得出每个节点每秒需要发送ping消息的数量=1+10*num(node.pong_received> cluster_node_timeout/2),因此cluster_node_timeout参数对消息发送的节点数量影响非常大。当我们的带宽资源紧张时,可以适当调大此参数。但是如果cluster_node_timeout过大会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。

(2)消息数据量

每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是myslots[CLUSTER_SLOTS/8],占用2KB,这块空间占用相对固定。消息体会携带一定数量的其他节点信息用于信息交换。而消息体携带数据量跟集群的节点数量相关,集群越大每次消息通信的成本也就更高。

通信开销

Redis Cluster内节点通信自身开销:

(1)节点自身信息,主要是自己负责的slots信息:slots[CLUSTER_SLOTS/8],占用2KB;

(2)携带总节点1/10的其他节点的状态信息(1个节点的状态数据约为104byte)

注:并不是所有的都是携带十分之一的节点信息的。

如果total_nodes/10小于3,那就至少携带3个节点信息;

如果total_nodes/10大于total_nodes-2,最多携带total_nodes-2个节点信息;

Else就total_nodes/10个节点信息。

image.png

通信开销

节点状态信息:clusterMsgDataGossip,ping、meet、pong采用clusterMsgDataGossip数组作为消息体。


image.png

所以每个Gossip消息大小为2KB+total_nodes/10*104b

Redis Cluster带宽消耗主要为:业务操作(读写)消耗+Gossip消息消耗。

我们现在假设节点数为64*2=128,floor(122)=12:

每个Gossip消息的大小约为:2KB+12*104b ≈ 3KB。

根据之前的每个节点每秒需要发送ping消息的数量=1+10*num(node.pong_received> cluster_node_timeout/2)

假设:cluster_node_timeout为15秒时,num=20,即开销=3KB(1+1020)220=25MB/s;

cluster_node_timeout为30秒时,num=5,即开销=3KB(1+105)220=6MB/s。

可以看出影响Gossip开销的主要两点:Cluster Redis的节点数和cluster_node_timeout设置的阈值:

那如果节点越多,Gossip消息就越大,最近接收pong消息时间间隔大于cluster_node_timeout/2秒的节点也会越多,那么带宽的开销越大。

所以得出如下结论:

(1)尽量避免大集群,针对大集群就拆分出去;

(2)如果某些场景必须使用大集群,那就可以通过增大cluster_node_timeout来降低带宽的消耗,但是会影响failover的时效,这个可以根据业务场景和集群具体状态评估;

(3)docker的分配问题,将大集群打散到小集群的物理机上,可以平衡和更高效的利用资源。

架构对比

Redis Cluster中节点负责存储数据,记录集群状态,集群节点能自动发现其他节点,检测出节点的状态,并在需要时剔除故障节点,提升新的主节点。

Redis Cluster中所有节点通过PING-PONG机制彼此互联,使用一个二级制协议(Cluster Bus) 进行通信,优化传输速度和带宽。发现新的节点、发送PING包、特定情况下发送集群消息,集群连接能够发布与订阅消息。

客户端和集群中的节点直连,不需要中间的Proxy层。理论上而言,客户端可以自由地向集群中的所有节点发送请求,但是每次不需要连接集群中的所有节点,只需要连接集群中任何一个可用节点即可。当客户端发起请求后,接收到重定向(MOVED\ASK)错误,会自动重定向到其他节点,所以客户端无需保存集群状态。不过客户端可以缓存键值和节点之间的映射关系,这样能明显提高命令执行的效率。

Redis Cluster中节点之间使用异步复制,在分区过程中存在窗口,容易导致丢失写入的数据,集群即使努力尝试所有写入,但是以下两种情况可能丢失数据:

命令操作已经到达主节点,但在主节点回复的时候,写入可能还没有通过主节点复制到从节点那里。如果这时主节点宕机了,这条命令将永久丢失。

Redis集群的节点不可用后,在经过集群半数以上Master节点与故障节点通信超过cluster-node-timeout时间后,认为该节点故障,从而集群根据自动故障机制,将从节点提升为主节点。这时集群恢复可用。

Redis Cluster的优势和不足

优势

  1. 无中心架构。

  2. 数据按照slot存储分布在多个节点,节点间数据共享,可动态调整数据分布。

  3. 可扩展性,可线性扩展到1000个节点,节点可动态添加或删除。

  4. 高可用性,部分节点不可用时,集群仍可用。通过增加Slave做standby数据副本,能够实现故障自动failover,节点之间通过gossip协议交换状态信息,用投票机制完成Slave到Master的角色提升。

  5. 降低运维成本,提高系统的扩展性和可用性。

不足

  1. Client实现复杂,驱动要求实现Smart Client,缓存slots mapping信息并及时更新,提高了开发难度,客户端的不成熟影响业务的稳定性。目前仅JedisCluster相对成熟,异常处理部分还不完善,比如常见的“max redirect exception”。

  2. 节点会因为某些原因发生阻塞(阻塞时间大于clutser-node-timeout),被判断下线,这种failover是没有必要的。

  3. 数据通过异步复制,不保证数据的强一致性。

  4. 多个业务使用同一套集群时,无法根据统计区分冷热数据,资源隔离性较差,容易出现相互影响的情况。

  5. Slave在集群中充当“冷备”,不能缓解读压力,当然可以通过SDK的合理设计来提高Slave资源的利用率。

Redis Cluster在业界有哪些探索

通过调研了解,目前业界使用Redis Cluster大致可以总结为4类:

直连型

直连型,又可以称之为经典型或者传统型,是官方的默认使用方式,架构图见图6。这种使用方式的优缺点在上面的介绍中已经有所说明,这里不再过多重复赘述。但值得一提的是,这种方式使用Redis Cluster需要依赖Smart Client,诸如连接维护、缓存路由表、MultiOp和Pipeline的支持都需要在Client上实现,而且很多语言的Client目前都还是没有的(关于Clients的更多介绍请参考https://redis.io/clients)。虽然Client能够进行定制化,但有一定的开发难度,客户端的不成熟将直接影响到线上业务的稳定性。

image.png

带Proxy型

在Redis Cluster还没有那么稳定的时候,很多公司都已经开始探索分布式Redis的实现了,比如有基于Twemproxy或者Codis的实现,下面举一个唯品会基于Twemproxy架构的例子(不少公司分布式Redis的集群架构都经历过这个阶段),如图7所示。

image.png

图7 Redis基于Twemproxy的架构实现

这种架构的优点和缺点也比较明显。

优点:

  1. 后端Sharding逻辑对业务透明,业务方的读写方式和操作单个Redis一致;
  2. 可以作为Cache和Storage的Proxy,Proxy的逻辑和Redis资源层的逻辑是隔离的;
  3. Proxy层可以用来兼容那些目前还不支持的Clients。

缺点:

  1. 结构复杂,运维成本高;

  2. 可扩展性差,进行扩缩容都需要手动干预;

  3. failover逻辑需要自己实现,其本身不能支持故障的自动转移;

  4. Proxy层多了一次转发,性能有所损耗。

正是因此,我们知道Redis Cluster和基于Twemproxy结构使用中各自的优缺点,于是就出现了下面的这种架构,糅合了二者的优点,尽量规避二者的缺点,架构如图8。


image.png

图8 Smart Proxy方案架构

目前业界Smart Proxy的方案了解到的有基于Nginx Proxy和自研的,自研的如饿了么开源部分功能的Corvus,优酷土豆是则通过Nginx来实现,滴滴也在展开基于这种方式的探索。选用Nginx Proxy主要是考虑到Nginx的高性能,包括异步非阻塞处理方式、高效的内存管理、和Redis一样都是基于epoll事件驱动模式等优点。优酷土豆的Redis服务化就是采用这种结构。

优点:

  1. 提供一套HTTP Restful接口,隔离底层资源,对客户端完全透明,跨语言调用变得简单;

  2. 升级维护较为容易,维护Redis Cluster,只需平滑升级Proxy;

  3. 层次化存储,底层存储做冷热异构存储;

  4. 权限控制,Proxy可以通过密钥管理白名单,把一些不合法的请求都过滤掉,并且也可以对用户请求的超大value进行控制和过滤;

  5. 安全性,可以屏蔽掉一些危险命令,比如keys *、save、flushall等,当然这些也可以在Redis上进行设置;

  6. 资源逻辑隔离,根据不同用户的key加上前缀,来实现动态路由和资源隔离;

  7. 监控埋点,对于不同的接口进行埋点监控。

缺点:

  1. Proxy层做了一次转发,性能有所损耗;

  2. 增加了运维成本和管理成本,需要对架构和Nginx Proxy的实现细节足够了解,因为Nginx Proxy在批量接口调用高并发下可能会瞬间向Redis Cluster发起几百甚至上千的协程去访问,导致Redis的连接数或系统负载的不稳定,进而影响集群整体的稳定性。

云服务型

这种类型典型的案例就是企业级的PaaS产品,如亚马逊和阿里云提供的Redis Cluster服务,用户无需知道内部的实现细节,只管使用即可,降低了运维和开发成本。当然也有开源的产品,国内如搜狐的CacheCloud,它提供一个Redis云管理平台,实现多种类型(Redis Standalone、Redis Sentinel、Redis Cluster)自动部署,解决Redis实例碎片化现象,提供完善统计、监控、运维功能,减少开发人员的运维成本和误操作,提高机器的利用率,提供灵活的伸缩性,提供方便的接入客户端,更多细节请参考:https://cachecloud.github.io。尽管这还不错,如果是一个新业务,到可以尝试一下,但若对于一个稳定的业务而言,要迁移到CacheCloud上则需要谨慎。如果对分布式框架感兴趣的可以看下Twitter开源的一个实现Memcached和Redis的分布式缓存框架Pelikan,目前国内并没有看到这样的应用案例,它的官网是http://twitter.github.io/pelikan/

image.png

图9 CacheCloud平台架构

自研型

这种类型在众多类型中更显得孤独,因为这种类型的方案更多是现象级,仅仅存在于为数不多的具有自研能力的公司中,或者说这种方案都是各公司根据自己的业务模型来进行定制化的。这类产品的一个共同特点是没有使用Redis Cluster的全部功能,只是借鉴了Redis Cluster的某些核心功能,比如说failover和slot的迁移。作为国内使用Redis较早的公司之一,新浪微博就基于内部定制化的Redis版本研发出了微博Redis服务化系统Tribe。它支持动态路由、读写分离(从节点能够处理读请求)、负载均衡、配置更新、数据聚集(相同前缀的数据落到同一个slot中)、动态扩缩容,以及数据落地存储。同类型的还有百度的BDRP系统。

image.png

图10 Tribe系统架构图

Redis Cluster运维开发最佳实践经验

根据公司的业务模型选择合适的架构,适合自己的才是最好的;

做好容错机制,当连接或者请求异常时进行连接retry或reconnect;

重试时间可设置大于cluster-node-time (默认15s),增强容错性,减少不必要的failover;

避免产生hot-key,导致节点成为系统的短板;

避免产生big-key,导致网卡打爆和慢查询;

设置合理的TTL,释放内存。避免大量key在同一时间段过期,虽然Redis已经做了很多优化,仍然会导致请求变慢;

避免使用阻塞操作(如save、flushall、flushdb、keys *等),不建议使用事务;

Redis Cluster不建议使用pipeline和multi-keys操作(如mset/mget. multi-key操作),减少max redirect的产生;

当数据量很大时,由于复制积压缓冲区大小的限制,主从节点做一次全量复制导致网络流量暴增,建议单实例容量不要分配过大或者借鉴微博的优化采用增量复制的方式来规避;

数据持久化建议在业务低峰期操作,关闭aofrewrite机制,aof的写入操作放到bio线程中完成,解决磁盘压力较大时Redis阻塞的问题。设置系统参数vm.overcommit_memory=1,也可以避免bgsave/aofrewrite的失败;

client buffer参数调整

client-output-buffer-limit normal 256mb 128mb 60

client-output-buffer-limit slave 512mb 256mb 180

对于版本升级的问题,修改源码,将Redis的核心处理逻辑封装到动态库,内存中的数据保存在全局变量里,通过外部程序来调用动态库里的相应函数来读写数据。版本升级时只需要替换成新的动态库文件即可,无须重新载入数据,可毫秒级完成;

对于实现异地多活或实现数据中心级灾备的要求(即实现集群间数据的实时同步),可以参考搜狐的实现:Redis Cluster => Redis-Port => Smart proxy => Redis Cluster;

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。