Redis集群

数据分布

分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节点负责整体数据的一个子集。

常见的分区规则有顺序分区和哈希分区:

  • 哈希分区:离散度好,数据分布业务无关,无法顺序访问
  • 顺序分区:离散度易倾斜,数据分布业务相关,可以顺序访问

常见哈希分区规则

节点取余分区

使用特定的数据,如Redis的键或用户ID,再根据节点数量N使用公式: hash(key)%N计算出哈希值,用来决定数据映射到哪一个节点上。这种方 案存在一个问题:当节点数量变化时,如扩容或收缩节点,数据节点映射关系需要重新计算,会导致数据的重新迁移。
这种方式的突出优点是简单性,常用于数据库的分库分表规则,一般采用预分区的方式,提前根据数据量规划好分区数,比如划分为512或1024张表,保证可支撑未来一段时间的数据量,再根据负载情况将表迁移到其他数据库中。扩容时通常采用翻倍扩容,避免数据映射全部被打乱导致全量迁移的情况。

一致性哈希分区

一致性哈希分区(Distributed Hash Table)实现思路是为系统中每个节点分配一个token,范围一般在0~2^32,这些token构成一个哈希环。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点
优点:加入和删除节点只影响哈希环中相邻的节点,对其他节点无影响。
缺点:

  • 加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。
  • 当使用少量节点时,节点变化将大范围影响哈希环中数据映射,因此这种方式不适合少量数据节点的分布式方案。
  • 普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。

虚拟槽分区

虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。这个范围一般远远大于节点数。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展,每个节点会负责一定数量的槽
如图:当前集群有5个节点,每个节点平均大约负责3276个槽。由于采用高质 量的哈希算法,每个槽所映射的数据通常比较均匀,将数据平均划分到5个节点进行数据分区。

Redis数据分区

Redis Cluster采用虚拟槽分区,所有的键根据哈希函数映射到0~16383整数槽内,计算公式:slot=CRC16(key)&16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。

Redis虚拟槽分区的特点:

  • 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
  • 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
  • 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。

集群功能限制

  1. key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mget、mget等操作可能存在于多个节点上因此不被支持。
  2. key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
  3. key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash、list等映射到不同的节点。
  4. 不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即db0。
  5. 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

搭建集群

手动搭建

准备节点

Redis集群一般由多个节点组成,至少需要6个才能保证高可用的集群。集群相关配置如下:

#端口
port 6379
#开启集群模式
cluster-enabled yes
#节点超时时间
cluster-node-timeout 15000
#集群内部配置文件
cluster-config-file "nodes-6379.conf"

准备好多个节点配置文件后,启动所有节点

redis-server conf/redis-6379.conf
redis-server conf/redis-6380.conf
redis-server conf/redis-6381.conf 
redis-server conf/redis-6382.conf
redis-server conf/redis-6383.conf
redis-server conf/redis-6384.conf

第一次启动时,集群没有哪部配置文件,会自动创建一份。Redis自动维护集群配置文件,当集群内节点信息发生变化,如添加节点、节点下线、故障转移等。节点会自动保存集群状态到配置文件中。

节点启动后,可以通过cluster nodes获取集群节点状态,因为此时每个节点还没有关联,只能看到自己的信息

节点握手

节点握手是指让一批运行在集群模式下的节点通过Gossp协议彼此通信,关联起来,由客户端发起命令:表示该客户端与另外的一个节点关联

cluster meet {ip} {port}

大致流程(6379关联6380)

  1. 节点6379本地创建6380节点信息对象,并发送meet消息。
  2. 节点6380接受到meet消息后,保存6379节点信息并回复pong消息。
  3. 之后节点6379和6380彼此定期通过ping/pong消息进行正常的节点通信。

只需要在一个节点对其他节点进行节点握手,握手状态就会通过消息在集群内部传播,其他节点会自动发现新节点并发起握手流程

节点建立握手后集群还不能工作,此时集群还处于下线状态,所有数据的读写都被禁止,还需要进行分配槽的操作

127.0.0.1:6379> set hello redis
(error) CLUSTERDOWN Hash slot not served

分配槽

Redis集群把所有的数据映射到16384个槽中。每个key会映射为一个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。通过cluster addslots命令为节点分配槽。

redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0...5461}
redis-cli -h 127.0.0.1 -p 6380 cluster addslots {5462...10922}
redis-cli -h 127.0.0.1 -p 6381 cluster addslots {10923...16383}

此时集群进入在线状态,可以通过执行cluster nodes命令查看节点和槽的分配关系:

5004e273167ac7ec3cd252e084b396e409f88b51 127.0.0.1:6383@16383 master - 0 1563070800554 3 connected
ed024ca813815308a7704400de84bb44d8867718 127.0.0.1:6382@16382 master - 0 1563070800000 0 connected
a2e6c40de0a37dba1cc299a01f3552c116d9657f 127.0.0.1:6380@16380 master - 0 1563070801562 5 connected 5462-10922
7984fb7d565c89133d94a2454d124949dcf4d9d4 127.0.0.1:6384@16384 master - 0 1563070798000 4 connected
418f6a083e488a0678dc8d8b7f6b9fee4f5355c6 127.0.0.1:6379@16379 myself,master - 0 1563070801000 1 connected 0-5461
52fbde319a11302d9c225af476e745bfde9e2670 127.0.0.1:6381@16381 master - 0 1563070800000 2 connected 10923-16383

作为一个完整的集群,每个负责处理槽的节点应该具有从节点,保证当它出现故障时可以自动进行故障转移。集群模式下,Reids节点角色分为主节点和从节点。首次启动的节点和被分配槽的 节点都是主节点,从节点负责复制主节点槽信息和相关的数据。使用cluster replicate {nodeId}命令让一个节点成为从节点。其中命令执行必须在对应的从节点上执行,nodeId是要复制主节点的节点ID:

127.0.0.1:6382> cluster replicate 418f6a083e488a0678dc8d8b7f6b9fee4f5355c6
redis-cli -p 6383 cluster replicate a2e6c40de0a37dba1cc299a01f3552c116d9657f
redis-cli -p 6384 cluster replicate 52fbde319a11302d9c225af476e745bfde9e2670

使用redis-trib.rb搭建集群(Redis5之后不推荐)

redis-trib.rb是采用Ruby实现的Redis集群管理工具。内部通过Cluster相 关命令帮我们简化集群创建、检查、槽迁移和均衡等常见运维操作,使用之前需要安装Ruby依赖环境

Ruby环境准备

  1. Mac系统自带Ruby环境,如果没有先安装Ruby

  2. 使用gem安装redis依赖

    gem install redis

  3. 将redis安装目录中src目录下的redis-trib.rb文件放到/usr/local/bin目录下

  4. 客户端执行redis-trib.rb确保环境搭建正确

准备节点

跟之前一样准备好节点配置并启动

创建集群

使用redis-trib.rb create命令完成节点握手和槽分配过程

redis-trib.rb create —replicas 1 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384

使用redis-cli —cluster

在Rdis5之后使用上面的方法会给出警告,Redis5之后给出了新的方式,推荐使用redis-cli --cluster 代替redis-trib.rb

使用命令如下:

redis-cli --cluster create 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 --cluster-replicas 1 

—replicas参数指定集群中每个主节点配备几个从节点

执行后输出如下:

>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 127.0.0.1:6383 to 127.0.0.1:6379
Adding replica 127.0.0.1:6384 to 127.0.0.1:6380
Adding replica 127.0.0.1:6382 to 127.0.0.1:6381
>>> Trying to optimize slaves allocation for anti-affinity
[WARNING] Some slaves are in the same host as their master
M: 4aed7d5835ce70cd788e8dca25ecc17a3b483d62 127.0.0.1:6379
   slots:[0-5460] (5461 slots) master
M: a8942f231fc69c02640bb2e61cb0e6f659b4d218 127.0.0.1:6380
   slots:[5461-10922] (5462 slots) master
M: cf2e667527b1a8e1eda1cd81403a97cfb9da6a93 127.0.0.1:6381
   slots:[10923-16383] (5461 slots) master
S: d237450657c855a57236c52c6e6ec6e098384dec 127.0.0.1:6382
   replicates a8942f231fc69c02640bb2e61cb0e6f659b4d218
S: e323baf44b75e2aeb88b691f6593753a3b9224ca 127.0.0.1:6383
   replicates cf2e667527b1a8e1eda1cd81403a97cfb9da6a93
S: d60caa155a815118ef4de33e9f71667d89cb1fc0 127.0.0.1:6384
   replicates 4aed7d5835ce70cd788e8dca25ecc17a3b483d62
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join
.....
>>> Performing Cluster Check (using node 127.0.0.1:6379)
M: 4aed7d5835ce70cd788e8dca25ecc17a3b483d62 127.0.0.1:6379
   slots:[0-5460] (5461 slots) master
   1 additional replica(s)
S: d60caa155a815118ef4de33e9f71667d89cb1fc0 127.0.0.1:6384
   slots: (0 slots) slave
   replicates 4aed7d5835ce70cd788e8dca25ecc17a3b483d62
M: cf2e667527b1a8e1eda1cd81403a97cfb9da6a93 127.0.0.1:6381
   slots:[10923-16383] (5461 slots) master
   1 additional replica(s)
S: e323baf44b75e2aeb88b691f6593753a3b9224ca 127.0.0.1:6383
   slots: (0 slots) slave
   replicates cf2e667527b1a8e1eda1cd81403a97cfb9da6a93
S: d237450657c855a57236c52c6e6ec6e098384dec 127.0.0.1:6382
   slots: (0 slots) slave
   replicates a8942f231fc69c02640bb2e61cb0e6f659b4d218
M: a8942f231fc69c02640bb2e61cb0e6f659b4d218 127.0.0.1:6380
   slots:[5461-10922] (5462 slots) master
   1 additional replica(s)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.

集群完整性检查:
集群完整性指所有的槽都分配到存活的主节点上,只要16384个槽中有一个没有分配给节点则表示集群不完整。可以使用命令redis-cli --cluster check {ip:port}检测之前创建的两个集群是否成功,check命令只需要给出集群中任意一个节点地址就可以完成整个集群的检查工作

节点通信

通信流程

在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息。常见的元数据维护方式分为:集中式和P2P方式。Redis集群采用P2P的Gossip(流言)协议,Gossip协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息。
通信过程:

  1. 集群中的每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口上加10000。
  2. 每个节点在固定周期内通过特定规则选择几个节点发送ping消息。
  3. 接收到ping消息的节点用pong消息作为响应。

Gossip消息

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

  • meet消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
  • ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息发送封装了自身节点和部分其他节点的状态数据。
  • pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
  • fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个 fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。

所有的消息格式划分为:消息头和消息体。
消息头包含发送节点自身状态数据,结构如下:

typedef struct { 
    char sig[4]; /* 信号标示 */ 
    uint32_t totlen; /* 消息总长度 */ 
    uint16_t ver; /* 协议版本*/ 
    uint16_t type; /* 消息类型,用于区分meet,ping,pong等消息 */ 
    uint16_t count; /* 消息体包含的节点数量,仅用于meet,ping,ping消息类型*/ 
    uint64_t currentEpoch; /* 当前发送节点的配置纪元 */ 
    uint64_t configEpoch; /* 主节点/从节点的主节点配置纪元 */ 
    uint64_t offset; /* 复制偏移量 */ 
    char sender[CLUSTER_NAMELEN]; /* 发送节点的nodeId */ 
    unsigned char myslots[CLUSTER_SLOTS/8]; /* 发送节点负责的槽信息 */ 
    char slaveof[CLUSTER_NAMELEN]; /* 如果发送节点是从节点,记录对应主节点的nodeId */ 
    uint16_t port; /* 端口号 */ 
    uint16_t flags; /* 发送节点标识,区分主从角色,是否下线等 */ 
    unsigned char state; /* 发送节点所处的集群状态 */ 
    unsigned char mflags[3]; /* 消息标识 */ 
    union clusterMsgData data /* 消息正文 */; 
} clusterMsg;

接收节点根据消息头就可以获取到发送节点的相关数据。
集群内所有的消息都采用相同的消息头结构clusterMsg,它包含了节点发送关键信息,如节点id、槽映射、节点标识(主从角色,是否下线)等。
消息体在Redis内部采用clusterMsgData结构声明:

union clusterMsgData { 
    /* ping,meet,pong消息体*/ 
    struct { 
        /* gossip消息结构数组 */ c
        lusterMsgDataGossip gossip[1];
    } ping;
     /* FAIL 消息体 */ 
    struct { 
        clusterMsgDataFail about; 
    } fail; 
    // ... 
};

消息体clusterMsgData定义发送消息的数据,其中ping、meet、pong都采用cluster MsgDataGossip数组作为消息体数据,实际消息类型使用消息头的type属性区分。每个消息体包含该节点的多个clusterMsgDataGossip结构数据,用于信息交换。
接受节点收到ping/meet消息时,执行解析消息头和消息体流程:

  • 解析消息头过程::消息头包含了发送节点的信息,如果发送节点是新节点且消息是meet类型,则加入到本地节点列表;如果是已知节点,则尝试更新发送节点的状态,如槽映射关系、主从角色等状态。
  • 解析消息体过程:如果消息体的clusterMsgDataGossip数组包含的节点是新节点,则尝试发起与新节点的meet握手流程;如果是已知节点,则根据cluster MsgDataGossip中的flags字段判断该节点是否下线,用于故障转移。

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

节点选择

Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。

由上图可以看出:消息交换的成本主要体现在单位时间选择发送消息的节点数量和每个消息携带的数据量

选择发送消息的节点数量

集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用于保证Gossip信息交换的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最近一次接受pong消息的时间大于cluster_node_timeout/2,则立刻发送ping消息,防止该节点信息太长时间未更新。根据以上规则得出每个节点每秒需要发送ping消息的数量=1+10*num(node.pong_received>cluster_node_timeout/2)

消息数据量

每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是myslots[CLUSTER_SLOTS/8],占用2KB,这块空间占用相对固定。消息体会携带一定数量的其他节点信息用于信息交换。

def get_wanted():
    int total_size = size(cluster.nodes) 
    # 默认包含节点总量的1/10 594 
    int wanted = floor(total_size/10); 
    if wanted < 3: 
        # 至少携带3个其他节点信息 
        wanted = 3; 
    if wanted > total_size -2 : 
        # 最多包含total_size - 2个 
        wanted = total_size - 2; 
    return wanted;

集群伸缩

集群伸缩指的是为集群添加节点进行扩容和下线部分节点进行缩容

集群伸缩 = 槽和数据在节点之间的移动

扩容集群

准备新节点

配置新的节点文件后启动节点

加入集群

可以使用两种方式将新节点加入集群:
一种是使用cluster meet命令,此方法在线上不建议使用,会 601 造成被加入节点的集群合并到现有集群的情况,从而造成数据丢失和错乱, 后果非常严重。
另一种是redis-cli --cluster add-node new_host:new_port existing_host:existing_port --slave --master-id {arg}命令

redis-cli --cluster add-node 127.0.0.1:6385 127.0.0.1:6379

>>> Adding node 127.0.0.1:6385 to cluster 127.0.0.1:6379
>>> Performing Cluster Check (using node 127.0.0.1:6379)
M: 4aed7d5835ce70cd788e8dca25ecc17a3b483d62 127.0.0.1:6379
   slots:[0-5460] (5461 slots) master
   1 additional replica(s)
S: d60caa155a815118ef4de33e9f71667d89cb1fc0 127.0.0.1:6384
   slots: (0 slots) slave
   replicates 4aed7d5835ce70cd788e8dca25ecc17a3b483d62
M: cf2e667527b1a8e1eda1cd81403a97cfb9da6a93 127.0.0.1:6381
   slots:[10923-16383] (5461 slots) master
   1 additional replica(s)
S: e323baf44b75e2aeb88b691f6593753a3b9224ca 127.0.0.1:6383
   slots: (0 slots) slave
   replicates cf2e667527b1a8e1eda1cd81403a97cfb9da6a93
S: d237450657c855a57236c52c6e6ec6e098384dec 127.0.0.1:6382
   slots: (0 slots) slave
   replicates a8942f231fc69c02640bb2e61cb0e6f659b4d218
M: a8942f231fc69c02640bb2e61cb0e6f659b4d218 127.0.0.1:6380
   slots:[5461-10922] (5462 slots) master
   1 additional replica(s)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
>>> Send CLUSTER MEET to node 127.0.0.1:6385 to make it join the cluster.
[OK] New node added correctly.

迁移槽和数据

迁移步骤:

  1. 槽迁移计划:需要事先为新节点制定槽分配计划,保证每个节点负责相似数量的槽,从而保证各节点的数据均匀。
  2. 迁移数据:
  • 在目标节点发送cluster setslot {slot} importing {sourceNodeId}命令,让目标节点准备导入槽的数据
  • 源节点发送cluster setslot {slot} migrating {targetNodeId}命令,让源节点准备迁出槽的数据
  • 源节点循环执行cluster getkeysinslot {slot} {count}命令,获取count个属于slot的键
  • 在源节点上执行migrate {targetIp} {targetPort} "" 0 {timeout} keys {keys ...},把上面获取到的键通过流水线机制批量迁移到目标节点。
  • 重复执行上面两个步骤知道所有键迁移完毕
  • 在集群内的所有主节点发送cluster setslot {slot} node {targetNodeId}命令,通知所有主节点槽分配给目标节点。

使用redis-cli —cluster

实际使用时,每次要操作大量的槽和对应的非常多的键,应该使用redis-cli —cluster提供的槽重分片功能

redis-cli --cluster reshard host:port --from <arg> --to <arg> --slots <arg> --yes --timeout<arg> --pipeline <arg>

参数说明:

  • host:port:必传参数,集群内任意节点地址,用来获取整个集群信息。
  • from:制定源节点的id,如果有多个源节点,使用逗号分隔,如果是all源节点变为集群内所有主节点,在迁移过程中提示用户输入。
  • to:需要迁移的目标节点的id,目标节点只能填写一个,在迁移过程中提示用户输入。
  • slots:需要迁移槽的总数量,在迁移过程中提示用户输入。
  • yes:当打印出reshard执行计划时,是否需要用户输入yes确认后再执行reshard。
  • timeout:控制每次migrate操作的超时时间,默认为60000毫秒。
  • pipeline:控制每次批量迁移键的数量,默认为10。

    执行redis-cli —cluster reshard 127.0.0.1:6379后,首先会要求我们输入要迁移的槽数量,然后会提示我们输入目标节点的ID,之后需要输入多个源节点ID(用done结束),最后输入yes同意迁移计划即可执行迁移工作。
    迁移之后的槽可能没有按顺序排列,由于槽用于hash运算本身顺序没有意义,因此无须强制要求节点负责槽的顺序性。

迁移成功后可以通过redis-cli --cluster rebalance {newIp:newPort}节点之间槽的均衡性

redis-cli --cluster rebalance 127.0.0.1:6380
>>> Performing Cluster Check (using node 127.0.0.1:6380)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
*** No rebalancing needed! All nodes are within the 2.00% threshold.

可以看出迁移之后所有主节点负责的槽数量差异在2%以内,因此集群 节点数据相对均匀,无需调整。

添加从节点

需要为新的主节点添加从节点从而保证整个集群的高可用。使用cluster replicate{masterNodeId}命令为主节点添加对应从节点

集群模式下slaveof添加从节点操作不在支持

收缩集群

收缩集群由两个步骤:

  1. 首先需要确定下线节点是否有负责的槽,如果有,需要把槽迁移到其他节点,保证节点下线后整个集群槽节点映射的完整性
  2. 当下线节点不再负责槽或者本身是从节点时,就可以通知集群内其他节点忘记下线节点,当所有节点忘记该节点后可以正常关闭

下线迁移槽

需要把要下线的节点所负责的槽平均分给其他节点:
首先执行cluster nodes查看节点信息

127.0.0.1:6385> cluster nodes

4aed7d5835ce70cd788e8dca25ecc17a3b483d62 127.0.0.1:6379@16379 master - 0 1563112011761 1 connected 1365-5460
a8942f231fc69c02640bb2e61cb0e6f659b4d218 127.0.0.1:6380@16380 master - 0 1563112008739 2 connected 6827-10922
cf2e667527b1a8e1eda1cd81403a97cfb9da6a93 127.0.0.1:6381@16381 master - 0 1563112009746 3 connected 12288-16383
d60caa155a815118ef4de33e9f71667d89cb1fc0 127.0.0.1:6384@16384 slave 4aed7d5835ce70cd788e8dca25ecc17a3b483d62 0 1563112010753 1 connected
f128f9e49e5340f9a642a07fda9934867fb41513 127.0.0.1:6385@16385 myself,master - 0 1563112010000 7 connected 0-1364 5461-6826 10923-12287
e323baf44b75e2aeb88b691f6593753a3b9224ca 127.0.0.1:6383@16383 slave cf2e667527b1a8e1eda1cd81403a97cfb9da6a93 0 1563112011000 3 connected
d237450657c855a57236c52c6e6ec6e098384dec 127.0.0.1:6382@16382 slave a8942f231fc69c02640bb2e61cb0e6f659b4d218 0 1563112010000 2 connected

然后执行槽迁移,使用redis-cli --cluster reshard {ip:port}(要下线的节点)让其他每个主节点平均接管槽,同样需要输入槽数量,要接受的节点Id,源节点ID。

忘记节点

忘记节点就是让其他节点不再与要下线节点进行Gossip消息交换。Redis提供cluster forget {downNodeId}命令实现该功能
当节点接收到cluster forget{down NodeId}命令后,会把nodeId指定的节点加入到禁用列表中,在禁用列表内的节点不再发送Gossip消息。禁用列表有效期是60秒,超过60秒节点会再次参与消息交换。也就是说当第一次forget命令发出后,有60秒的时间让集群内的所有节点忘记下线节点。
线上一般不建议使用该命令,因为需要跟大量节点交互,容易遗漏,超时
可以使用redis-cli --cluster del-node {host:port} {downNodeId}命令
当下线主节点具有从节点时需要把该从节点指向到其他主节点,因此对于主从节点都下线的情况,建议先下线从节点再下线主节点,防止不必要的全量复制。

redis-cli --cluster del-node 127.0.0.1:6379 f128f9e49e5340f9a642a07fda9934867fb41513

>>> Removing node f128f9e49e5340f9a642a07fda9934867fb41513 from cluster 127.0.0.1:6379
>>> Sending CLUSTER FORGET messages to the cluster...
>>> SHUTDOWN the node.

请求路由

在集群模式下,Redis接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为MOVED重定向。可以通过cluster keyslot {key}返回key对应的槽:

127.0.0.1:6379> set hello1 world
(error) MOVED 11613 127.0.0.1:6381
127.0.0.1:6379> cluster keyslot hello1
(integer) 11613

在使用redis-cli时,可以加入-c参数支持自动重定向

redis-cli -c

127.0.0.1:6379> set hello1 world

-> Redirected to slot [11613] located at 127.0.0.1:6381
OK

127.0.0.1:6381>

redis-cli自动帮我们连接到正确的节点执行命令,这个过程是在redis-cli内部维护,实质上是client端接到MOVED信息之后再次发起请求,并不在Redis节点中完成请求转发,节点对于不属于它的键命令只回复重定向响应,并不负责转发。
键命令执行步骤分为两部:计算槽和查找槽对应的节点

  1. 计算槽
    根据键的有效部分使用CRC16函数计算出散列值,再取对16383的余数,使每个键都可以映射到0~16383槽范围内。

    如果键内容包含{和}大括号字符,则计算槽的有效部分是括号内的内容;否则采用键的全内容计算槽。

键内部使用大括号包含的内容又叫做hash_tag,它提供不同的键可以具备相同slot的功能,常用于Redis IO优化。如在集群模式下使用mget等命令优化批量调用时,键列表必须具有相同的slot,否则会报错。这时可以利用hash_tag让不同的键具有相同的slot达到优化的目的。
2. 查找槽对应的节点
集群内通过消息交换每个节点都会知道所有节点的槽信息,内部保存在clusterState结构中,结构所示:

typedef struct clusterState {
    clusterNode *myself; /* 自身节点,clusterNode代表节点结构体 */
    clusterNode *slots[CLUSTER_SLOTS]; /* 16384个槽和节点映射数组,数组下标代表对应的槽 */ 
    ... 
} clusterState;

根据MOVED重定向机制,客户端可以随机连接集群内任一Redis获取键所在节点,这种客户端又叫Dummy(傀儡)客户端,它优点是代码实现简单,对客户端协议影响较小,只需要根据重定向信息再次发送请求即可。但是它的弊端很明显,每次执行键命令前都要到Redis上进行重定向才能找到要执行命令的节点,额外增加了IO开销。

Smart客户端

大多数开发语言的Redis客户端都采用Smart客户端支持集群协议,Smart客户端通过在内部维护slot→node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot→node映射。

ASK重定向

当集群正在执行槽迁移时,此时一部分数据在源节点,一部分数据在目标节点。若在此时发送键命令,如果该键已经迁移到目标节点,则会回复ASK重定向异常。

具体流程:

  1. 客户端根据本地slots缓存发送命令到源节点,如果存在键对象则直接执行并返回结果给客户端。
  2. 如果键对象不存在,则可能存在于目标节点,这时源节点会回复ASK重定向异常。格式如下:(error)ASK {slot} {targetIP}:{targetPort}
  3. 客户端从ASK重定向异常提取出目标节点信息,发送asking命令到目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不存在则返回不存在信息。

ASK与MOVED虽然都是对客户端的重定向控制,但是有着本质区别。ASK重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存。

节点内部支持

源节点和目标节点在内部的clusterState结构中维护着当前正在迁移的槽信息,用于识别槽迁移情况:

typedef struct clusterState {
    clusterNode *myself; /* 自身节点 / 
    clusterNode *slots[CLUSTER_SLOTS]; /* 槽和节点映射数组 */ 
    clusterNode *migrating_slots_to[CLUSTER_SLOTS];/* 正在迁出的槽节点数组 */ 
    clusterNode *importing_slots_from[CLUSTER_SLOTS];/* 正在迁入的槽节点数组*/ 
    ... 
} clusterState;

节点每次收到键命令时,都会根据clusterState内的迁移属性进行命令处理:

  • 如果键所在的槽由当前节点负责,但键不存在则查找migrating_slots_to数组查看槽是否正在迁出,如果是返回ASK重定向。
  • 如果客户端发送asking命令打开了CLIENT_ASKING标识,则该客户端下次发送键命令时查找importing_slots_from数组获取clusterNode,如果指向自身则执行命令。
  • asking命令是一次性命令,每次执行完后客户端标识都会修改回原状态,因此每次客户端接收到ASK重定向后都需要发送asking命令。
  • 批量操作。ASK重定向对单键命令支持得很完善,但是,使用批量操作,如mget或pipeline。当槽处于迁移状态时,批量操作会受到影响。

集群环境下对于使用批量操作的场景,优先使用Pipeline方式,在客户端实现对ASK重定向的正确处理,这样既可以受益于批量操作的IO优化,又可以兼容slot迁移场景。

故障迁移

当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务

故障发现

Redis集群内节点通过ping/pong消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线(pfail)和客观下线(fail)

  • 主观下线:指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。
  • 客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。

主观下线

集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。
具体流程:

  1. 节点a发送ping消息给节点b,如果通信正常将接收到pong消息,节点a更新最近一次与节点b的通信时间。
  2. 如果节点a与节点b通信出现问题则断开连接,下次会进行重连。如果一直通信失败,则节点a记录的与节点b最后通信时间将无法更新。
  3. 节点a内的定时任务检测到与节点b最后通信时间超过cluster-node-timeout时,更新本地对节点b的状态为主观下线(pfail)。

客观下线

当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。ping/pong消息的消息体会携带集群1/10的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态时,会在本地找到故障节点的ClusterNode结构,保存到下线报告链表中。结构如下:

struct clusterNode { /* 认为是主观下线的clusterNode结构 */ 
    list *fail_reports; /* 记录了所有其他节点对该节点的下线报告 */ 
    ... 
};

通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时,触发客观下线流程。

集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制。所以必须是负责槽的主节点参与故障发现决策

客观下线流程:

  1. 当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态处理,从节点则忽略。
  2. 找到pfail对应的节点结构,更新clusterNode内部下线报告链表。
  3. 根据更新后的下线报告链表告尝试进行客观下线。

下线报告链表

每个ClusterNode结构中都会存在一个下线链表结构,保存了其他主节点针对当前节点的下线报告:

typedef struct clusterNodeFailReport { 
    struct clusterNode *node; /* 报告该节点为主观下线的节点 */ 
    mstime_t time; /* 最近收到下线报告的时间 */ 
} clusterNodeFailReport;

下线报告中保存了报告故障的节点结构和最近收到下线报告的时间,当接收到fail状态时,会维护对应节点的下线上报链表。每个下线报告都存在有效期,每次在尝试触发客观下线时,都会检测下线报告是否过期,对于过期的下线报告将被删除。如果在cluster-node-time*2的时间内该下线报告没有得到更新则过期并删除.

尝试客观下线

集群中的节点每次接收到其他节点的pfail状态,都会尝试触发客观下线:

  1. 首先统计有效的下线报告数量,如果小于集群内持有槽的主节点总数的一半则退出。
  2. 当下线报告大于槽主节点数量一半时,标记对应故障节点为客观下线状态。
  3. 向集群广播一条fail消息,通知所有的节点将故障节点标记为客观下线,fail消息的消息体只包含故障节点的ID。
    广播fail消息有一下两个作用:
  • 通知集群内所有的节点标记故障节点为客观下线状态并立即生效
  • 通知故障节点的从节点触发故障转移流程

故障恢复

故障节点变为客观下线后,如果下线节点是持有槽的主节点,则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程:

  1. 资格检查
  2. 准备选举时间
  3. 发起选举
  4. 选举投票
  5. 替换主节点

资格检查

每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。参数cluster-slavevalidity-factor用于从节点的有效因子,默认为10。

准备选举时间

当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。故障选举时间相关字段如下:

struct clusterState { 
    ... 
    mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间 */ 
    int failover_auth_rank; /* 记录当前从节点排名 */ 
}

采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。

发起选举

当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后,发起选举流程如下:

  1. 更新配置纪元:
    配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元(clusterNode.configEpoch)标示当前主节点的版本,所有主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元(clusterState.current Epoch),用于记录集群内所有主节点配置纪元 的最大版本。执行cluster info命令可以查看配置纪元信息。配置纪元会跟随ping/pong消息在集群内传播,当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突,nodeId更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突。

配置纪元的主要作用:

  • 标示集群内每个主节点的不同版本和当前集群最大的版本。
  • 每次集群发生重要事件时,这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点,用于记录这一关键事件。
  • 主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。

从节点每次发起投票时都会自增集群的全局配置纪元,并单独保存在clusterState.failover_auth_epoch变量中用于标识本次从节点发起选举的版本。
2. 广播选举消息
在集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。

选举投票

只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST),因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票,当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。
当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作。

  • 投票作废:每个配置纪元代表了一次选举周期,如果在开始投票之后的cluster-node-timeout*2时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

替换主节点

  1. 当前从节点取消复制变为主节点。
  2. 执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。
  3. 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。

集群运维

集群完整性

默认情况下只有当集群中16384个槽都指派了节点时,真个集群才能够使用,否则不可用。此时,从故障发现到自动 完成转移期间整个集群是不可用状态,可以通过修改配置参数cluster-require-full-coverage配置为no,当主节点故障时,只影响它负责槽的相关命令,不影响其他主节点的可用性。

带宽消耗

集群的带宽消耗主要分为:读写命令消耗+Gossip消息消耗,其中节点间消息通信对带宽的消耗体现在以下几个方面:

  • 消息发送频率:跟cluster-node-timeout密切相关,当节点发现与其他节点最后通信时间超过cluster-node-timeout/2时会直接发送ping消息。
  • 消息数据量:每个消息主要的数据占用包含:slots槽数组(2KB空 间)和整个集群1/10的状态数据(10个节点状态数据约1KB)。
  • 节点部署的机器规模:机器带宽的上线是固定的,因此相同规模的集群分布的机器越多每台机器划分的节点越均匀,则集群内整体的可用带宽越高。

集群倾斜

指不同节点之间数据量和请求量出现明显差异,包括数据倾斜和请求倾斜

数据倾斜

  1. 节点和槽分配严重不均
    可以使用redis-cli --cluster info {host:ip}查看所有节点负责的槽和键总量以及每个槽平均键数量,当节点对应的槽数量不均匀时,可以使用redis-cli --cluster rebalance {host:ip}进行平衡
  2. 不同槽对应键数量差异过大
    键通过CRC16哈希函数映射到槽上, 正常情况下槽内键数量会相对均匀。但当大量使用hash_tag时,会产生不同的键映射到同一个槽的情况。通过命令:cluster countkeysinslot {slot}可以获取槽对应的键数量,识别出哪些槽映射了过多的键。再通过命令cluster getkeysinslot {slot} {count}循环迭代出槽下所有的键。从而发现过度使用 hash_tag的键
  3. 集合对象包含大量元素。
  4. 内存相关配置不一致。

请求倾斜

集群内特定节点请求量/流量过大将导致节点之间负载不均。

手动故障转移

Redis集群提供手动故障转移功能:指定从节点发起转移流程,主从节点角色进行切换,从节点变为新的主节点对外提供服务,旧的主节点变为它的从节点。
通过在从节点上执行cluster failover命令发起转移流程,默认情况下转移期间客户端请求会有短暂的阻塞,但不会丢失数据。
具体流程:

  1. 从节点通知主节点停止处理所有客户端请求。
  2. 主节点发送对应从节点延迟复制的数据
  3. 节点接收处理复制延迟的数据,直到主从复制偏移量一致为止, 保证复制数据不丢失。
  4. 从节点立刻发起投票选举(这里不需要延迟触发选举)。选举成功后断开复制变为新的主节点,之后向集群广播主节点pong消息
  5. 旧主节点接受到消息后更新自身配置变为从节点,解除所有客户端请求阻塞,这些请求会被重定向到新主节点上执行。
  6. 旧主节点变为从节点后,向新的主节点发起全量复制流程。

数据迁移

把单机Redis数据迁移到集群环境:

redis-trib.rb import host:port --from <arg> --copy --replace