目录
一、数据分布
1.数据分布理论
2.Redis数据分区
3.集群功能限制
二、搭建集群
1.准备节点
2.节点握手
3.分配槽
4.用redis-trib.rb搭建集群
三、节点通信
1.通信流程
2.Gossip消息
3.节点选择
四、集群伸缩
1.伸缩原理
2.扩容集群
3.收缩集群
五、请求路由
1.请求重定向
2.Smart客户端
3.ASK重定向
六、故障转移
1.故障发现
2.故障恢复
3.故障转移时间
4.故障转移演练
本章重点回顾
文章将从数据分布、搭建集群、节点通信、集群伸缩、请求路由、 故障转移、集群运维几个方面介绍Redis Cluster。
把数据集按照分区规则划分到多个节点上,每个节点负责整体数据的一 个子集;
常见的分区规则有哈希分区和顺 序分区两种,Redis Cluster采用哈希分区规则;
常见的哈希分区规则有几种:
1.节点取余分区
使用特定的数据,如Redis的键或用户ID,再根据节点数量N使用公 式:hash(key)%N计算出哈希值,用来决定数据映射到哪一个节点 上。
缺点:当节点数量变化时,如扩容或收缩节点, 数据节点映射关系需要重新计算,会导致数据的重新迁移
优点:简单性,常用于数据库的分库分表规则,一般采用预分区的方式,提前根据数据量规划好分区数,再根据负载情况将表 迁移到其他数据库中。扩容时通常采用翻倍扩容,避免数据映射全部被 打乱导致全量迁移的情况
2.一致性哈希分区
为系统中每 个节点分配一个token,范围一般在0~2 32 ,这些token构成一个哈希环。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找 到第一个大于等于该哈希值的token节点。
优点:加入和删除节点只影响哈希 环中相邻的节点,对其他节点无影响
缺点:
3.虚拟槽分区
虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把 所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot);Redis Cluster槽范围是0~16383;
槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了 方便数据拆分和集群扩展。每个节点会负责一定数量的槽;当前集群有5个节点,每个节点平均大约负责3276个槽。
Redis Cluster采用虚拟槽分区,所有的键根据哈希函数映射到 0~16383整数槽内,计算公式:slot=CRC16(key)&16383。每一个节 点负责维护一部分槽以及槽所映射的键值数据;
Redis虚拟槽分区的特点:
Redis集群相对单机在功能上存在的一些限制:
1)key批量操作支持有限,如mset、mget,目前只支持具有相同 slot值的key执行批量操作。
2)key事务操作支持有限。同理只支持多key在同一节点上的事务操作
3)key作为数据分区的最小粒度,因此不能将一个大的键值对象如 hash、list等映射到不同的节点
4)不支持多数据库空间。单机下的Redis可以支持16个数据库,集 群模式下只能使用一个数据库空间,即db0
5)复制结构只支持一层,从节点只能复制主节点,不支持嵌套树 状复制结构
搭建集群工作需要以下三个步骤:
1)准备节点
2)节点握手
3)分配槽
Redis集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群。每个节点需要开启配置cluster-enabled yes,让Redis 运行在集群模式下。建议为集群内所有节点统一目录,一般划分三个目录:conf、data、log,分别存放配置、数据和日志相关文件。
把6个节 点配置统一放在conf目录下,集群相关配置如下:
#节点端口
port 6379
# 开启集群模式
cluster-enabled yes
# 节点超时时间,单位毫秒
cluster-node-timeout 15000
# 集群内部配置文件
cluster-config-file "nodes-6379.conf"
其他配置和单机模式一致即可,配置文件命名规则redis- {port}.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
检查节点日志是否正确,日志内容如下:
cat log/redis-6379.log
* No cluster configuration found, I'm cfb28ef1deee4e0fa78da86abe5d24566744411e
# Server started, Redis version 3.0.7
* The server is now ready to accept connections on port 6379
集群模式的Redis除了原有的配置文件之外Redis还自动维护了一个集群配置文件,集群内节点信息发生变化时,节点会自动保存集群状态到配置文件中不需要手动修改,可以防止节点重启时产生集群信息错乱。
6379首次启动后生成集群配置:
#cat data/nodes-6379.conf
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
vars currentEpoch 0 lastVoteEpoch 0
#文件内容记录了集群初始状态,这里最重要的是节点ID,它是一个
40位16进制字符串,用于唯一标识集群内一个节点
#在节点6380执行cluster nodes
命令获取集群节点状态:
127.0.0.1:6380>cluster nodes
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 myself,master - 0 0 0 connected
节点握手让6个节点彼此 建立联系从而组成一个集群,节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通 信,达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户 端发起命令:cluster meet{ip}{port};
cluster meet命令是一个异步命令,执行之后立刻返 回。内部发起与目标节点进行握手通信:
1)节点6379本地创建6380节点信息对象,并发送meet消息
2)节点6380接受到meet消息后,保存6379节点信息并回复pong消息
3)之后节点6379和6380彼此定期通过ping/pong消息进行正常的节点通信
#在客户端查看集群状态只能看到与之建立通信连接的节点
127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0
0 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468073534265
1 connected
127.0.0.1:6380> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 master - 0 1468073571641
0 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 myself,master - 0 0
1 connected
我们只需要在集群内任意节点上执行cluster meet命令加入新节点;握手状态会通过消息在集群内传播,这样其他节点会自动发现新节点并 发起握手流程。最后执行cluster nodes命令确认6个节点都彼此感知并组成集群;
节点建立握手之后集群还不能正常工作,这时集群处于下线状态, 所有的数据读写都被禁止;通过cluster info命令可以获取集群当前状态:
127.0.0.1:6379> cluster info
cluster_state:fail
cluster_slots_assigned:0
cluster_slots_ok:0
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:0
cluster_slots_assigned:0 被分配的槽是0;目前所有的槽没有分配到节点,因此集群无法完成槽到节点的映 射。只有当16384个槽全部分配给节点后,集群才进入在线状态。
Redis集群把所有的数据映射到16384个槽中。每个key会映射为一 个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。 通过cluster addslots命令为节点分配槽。这里利用bash特性批量设置槽(slots),命令如下:
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}
#把16384个slot平均分配给6379、6380、6381三个节点。执行cluster
info查看集群状态
127.0.0.1:6379> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:5
cluster_my_epoch:0
cluster_stats_messages_sent:4874
cluster_stats_messages_received:4726
#当前集群状态是OK,集群进入在线状态。所有的槽都已经分配给
节点,执行cluster nodes命令可以看到节点和槽的分配关系:
127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 master - 0 1468076240123
5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
0-5461
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 master - 0 1468076239622
4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 master - 0 1468076240628
3 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468076237606
1 connected
5462-10922
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468076238612
2 connected
10923-16383
目前还有三个节点没有使用,作为一个完整的集群,每个负责处理 槽的节点应该具有从节点,保证当它出现故障时可以自动进行故障转移。
集群模式下,Reids节点角色分为主节点和从节点。首次启动的节 点和被分配槽的节点都是主节点,从节点负责复制主节点槽信息和相关的数据。
使用cluster replicate{nodeId}命令让一个节点成为从节点(给主节点分配从节点)。其中命令执行必须在对应的从节点上执行,nodeId是要复制主节点的节点 ID,命令如下:
127.0.0.1:6382>cluster replicate cfb28ef1deee4e0fa78da86abe5d24566744411e
OK
127.0.0.1:6383>cluster replicate 8e41673d59c9568aa9d29fb174ce733345b3e8f1
OK
127.0.0.1:6384>cluster replicate 40b8d09d44294d2e23c7c768efc8fcd153446746
OK
集群完整结构:
上边我们依照Redis协议手动建立一个集群。它由6个节点构 成,3个主节点负责处理槽和相关数据,3个从节点负责故障转移。集群搭 建需要很多步骤,当集群节点众多时,必然会加大搭建集群的复杂度和 运维成本。因此Redis官方提供了redis-trib.rb工具方便我们快速搭建集群。
redis-trib.rb是采用Ruby实现的Redis集群管理工具。内部通过Cluster 相关命令帮我们简化集群创建、检查、槽迁移和均衡等常见运维操作, 使用之前需要安装Ruby依赖环境。
1.Ruby环境准备
安装Ruby:
-- 下载
ruby
wget https:// cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.1.tar.gz
-- 安装
ruby
tar xvf ruby-2.3.1.tar.gz
./configure -prefix=/usr/local/ruby
make
make install
cd /usr/local/ruby
sudo cp bin/ruby /usr/local/bin
sudo cp bin/gem /usr/local/bin
安装rubygem redis依赖:
wget http:// rubygems.org/downloads/redis-3.3.0.gem
gem install -l redis-3.3.0.gem
gem list --check redis gem
安装redis-trib.rb:
sudo cp /{redis_home}/src/redis-trib.rb /usr/local/bin
安装完Ruby环境后,执行redis-trib.rb命令确认环境是否正确,输出
如下:
# redis-trib.rb
Usage: redis-trib
create host1:port1 ... hostN:portN
--replicas
check host:port
info host:port
fix host:port
--timeout
reshard host:port
--from
--to
--slots
--yes
--timeout
--pipeline
redis-trib.rb提供了集群创建、检查、修 复、均衡等命令行工具。我们关注集群创建命令,使用redis-trib.rb create命令可快速搭建集群;
2.准备节点
准备好节点配置并启动
redis-server conf/redis-6481.conf
redis-server conf/redis-6482.conf
redis-server conf/redis-6483.conf
redis-server conf/redis-6484.conf
redis-server conf/redis-6485.conf
redis-server conf/redis-6486.conf
3.创建集群
1)使用redis-trib.rb create命令完成节点握手和槽 分配过程
redis-trib.rb create --replicas 1 127.0.0.1:6481 127.0.0.1:6482 127.0.0.1:6483
127.0.0.1:6484 127.0.0.1:6485 127.0.0.1:6486
--replicas参数指定集群中每个主节点配备几个从节点
--如果部署节点使用不同的IP地址,redis-trib.rb会尽可能保证主从节点不分配在同一机器下
2)创建过程中首先会给出主从节点角色分配的计划:
>>> Creating cluster
>>> Performing hash slots allocation on 6 nodes...
Using 3 masters:
127.0.0.1:6481
127.0.0.1:6482
127.0.0.1:6483
Adding replica 127.0.0.1:6484 to 127.0.0.1:6481
Adding replica 127.0.0.1:6485 to 127.0.0.1:6482
Adding replica 127.0.0.1:6486 to 127.0.0.1:6483
M: 869de192169c4607bb886944588bc358d6045afa 127.0.0.1:6481
slots:0-5460 (5461 slots) master
M: 6f9f24923eb37f1e4dce1c88430f6fc23ad4a47b 127.0.0.1:6482
slots:5461-10922 (5462 slots) master
M: 6228a1adb6c26139b0adbe81828f43a4ec196271 127.0.0.1:6483
slots:10923-16383 (5461 slots) master
S: 22451ea81fac73fe7a91cf051cd50b2bf308c3f3 127.0.0.1:6484
replicates 869de192169c4607bb886944588bc358d6045afa
S: 89158df8e62958848134d632e75d1a8d2518f07b 127.0.0.1:6485
replicates 6f9f24923eb37f1e4dce1c88430f6fc23ad4a47b
S: bcb394c48d50941f235cd6988a40e469530137af 127.0.0.1:6486
replicates 6228a1adb6c26139b0adbe81828f43a4ec196271
Can I set the above configuration (type 'yes' to accept):
3)输入yes,redis-trib.rb开始执行节点握手和 槽分配操作:
>>> 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:6481)
...忽略
...
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
#最后的输出报告说明:16384个槽全部被分配,集群创建成功。这
里需要注意给redis-trib.rb的节点地址必须是不包含任何槽/数据的节点,
否则会拒绝创建集群
4.集群完整性检查
集群完整性指所有的槽都分配到存活的主节点上,只要16384个槽 中有一个没有分配给节点则表示集群不完整。
使用redis-trib.rb check命令检测之前创建的两个集群是否成功(只需要给出集 群中任意一个节点地址):
redis-trib.rb check 127.0.0.1:6379
#输出信息,提示集群所有的槽都已分配到节点
[OK] All 16384 slots covered.
Redis集群采用P2P的Gossip(流言) 协议,在分布式存储中提供维护节点元数据信息的机制,所谓元数据 是指节点负责哪些数据,是否出现故障等状态信息。Gossip协议工作原理就是节点彼此不断通信交换信息,一段时间 后所有的节点都会知道集群完整的信息;
通信过程:
1)集群中的每个节点都会单独开辟一个TCP通道,用于节点之间 彼此通信,通信端口号在基础端口上加10000
2)每个节点在固定周期内通过特定规则选择几个节点发送ping消息
3)接收到ping消息的节点用pong消息作为响应
集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知 道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通 信,最终它们会达到一致的状态,从而 达到集群状态同步的目的;
Gossip协议的主要职责就是信息交换。信息交换的载体就是节点彼 此发送的Gossip消息;
常用的Gossip消息可分为:ping消息、pong消息、meet消息、fail消 息等:
消息格式划分为:消息头和消息体。消息头包含发送节点自 身状态数据,接收节点根据消息头就可以获取到发送节点的相关数据
#集群内所有的消息都采用相同的消息头结构clusterMsg,它包含了
发送节点关键信息,如节点id、槽映射、节点标识(主从角色,是否下
线)等
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;
#消息体clusterMsgData定义发送消息的数据,其中ping、meet、pong
都采用cluster MsgDataGossip数组作为消息体数据,实际消息类型使用
消息头的type属性区分。每个消息体包含该节点的多个
clusterMsgDataGossip结构数据,用于信息交换
union clusterMsgData {
/* ping,meet,pong消息体*/
struct {
/* gossip消息结构数组*/
clusterMsgDataGossip gossip[1];
} ping;
/* FAIL 消息体*/
struct {
clusterMsgDataFail about;
} fail;
// ...
};
#clusterMsgDataGossip结构数据,用于信息交换
typedef struct {
char nodename[CLUSTER_NAMELEN]; /* 节点的nodeId */
uint32_t ping_sent; /* 最后一次向该节点发送ping消息时间*/
uint32_t pong_received; /* 最后一次接收该节点pong消息时间*/
char ip[NET_IP_STR_LEN]; /* IP */
uint16_t port; /* port*/
uint16_t flags; /* 该节点标识, */
} clusterMsgDataGossip;
当接收到ping、meet消息时,接收节点会解析消息内容并根据自身的识别情况做出相应处理,流程如下:
1)解析消息头过程:消息头包含了发送节点的信息,如果发送节点 是新节点且消息是meet类型,则加入到本地节点列表;如果是已知节 点,则尝试更新发送节点的状态,如槽映射关系、主从角色等状态
2)解析消息体过程:如果消息体的clusterMsgDataGossip数组包含的节点是新节点,则尝试发起与新节点的meet握手流程;如果是已知节点,则根据cluster MsgDataGossip中的flags字段判断该节点是否下线,用于故障转移
消息处理完后回复pong消息,内容同样包含消息头和消息体,发送 节点接收到回复的pong消息后,采用类似的流程解析处理消息并更新与 接收节点最后通信时间,完成一次消息通信。
Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。因此节点每次选择需要通信的节点列表变得非常重要。通信节点选择过 多虽然可以做到信息及时交换但成本过高。节点选择过少会降低集群内 所有节点彼此信息交换频率,从而影响故障判定、新节点发现等需求的速度。
Gossip协议通信节点选择的规则:
兼顾信息交换实时性和成本开销
消息交换的成本主要体现在单位 时间选择发送消息的节点数量和每个消息携带的数据量;
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参数对消息发送的节点数量影响非常大。当我们的 带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降 低带宽占用率。
过度调大cluster_node_timeout会影响消息交换的频率从 而影响故障转移、槽信息更新、新节点发现的速度。
2)消息数据量
每个ping消息的数据量体现在消息头和消息体中,其中消息头主要 占用空间的字段是myslots[CLUSTER_SLOTS/8],占用2KB,这块空间 占用相对固定。消息体会携带一定数量的其他节点信息用于信息交换;
消息体携带数据量跟集群的节点数息息相关, 更大的集群每次消息通信的成本也就更高,对于Redis集群来说并 不是大而全的集群更好;
Redis集群提供了灵活的节点扩容和收缩方案,集群可以添加节点进行扩容也可以下线部分节点进行缩容;它是Redis集群化之后最重要的功能,熟练掌握集群伸缩可以应对线上数据规模和并发量;
Redis集群可以实现对节点的灵活上下线控制。其 中原理可抽象为槽和对应数据在不同节点之间灵活移动;
三个主节点分别维护自己负责的槽和对应的数据,如果要加入1 个节点实现集群扩容时,需要通过相关命令把一部分槽和数据迁移给新节点;
每个节点把一部分槽和数据迁移到新的节点6385,每个节点负 责的槽和数据相比之前变少了从而达到了集群扩容的目的;集群伸缩= 槽和数据在节点之间的移动;
1)准备新节点
准备好新节点并运行在集群模式下,新节点建议跟集群内 的节点配置保持一致,此时新节点并没有与集群建立通信所以此时新节点属于孤儿节点;
2)加入集群
新节点依然采用cluster meet命令加入到现有集群中,从孤儿节点加入到集群通信中;集群内新旧节点经过一段时间的ping/pong消息通信之后,所有节点 会发现新节点并将它们的状态保存到本地。
新节点刚开始都是主节点状态,但是由于没有负责的槽,所以不能接受任何读写操作。一般会有两种操作一是为它迁移槽和数据实现扩容,二是作为其他主节点的从节点负责故障转移。
通过redis-trib.rb工具为现有集群添加新节点的命令
直接添加为从节点:
redis-trib.rb add-node new_host:new_port existing_host:existing_port --slave
--master-id
建议使用redis-trib.rb add-node命令加入新节点,该命令内 部会执行新节点状态检查,如果新节点已经加入其他集群或者包含数 据,则放弃集群加入操作并打印如下信息
redis-trib.rb add-node 127.0.0.1:6385 127.0.0.1:6379
redis-trib.rb add-node 127.0.0.1:6386 127.0.0.1:6379
如果我们手动执行cluster meet命令加入已经存在于其他集群的节 点,会造成被加入节点的集群合并到现有集群的情况,从而造成数据丢 失和错乱,后果非常严重,线上谨慎操作。
3)迁移槽和数据
加入集群后需要为新节点迁移槽和相关数据,槽在迁移过程中集群 可以正常提供读写服务,迁移过程是集群扩容最核心的环节;
(1)槽迁移计划
槽是Redis集群管理数据的基本单位,首先需要为新节点制定槽的 迁移计划,确定原有节点的哪些槽需要迁移到新节点。迁移计划需要确 保每个节点负责相似数量的槽,从而保证各节点的数据均匀;
(2)迁移数据 数据迁移过程是逐个槽进行的
1)对目标节点发送cluster setslot{slot}importing{sourceNodeId}命 令,让目标节点准备导入槽的数据
2)对源节点发送cluster setslot{slot}migrating{targetNodeId}命令, 让源节点准备迁出槽的数据
3)源节点循环执行cluster getkeysinslot{slot}{count}命令,获取 count个属于槽{slot}的键
4)在源节点上执行migrate{targetIp}{targetPort}""0{timeout}keys{keys...}命令,把获取的键通过流水线 (pipeline)机制批量迁移到目标节点,批量迁移版本的migrate命令在 Redis3.0.6以上版本提供,之前的migrate命令只能单个键迁移。对于大 量key的场景,批量键迁移将极大降低节点之间网络IO次数
5)重复执行步骤3)和步骤4)直到槽下所有的键值数据迁移到目标节点
6)向集群内所有主节点发送cluster setslot{slot}node{targetNodeId} 命令,通知槽分配给目标节点。为了保证槽节点映射变更及时传播,需 要遍历发送给所有主节点更新被迁移的槽指向新节点;
使用命令把源节点6379负责的槽4096迁移到目标节点6385中,整体流程如下:
1)目标节点准备导入槽4096数据:
127.0.0.1:6385>cluster setslot 4096 importing cfb28ef1deee4e0fa78da86abe5d24566744411e
OK
确认槽4096导入状态开启:
127.0.0.1:6385>cluster nodes
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 myself,master - 0 0 7 connected
[4096-<-cfb28ef1deee4e0fa78da86abe5d24566744411e]
...
2)源节点准备导出槽4096数据:
127.0.0.1:6379>cluster setslot 4096 migrating 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
OK
确认槽4096导出状态开启:
127.0.0.1:6379>cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
0-5461 [4096->-1a205dd8b2819a00dd1e8b6be40a8e2abe77b756]
...
3)批量获取槽4096对应的键,这里我们获取到3个处于该槽的键:
127.0.0.1:6379> cluster getkeysinslot 4096 100
1) "key:test:5028"
2) "key:test:68253"
3) "key:test:79212"
确认这三个键是否存在于源节点:
127.0.0.1:6379>mget key:test:5028 key:test:68253 key:test:79212
1) "value:5028"
2) "value:68253"
3) "value:79212"
4)批量迁移这3个键,migrate命令保证了每个键迁移过程的原子性:
127.0.0.1:6379>migrate 127.0.0.1 6385 "" 0 5000 keys key:test:5028 key:test:68253
key:test:79212
查询这三个键,发现已经不在源节点中,
Redis返回ASK转向错误
127.0.0.1:6379> mget key:test:5028 key:test:68253 key:test:79212
(error) ASK 4096 127.0.0.1:6385
5)通知所有主节点槽4096指派给目标节点6385:
127.0.0.1:6379>cluster setslot 4096 node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
127.0.0.1:6380>cluster setslot 4096 node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
127.0.0.1:6381>cluster setslot 4096 node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
127.0.0.1:6385>cluster setslot 4096 node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
确认源节点6379不再负责槽4096改为目标节点6385负责:
127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
0-4095 4097-5461
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1469718011079 7
connected 4096
实际操作时肯定涉及大量槽并且每个槽对应非常多的键所以我们不能通过命令来进行迁移数据操作。因此 redis-trib提供了槽重分片功能:
redis-trib.rb reshard host:port --from --to --slots --yes --timeout
--pipeline
#1)输入命令
redis-trib.rb reshard 127.0.0.1:6379
#2)打印出集群每个节点信息后,reshard命令需要确认迁移的槽数量,我们输入4096个:
How many slots do you want to move (from 1 to 16384)4096
#3)输入6385的节点ID作为目标节点,目标节点只能指定一个:
What is the receiving node ID 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
#4)之后输入源节点的ID,这里分别输入节点6379、6380、6381三个节
点ID最后用done表示结束:
Please enter all the source node IDs.
Type 'all' to use all the nodes as source nodes for the hash slots.
Type 'done' once you entered all the source nodes IDs.
Source node #1:cfb28ef1deee4e0fa78da86abe5d24566744411e
Source node #2:8e41673d59c9568aa9d29fb174ce733345b3e8f1
Source node #3:40b8d09d44294d2e23c7c768efc8fcd153446746
Source node #4:done
#5)数据迁移之前会打印出所有的槽从源节点到目标节点的计划,确认
计划无误后输入yes执行迁移工作:
Moving slot 0 from cfb28ef1deee4e0fa78da86abe5d24566744411e
....
Moving slot 1365 from cfb28ef1deee4e0fa78da86abe5d24566744411e
Moving slot 5462 from 8e41673d59c9568aa9d29fb174ce733345b3e8f1
...
Moving slot 6826 from 8e41673d59c9568aa9d29fb174ce733345b3e8f1
Moving slot 10923 from 40b8d09d44294d2e23c7c768efc8fcd153446746
...
Moving slot 12287 from 40b8d09d44294d2e23c7c768efc8fcd153446746
Do you want to proceed with the proposed reshard plan (yes/no) yes
#6)当所有的槽迁移完成后,reshard命令自动退出,执行cluster nodes命
令检查节点和槽映射的变化:
127.0.0.1:6379>cluster nodes
#7)迁移之后建议使用redis-trib.rb rebalance命令检查节点之间槽的均
衡性。命令如下:
# redis-trib.rb rebalance 127.0.0.1:6380
(3)添加从节点
我们把6385、6386节点加入到集群,节点6385迁移了部分槽和数据作为主节点,但目前还没有从节点,因此该节点不具备故障转移的能力。 需要把节点6386作为6385的从节点,保证整个集群的高可用。使用cluster replicate{masterNodeId}命令为主节点添加对应从节点。
#注意在集群模式下slaveof添加从节点操作不再支持
127.0.0.1:6386>cluster replicate 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
#查看节点6386状态确认已经变成6385节点的从节点
127.0.0.1:6386>cluster nodes
到此整个集群扩容完成:
收缩集群,从现有集群中安全下线部分节点,流程如下:
1.下线迁移槽
下线节点需要把自己负责的槽迁移到其他节点,原理与之前节点扩容的迁移槽过程一致;
#6381是主节点,负责槽(12288-16383),6384是它的从节点
127.0.0.1:6381> cluster nodes
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 myself,master - 0 0 2 connected
12288-16383
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 slave 40b8d09d44294d2e2
3c7c768efc8fcd153446746 0 1469894180780 5 connected
收缩正好和扩容迁移方向相反,6381变为源节点,其他主节点变为目标节点,源节点需要把自身负责的4096个槽均匀地迁移到其他主节点上。这里直接使用redis-trib.rb reshard命令完成槽迁移。由于每次执行 reshard命令只能有一个目标节点,因此需要执行3次reshard命令,分别迁移1365、1365、1366个槽;
#redis-trib.rb reshard 127.0.0.1:6381
>>> Performing Cluster Check (using node 127.0.0.1:6381)
...
[OK] All 16384 slots covered.
How many slots do you want to move (from 1 to 16384)1365
What is the receiving node ID cfb28ef1deee4e0fa78da86abe5d24566744411e /*输入
6379
节点
id作为目标节点
.*/
Please enter all the source node IDs.
Type 'all' to use all the nodes as source nodes for the hash slots.
Type 'done' once you entered all the source nodes IDs.
Source node #1:40b8d09d44294d2e23c7c768efc8fcd153446746 /*源节点
6381 id*/
Source node #2:done /* 输入
done确认
*/
...
Do you want to proceed with the proposed reshard plan (yes/no) yes
...
槽迁移完成后,6379节点接管了1365个槽12288~13652,如下所
示:
127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 10 connected
1366-4095 4097-5461 12288-13652
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1469895725227 2
connected 13653-16383
...
继续把1365个槽迁移到节点6380,把最后的1366个槽迁移到节点6385中,节点6381所有的槽全部迁出完成,6381不再负责任何槽;
2.忘记节点
集群内的节点不停地通过Gossip消息彼此交换节点状态,Redis提供了cluster forget{downNodeId}命令实现让其他节点不再与要下线节点进行Gossip消息交换;
当节点接收到cluster forget{down NodeId}命令后,会把nodeId指定 的节点加入到禁用列表中,在禁用列表内的节点不再发送Gossip消息。 禁用列表有效期是60秒,超过60秒节点会再次参与消息交换。也就是说 当第一次forget命令发出后,我们有60秒的时间让集群内的所有节点忘 记下线节点。 线上操作不建议直接使用cluster forget命令下线节点,需要跟大量 节点命令交互,实际操作起来过于繁琐并且容易遗漏forget节点。
使用redis-trib.rb del-node{host:port}{downNodeId}命令:
#del-node命令帮我们实现了安全下线的后续操作。当下线主节点具有从节点时需要把该从节点
指向到其他主节点,因此对于主从节点都下线的情况,建议先下线从节点再下线主节点,防止不必要的全量复制
redis-trib.rb del-node 127.0.0.1:6379 4fa7eac4080f0b667ffeab9b87841da49b84a6e4 #从节点 6384 id
redis-trib.rb del-node 127.0.0.1:6379 40b8d09d44294d2e23c7c768efc8fcd153446746 #主节点 6381 id
#节点下线后确认节点状态:
127.0.0.1:6379> cluster nodes
使用客户端去操作集群,Redis集群对客户端通信协议做了比较大的修改,为了追求性能最大化,并没有采用代理的方式而是采用客户端直连节点的方式。因此对于从单机切换到集群环境的应用需要修改客户端代码;
Redis在集群模式下接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点,这个过程称为MOVED重定向。
#cluster keyslot{key}命令返回key所对应的槽:
127.0.0.1:6379> cluster keyslot key:test:1
(integer) 5191
127.0.0.1:6379> cluster nodes
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 10 connected
1366-4095 4097-5461 12288-13652
#键对应槽是9252,不属于6379节点,则回复MOVED{slot}{ip}{port}格式重定向信息:
127.0.0.1:6379> set key:test:2 value-2
(error) MOVED 9252 127.0.0.1:6380
127.0.0.1:6379> cluster keyslot key:test:2
(integer) 9252
重定向信息包含了键所对应的槽以及负责该槽的节点地址,根据这 些信息客户端就可以向正确的节点发起请求。在6380节点上成功执行之 前的命令:
127.0.0.1:6380> set key:test:2 value-2
OK
使用redis-cli命令时,可以加入-c参数支持自动重定向,简化手动发 起重定向操作
#redis-cli -p 6379 -c
127.0.0.1:6379> set key:test:2 value-2
-> Redirected to slot [9252] located at 127.0.0.1:6380
OK
redis-cli自动帮我们连接到正确的节点执行命令,这个过程是在 redis-cli内部维护,实质上是client端接到MOVED信息之后再次发起请 求,并不在Redis节点中完成请求转发;
1)计算槽
Redis首先需要计算键所对应的槽。根据键的有效部分使用CRC16 函数计算出散列值,再取对16383的余数,使每个键都可以映射到 0~16383槽范围内;
#cluster keyslot命令就是采用key_hash_slot函数实现的:
127.0.0.1:6379> cluster keyslot key:test:111
(integer) 10050
127.0.0.1:6379> cluster keyslot key:{hash_tag}:111
(integer) 2515
127.0.0.1:6379> cluster keyslot key:{hash_tag}:222
(integer) 2515
键内部使用大括号包含的内容又叫做hash_tag,它提供不同的键可以具备相同slot的功能,常用于Redis IO优化;
#例如在集群模式下使用mget等命令优化批量调用时,键列表必须具
有相同的slot,否则会报错。这时可以利用hash_tag让不同的键具有相同
的slot达到优化的目的:
127.0.0.1:6385> mget user:10086:frends user:10086:videos
(error) CROSSSLOT Keys in request don't hash to the same slot
127.0.0.1:6385> mget user:{10086}:friends user:{10086}:videos
1) "friends"
2) "videos"
Pipeline同样可以受益于hash_tag,由于Pipeline只能向一个节点批量 发送执行命令,而相同slot必然会对应到唯一的节点,降低了集群使用Pipeline的门槛;
2)槽节点查找
Redis计算得到键对应的槽后,需要查找槽所对应的节点。集群内 通过消息交换每个节点都会知道所有节点的槽信息,内部保存在 clusterState结构中;
根据MOVED重定向机制, 客户端可以随机连接集群内任一Redis获取键所在节点,这种客户端又叫Dummy(傀儡)客户端
它优点:代码实现简单,对客户端协议影 响较小,只需要根据重定向信息再次发送请求即可。
它的弊端:每次执行键命令前都要到Redis上进行重定向才能找到要执行命令的节点,额外增加了IO开销,这不是Redis集群高效的使用方式。
通常集群客户端都采用另一种实现:Smart(智能)客户端
Smart客户端通过在内部维护slot→node的映射关系,本地就 可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向 负责协助Smart客户端更新slot→node映射。
1.smart客户端原理
以Java的Jedis为例,说 明Smart客户端操作集群的流程:
1)首先在JedisCluster初始化时会选择一个运行节点,初始化槽和 节点映射关系,使用cluster slots命令完成:
127.0.0.1:6379> cluster slots
1) 1) (integer) 0 // 开始槽范围
2) (integer) 1365 // 结束槽范围
3) 1) "127.0.0.1" // 主节点ip
2) (integer) 6385 // 主节点地址
4) 1) "127.0.0.1" // 从节点ip
2) (integer) 6386 // 从节点端口
2) 1) (integer) 5462
2) (integer) 6826
3) 1) "127.0.0.1"
2) (integer) 6385
4) 1) "127.0.0.1"
2) (integer) 6386
...
2)JedisCluster解析cluster slots结果缓存在本地,并为每个节点创建 唯一的JedisPool连接池。映射关系在JedisClusterInfoCache类中:
public class JedisClusterInfoCache {
private Map nodes = new HashMap();
private Map slots = new HashMap();
...
}
3)JedisCluster执行键命令的过程有些复杂
键命令执行流程:
Smart客户端成本和可能存在的问题分析:
1)客户端内部维护slots缓存表,并且针对每个节点维护连接池, 当集群规模非常大时,客户端会维护非常多的连接并消耗更多的内存
2)使用Jedis操作集群时最常见的错误是:
throw new JedisClusterMaxRedirectionsException("Too many Cluster redirections"); 这经常会引起开发人员的疑惑,它隐藏了内部错误细节,原因是节 点宕机或请求超时都会抛出JedisConnectionException,导致触发了随机 重试,当重试次数耗尽抛出这个错误;
3)当出现JedisConnectionException时,Jedis认为可能是集群节点故 障需要随机重试来更新slots缓存,因此了解哪些异常将抛出 JedisConnectionException变得非常重要,有如下几种情况会抛出 JedisConnectionException:
前两点都可能是节点故障需要通过JedisConnectionException来更新 slots缓存,但是第三点没有必要,因此Jedis2.8.1版本之后对于连接池的 超时抛出Jedis Exception,从而避免触发随机重试机制;
4)Redis集群支持自动故障转移,但是从故障发现到完成转移需要 一定的时间,节点宕机期间所有指向这个节点的命令都会触发随机重 试,每次收到MOVED重定向后会调用JedisClusterInfoCache类的 renewSlotCache方法
获得写锁后再执行cluster slots命令初始化缓存,由于集群所有的键命令都会执行getSlotPool方法计算槽对应节点,它内部要求读锁。Reentrant ReadWriteLock是读锁共享且读写锁互斥,从而导致所有的请求都会造成阻塞。对于并发量高的场景将极大地影响集群吞吐。这个现象称为cluster slots风暴:
比如默认重试5次的情况,当抛出 JedisClusterMaxRedirectionsException异常时,内部最少需要9次IO通 信:5次发送命令+2次ping命令保证随机节点正常+2次cluster slots命令初 始化slots缓存。导致异常判定时间变长
多次调用cluster slots 命令,高并发时将过度消耗Redis节点资源,如果集群slotnode映射庞 大则cluster slots返回信息越多,问题越严重
针对以上问题在Jedis2.8.2版本做了改进:
2.Smart客户端——JedisCluster
(1)JedisCluster的定义
Jedis为Redis Cluster提供了Smart客户端,对应的类是JedisCluster, 它的初始化方法如下:
public JedisCluster(Set jedisClusterNode, int connectionTimeout, int
soTimeout, int maxAttempts, final GenericObjectPoolConfig poolConfig) {
...
}
JedisCluster的初始化过程:
// 初始化所有节点
(例如6个节点)
Set jedisClusterNode = new HashSet();
jedisClusterNode.add(new HostAndPort("10.10.xx.1", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.2", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.3", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.4", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.5", 6379));
jedisClusterNode.add(new HostAndPort("10.10.xx.6", 6379));
// 初始化commnon-pool连接池,并设置相关参数
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
// 初始化JedisCluster
JedisCluster jedisCluster = new JedisCluster(jedisClusterNode, 1000, 1000, 5, poolConfig);
JedisCluster可以实现命令的调用:
jedisCluster.set("hello", "world");
jedisCluster.get("key");
JedisCluster的使用需要注意以下几点:
(2)多节点命令和操作
Redis Cluster虽然提供了分布式的特性,但是有些命令或者操作, 诸如keys、flushall、删除指定模式的键,需要遍历所有节点才可以完成,从Redis Cluster删除指定模式键的功能:
// 从RedisCluster批量删除指定pattern的数据
public void delRedisClusterByPattern(JedisCluster jedisCluster, String pattern,
int scanCounter) {
// 获取所有节点的JedisPool
Map jedisPoolMap = jedisCluster.getClusterNodes();
for (Entry entry : jedisPoolMap.entrySet()) {
// 获取每个节点的Jedis连接
Jedis jedis = entry.getValue().getResource();
// 只删除主节点数据
if (!isMaster(jedis)) {
continue;
}
// 使用Pipeline每次删除指定前缀的数据
Pipeline pipeline = jedis.pipelined();
// 使用scan扫描指定前缀的数据
String cursor = "0";
// 指定扫描参数:每次扫描个数和pattern
ScanParams params = new ScanParams().count(scanCounter).match(pattern);
while (true) {
// 执行扫描
ScanResult scanResult = jedis.scan(cursor, params);
// 删除的key列表
List keyList = scanResult.getResult();
if (keyList != null && keyList.size() > 0) {
for (String key : keyList) {
pipeline.del(key);
}
// 批量删除
pipeline.syncAndReturnAll();
}
cursor = scanResult.getStringCursor();
// 如果游标变为0,说明扫描完毕
if ("0".equals(cursor)) {
break;
}
}
}
}
// 判断当前Redis是否为master节点
private boolean isMaster(Jedis jedis) {
String[] data = jedis.info("Replication").split("\r\n");
for (String line : data) {
if ("role:master".equals(line.trim())) {
return true;
}
}
return false;
}
具体分为如下几个步骤:
1)通过jedisCluster.getClusterNodes()获取所有节点的连接池
2)使用info replication筛选1)中的主节点
3)遍历主节点,使用scan命令找到指定模式的key,使用Pipeline机 制删除
#每次遍历1000个key,将Redis Cluster中以user开头的key全部删除。
String pattern = "user*";
int scanCounter = 1000;
delRedisClusterByPattern(jedisCluster, pattern, scanCounter);
keys、flushall等需要遍历所有节点的命令,同样可以参照 上面的方法进行相应功能的实现;
(3)批量操作的方法
Redis Cluster中,由于key分布到各个节点上,会造成无法实现 mget、mset等功能。但是可以利用CRC16算法计算出key对应的slot,以 及Smart客户端保存了slot和节点对应关系的特性,将属于同一个Redis节 点的key进行归档,然后分别对每个节点对应的子key列表执行mget或者 pipeline操作,下面文章我们会出具体使用方法;
(4)使用Lua、事务等特性的方法
Lua和事务需要所操作的key,必须在一个节点上,不过Redis Cluster提供了hashtag,如果开发人员确实要使用Lua或者事务,可以将 所要操作的key使用一个hashtag;
// hashtag
String hastag = "{user}";
// 用户A的关注表
String userAFollowKey = hastag + ":a:follow";
// 用户B的粉丝表
String userBFanKey = hastag + ":b:fans";
// 计算hashtag对应的slot
int slot = JedisClusterCRC16.getSlot(hastag);
// 获取指定slot的JedisPool
JedisPool jedisPool = jedisCluster.getConnectionHandler().getJedisPoolFromSlot(slot);
// 在当个节点上执行事务
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
// 用户A的关注表加入用户B,用户B的粉丝列表加入用户A
Transaction transaction = jedis.multi();
transaction.sadd(userAFollowKey, "user:b");
transaction.sadd(userBFanKey, "user:a");
transaction.exec();
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
if (jedis!= null)
jedis.close();
}
具体步骤如下:
1)将事务中所有的key添加hashtag
2)使用CRC16计算hashtag对应的slot
3)获取指定slot对应的节点连接池JedisPool
4)在JedisPool上执行事务
Redis集群支持在线迁移槽(slot)和数据来完成水平伸缩,当slot对 应的数据从源节点到目标节点迁移过程中,客户端需要做到智能识别, 保证键命令可正常执行;
1.客户端ASK重定向流程
1)客户端根据本地slots缓存发送命令到源节点,如果存在键对象 则直接执行并返回结果给客户端
2)如果键对象不存在,则可能存在于目标节点,这时源节点会回 复ASK重定向异常。格式如下:(error)ASK{slot}{targetIP}: {targetPort}
3)客户端从ASK重定向异常提取出目标节点信息,发送asking命令 到目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不 存在则返回不存在信息
ASK与MOVED的区别:
ASK重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存。
2.节点内部处理
为了支持ASK重定向,源节点和目标节点在内部的clusterState结构 中维护当前正在迁移的槽信息,用于识别槽迁移情况:
typedef struct clusterState {
clusterNode *myself; /* 自身节点*/
clusterNode *slots[CLUSTER_SLOTS]; /* 槽和节点映射数组*/
clusterNode *migrating_slots_to[CLUSTER_SLOTS];/* 正在迁出的槽节点数组*/
clusterNode *importing_slots_from[CLUSTER_SLOTS];/* 正在迁入的槽节点数组*/
...
} clusterState;
节点每次接收到键命令时,都会根据clusterState内的迁移属性进行命令处理:
当在集群环境下使用mget、mset等批量操作时,slot迁 移数据期间由于键列表无法保证在同一节点,会导致大量错误;Pipeline并没有直接抛出异常,可以借助于 JedisAskDataException内返回的目标节点信息,手动重定向请求给目标 节点,修改后的程序如下:
@Test
public void pipelineOnAskTestV2() {
JedisSlotBasedConnectionHandler connectionHandler = new JedisCluster(new Host
AndPort("127.0.0.1", 6379)) {
public JedisSlotBasedConnectionHandler getConnectionHandler() {
return (JedisSlotBasedConnectionHandler) super.connectionHandler;
}
}.getConnectionHandler();
List keys = Arrays.asList("key:test:68253", "key:test:79212", "key:
test:5028");
Jedis jedis = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.get
Slot(keys.get(2)));
try {
Pipeline pipelined = jedis.pipelined();
for (String key : keys) {
pipelined.get(key);
}
List
1)Pipeline严格按照键发送的顺序返回结果,即使出现异常也是如此
2)理解ASK重定向之后,可以手动发起ASK流程保证Pipeline的结果正确性
集群环境下对于使用批量操作的场景,建议优先使用Pipeline方 式,在客户端实现对ASK重定向的正确处理,这样既可以受益于批量操 作的IO优化,又可以兼容slot迁移场景
Redis集群自身实现了高可用。高可用首先需要解决集群部分失败 的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务;
Redis集群内节点通过ping/pong消息实现节点 通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状 态、节点故障等。因此故障发现也是通过消息传播机制实现的,主要环 节包括:主观下线(pfail)和客观下线(fail);
1)主观下线
指某个节点认为另一个节点不可用,即下线状态,这 个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况;
集群中每个节点都会定期向其他节点发送ping消息,接收节点回复 pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败, 则发送节点会认为接收节点存在故障,把接收节点标记为主观下线 (pfail)状态。
typedef struct clusterState {
clusterNode *myself; /* 自身节点*/
dict *nodes;/* 当前集群内所有节点的字典集合,key为节点ID,value为对应节点ClusterNode结构*/
...
} clusterState;字典nodes属性中的clusterNode结构保存了节点的状态,关键属性如下:
typedef struct clusterNode {
int flags; /* 当前节点状态,如:主从角色,是否下线等*/
mstime_t ping_sent; /* 最后一次与该节点发送ping消息的时间*/
mstime_t pong_received; /* 最后一次接收到该节点pong消息的时间*/
...
} clusterNode;
其中最重要的属性是flags,用于标示该节点对应状态如下:
CLUSTER_NODE_MASTER 1 /* 当前为主节点*/
CLUSTER_NODE_SLAVE 2 /* 当前为从节点*/
CLUSTER_NODE_PFAIL 4 /* 主观下线状态*/
CLUSTER_NODE_FAIL 8 /* 客观下线状态*/
CLUSTER_NODE_MYSELF 16 /* 表示自身节点*/
CLUSTER_NODE_HANDSHAKE 32 /* 握手状态,未与其他节点进行消息通信*/
CLUSTER_NODE_NOADDR 64 /* 无地址节点,用于第一次meet通信未完成或者通信失败*/
CLUSTER_NODE_MEET 128 /* 需要接受meet消息的节点状态*/
CLUSTER_NODE_MIGRATE_TO 256 /* 该节点被选中为新的主节点状态*/
Redis集群对于节点最终是否故障判断非常严谨,只有一个节点认为主观下线并不能准确判断是否故障;多个节点协作完成故障发现的过程叫做客观下线;
2)客观下线
指标记一个节点真正的下线,集群内多个节点都认为 该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移;
当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消 息在集群内传播。ping/pong消息的消息体会携带集群1/10的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态时,会在本 地找到故障节点的ClusterNode结构,保存到下线报告链表中;
通过Gossip消息传播,集群内节点不断收集到故障节点的下线报 告。当半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程 ;
1)为什么必须是负责槽的主节点参与故障发现决策
因为集群模 式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制
2)为什么半数以上处理槽的主节点
必须半数以上是为了应对网络分区等原因造成的集群分割情况,被分割的小集群因为无法完成从主观下线到客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外提供服务
流程:
(1)维护下线报告链表
每个节点ClusterNode结构中都会存在一个下线链表结构,保存了其 他主节点针对当前节点的下线报告
typedef struct clusterNodeFailReport {
struct clusterNode *node; /* 报告该节点为主观下线的节点*/
mstime_t time; /* 最近收到下线报告的时间*/
} clusterNodeFailReport;
下线报告中保存了报告故障的节点结构和最近收到下线报告的时 间,当接收到fail状态时,会维护对应节点的下线上报链表;
每个下线报告都存在有效期,每次在尝试触发客观下线时,都会检测下线报告是否过期,对于过期的下线报告将被删除。下线报告的有效期限是server.cluster_node_timeout*2,主要是针对 故障误报的情况。例如节点A在上一小时报告节点B主观下线,但是之 后又恢复正常。现在又有其他节点上报节点B主观下线,根据实际情况 之前的属于误报不能被使用;
注意:
如果在cluster-node-time*2时间内无法收集到一半以上槽节点的下线 报告,那么之前的下线报告将会过期,也就是说主观下线上报的速度追 赶不上下线报告过期的速度,那么故障节点将永远无法被标记为客观下 线从而导致故障转移失败。因此不建议将cluster-node-time设置得过小;
(2)尝试客观下线
集群中的节点每次接收到其他节点的pfail状态,都会尝试触发客观下线:
流程说明:
1)首先统计有效的下线报告数量,如果小于集群内持有槽的主节点总数的一半则退出
2)当下线报告大于槽主节点数量一半时,标记对应故障节点为客观下线状态
3)向集群广播一条fail消息,通知所有的节点将故障节点标记为客 观下线,fail消息的消息体只包含故障节点的ID;广播fail消息是客观下线的最后一步:
·通知集群内所有的节点标记故障节点为客观下线状态并立刻生 效。
·通知故障节点的从节点触发故障转移流程
注意:
网络分区会导致分割后的小集群无法收到大集群的fail消息,因此如果故障节点所有的从节点都在小集群内将导致无法完成后续故障转移,因此部署主从结构时需要根据自身机房/机架拓扑结构,降低主从被分区的可能性;
故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要 在它的从节点中选出一个替换它,从而保证集群的高可用。
1.资格检查
每个从节点都要检查最后与主节点断线时间,判断是否有资格替换 故障的主节点。如果从节点与主节点断线时间超过cluster-nodetime*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。 参数cluster-slave-validity-factor用于从节点的有效因子,默认为10;
2.准备选举时间
当从节点符合故障转移资格后,更新触发故障选举的时间,只有到 达该时间后才能执行后续流程:
struct clusterState {
...
mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间*/
int failover_auth_rank; /* 记录当前从节点排名*/
}
这里之所以采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越 低,那么它应该具有更高的优先级来替换故障主节点;
主节点b进入客观下线后,它的三个从节点根据自身复制偏移量设 置延迟选举时间,如复制偏移量最大的节点slave b-1延迟1秒执行,保证 复制延迟低的从节点优先发起选举
3.发起选举
当从节点定时任务检测到达故障选举时间(failover_auth_time)到 达后,发起选举流程:
(1)更新配置纪元
配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪 元(clusterNode.configEpoch)标示当前主节点的版本,所有主节点的配 置纪元都不相等,从节点会复制主节点的配置纪元。整个集群又维护一 个全局的配置纪元(clusterState.current Epoch),用于记录集群内所有 主节点配置纪元的最大版本
127.0.0.1:6379> cluster info
...
cluster_current_epoch:15 // 整个集群最大配置纪元
cluster_my_epoch:13 // 当前主节点配置纪元
配置纪元的主要作用:
·标示集群内每个主节点的不同版本和当前集群最大的版本
·每次集群发生重要事件时,这里的重要事件指出现新的主节点 (新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群 全局的配置纪元并赋值给相关主节点,用于记录这一关键事件
·主节点具有更大的配置纪元代表了更新的集群状态,因此当节点 间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置 纪元更大的一方为准,防止过时的消息状态污染集群
配置纪元的应用场景有:
注意:
之前在通过cluster setslot命令修改槽节点映射时,需要确保执行请 求的主节点本地配置纪元(configEpoch)是最大值,否则修改后的槽信 息在消息传播中不会被拥有更高的配置纪元的节点采纳。由于Gossip通 信机制无法准确知道当前最大的配置纪元在哪个节点,因此在槽迁移任 务最后的cluster setslot{slot}node{nodeId}命令需要在全部主节点中执行 一遍。
(2)广播选举消息
在集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记 录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次 选举。消息内容如同ping消息只是将type类型变为 FAILOVER_AUTH_REQUEST。
4.选举投票
只有持有槽的主节点才会处理故障选举消息 (FAILOVER_AUTH_REQUEST),因为每个持有槽的节点在一个配 置纪元内都有唯一的一张选票,当接到第一个请求投票的从节点消息时 回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他 从节点的选举消息将忽略;
投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的 主节点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投 票给一个从节点,因此只能有一个从节点获得N/2+1的选票,保证能够 找出唯一的从节点。
Redis集群没有直接使用从节点进行领导者选举,主要因为从节点 数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪 费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从 节点也可以完成选举过程。
当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行 替换主节点操作,例如集群内有5个持有槽的主节点,主节点b故障后还 有4个,当其中一个从节点收集到3张投票时代表获得了足够的选票可以 进行替换主节点操作:
注意:
故障主节点也算在投票数内,假设集群内节点规模是3主3从,其中 有2个主节点部署在一台机器上,当这台机器宕机时,由于从节点无法 收集到3/2+1个主节点选票将导致故障转移失败。这个问题也适用于故 障发现环节。因此部署集群时所有主节点最少需要部署在3台物理机上 才能避免单点问题;
投票作废 :每个配置纪元代表了一次选举周期,如果在开始投票 之后的cluster-node-timeout*2时间内从节点没有获取足够数量的投票, 则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举 成功为止。
5.替换主节点
当从节点收集到足够的选票之后,触发替换主节点操作:
1)当前从节点取消复制变为主节点。
2)执行clusterDelSlot操作撤销故障主节点负责的槽,并执行 clusterAddSlot把这些槽委派给自己。
3)向集群广播自己的pong消息,通知集群内所有的节点当前从节 点变为主节点并接管了故障主节点的槽信息。
1)主观下线(pfail)识别时间=cluster-node-timeout
2)主观下线状态消息传播时间<=cluster-node-timeout/2。消息通信 机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在 选择包含哪些节点时会优先选取下线状态节点,所以通常这段时间内能 够收集到半数以上主节点的pfail报告从而完成故障发现
3)从节点转移时间<=1000毫秒。由于存在延迟发起选举机制,偏 移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成 功,所以从节点执行转移时间在1秒以内
failover-time(毫秒
) ≤
cluster-node-timeout + cluster-node-timeout/2 + 1000
故障转移时间跟cluster-node-timeout参数息息相关,默认15 秒。配置时可以根据业务容忍度做出适当调整,但不是越小越好
1.确认集群状态:
127.0.0.1:6379> cluster nodes
2.使用kill-9强制关闭主 节点6385进程
# ps -ef | grep redis-server | grep 6385
501 1362 1 0 10:50 0:11.65 redis-server *:6385 [cluster]
# kill -9 1362
3.日志分析如下:
#从节点6386与主节点6385复制中断
==> redis-6386.log <==
# Connection with master lost.
* Caching the disconnected master state.
* Connecting to MASTER 127.0.0.1:6385
* MASTER <-> SLAVE sync started
# Error condition on socket for SYNC: Connection refused
#6379和6380两个主节点都标记6385为主观下线,超过半数因此标记为客观下线状态:
==> redis-6380.log <==
* Marking node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing (quorum reached).
==> redis-6379.log <==
* Marking node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing (quorum reached).
#从节点识别正在复制的主节点进入客观下线后准备选举时间,日志打印了选举延迟964毫秒之后执行,并打印当前从节点复制偏移量。
==> redis-6386.log <==
# Start of election delayed for 964 milliseconds (rank #0, offset 1822).
#延迟选举时间到达后,从节点更新配置纪元并发起故障选举。
==> redis-6386.log <==
1364:S 22 Aug 23:12:25.064 # Starting a failover election for epoch 17.
#6379和6380主节点为从节点6386投票
==> redis-6380.log <==
# Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17
==> redis-6379.log <==
# Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17
#从节点获取2个主节点投票之后,超过半数执行替换主节点操作,
从而完成故障转移:
==> redis-6386.log <==
# Failover election won: I'm the new master.
# configEpoch set to 17 after successful failover
成功完成故障转移之后,我们对已经出现故障节点6385进行恢复, 观察节点状态是否正确:
1)重新启动故障节点6385
#redis-server conf/redis-6385.conf
2)6385节点启动后发现自己负责的槽指派给另一个节点,则以现 有集群配置为准,变为新主节点6386的从节点
# I have keys for slot 4096, but the slot is assigned to another node. Setting it to
importing state.
# Configuration change detected. Reconfiguring myself as a replica of 475528b1bcf
8e74d227104a6cf1bf70f00c24aae
3)集群内其他节点接收到6385发来的ping消息,清空客观下线状态:
==> redis-6379.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
==> redis-6380.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
==> redis-6382.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
==> redis-6383.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
==> redis-6386.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
4)6385节点变为从节点,对主节点6386发起复制流程
==> redis-6385.log <==
* MASTER <-> SLAVE sync: Flushing old data
* MASTER <-> SLAVE sync: Loading DB in memory
* MASTER <-> SLAVE sync: Finished with success
5)最终集群状态:
1)Redis集群数据分区规则采用虚拟槽方式,所有的键映射到 16384个槽中,每个节点负责一部分槽和相关数据,实现数据和请求的 负载均衡。
2)搭建集群划分三个步骤:准备节点,节点握手,分配槽。可以使用redis-trib.rb create命令快速搭建集群。
3)集群内部节点通信采用Gossip协议彼此发送消息,消息类型分 为:ping消息、pong消息、meet消息、fail消息等。节点定期不断发送和 接受ping/pong消息来维护更新集群的状态。消息内容包括节点自身数据和部分其他节点的状态数据。
4)集群伸缩通过在节点之间移动槽和相关数据实现。扩容时根据 槽迁移计划把槽从源节点迁移到目标节点,源节点负责的槽相比之前变 少从而达到集群扩容的目的,收缩时如果下线的节点有负责的槽需要迁 移到其他节点,再通过cluster forget命令让集群内其他节点忘记被下线 节点。
5)使用Smart客户端操作集群达到通信效率最大化,节点接收到键命令时会判断相关的槽是否由自身节点负责,如果不是则返回重定向信息。重定向分为MOVED和ASK, ASK说明集群正在进行槽数据迁移,客户端只在本次请求中做临时重定向,不会更新本地槽缓存。MOVED重定向说明槽已经明确分派到另一 个节点,客户端需要更新槽节点缓存。
6)集群自动故障转移过程分为故障发现和故障恢复。节点下线分为主观下线和客观下线,当超过半数主节点认为故障节点为主观下线时标记它为客观下线状态。从节点负责对客观下线的主节点触发故障恢复流程,保证集群的可用性。
下面的文章我们会讲开发和运维集群过程中常见问题包括:超大规模集群带宽消耗,pub/sub广播问题,集群节点倾斜问题,手动故障转移,在线迁移数据等。还有redis其他常见的一些运维问题;