分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题,即把数据集划分到多个节点上,每个节点负责整体数据的一个子集
需要重点关注的是数据分区规则。常见的分区规则有哈希分区
和顺序分区
两种
由于Redis Cluster采用哈希分区规则,这里我们重点讨论哈希分区,常见的哈希分区规则有几种,下面分别介绍。
hash(key) % N
计算出哈希值,用来决定数据映射到哪一个节点上。哈希环
。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点。Redis Cluster就是采用虚拟槽分区
。Redis Cluser采用虚拟槽分区,所有的键根据哈希函数映射到0~16383整数槽内,计算公式:slot=CRC16(key)&16383
。每一个节点负责维护一部分槽以及槽所映射的键值数据。
Redis虚拟槽分区的特点:
key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mset、mget等操作可能存在于多个节点上因此不被支持。
key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash、list等映射到不同的节点。
不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即db0。
复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
介绍完Redis集群分区规则之后,下面我们开始搭建Redis集群。搭建集群工作需要以下三个步骤:
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"
第一次启动时如果没有集群配置文件,它会自动创建一份,文件名称采用cluster-config-file
参数项控制,建议采用node-{port}.conf
格式定义,通过使用端口号区分不同节点,防止同一机器下多个节点彼此覆盖,造成集群信息异常。如果启动时存在集群配置文件,节点会使用配置文件内容初始化集群信息。
集群模式的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进制字符串,用于唯一标识集群内一个节点,之后很多集群操作都要借助于节点ID来完成。需要注意是,节点ID不同于运行ID。节点ID在集群初始化时只创建一次,节点重启时会加载集群配置文件进行重用,而Redis的运行ID每次重启都会变化。
在节点6380执行cluster nodes命令获取集群节点状态:
127.0.0.1:6380>cluster nodes
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 myself,master - 0 0 0 connected
每个节点目前只能识别出自己的节点信息。我们启动6个节点,但每个节点彼此并不知道对方的存在,下面通过节点握手让6个节点彼此建立联系从而组成一个集群。
节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信,达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命令:cluster meet {ip} {port}
在节点6379上执行cluster meet 127.0.0.1 6380
让节点6379和6380节点进行握手通信。cluster meet命令是一个异步命令,执行之后立刻返回。
1)节点6379本地创建6380节点信息对象,并发送meet消息。
2)节点6380接受到meet消息后,保存6379节点信息并回复pong消息。
3)之后节点6379和6380彼此定期通过ping/pong消息进行正常的节点通信。
这里的meet、ping、pong消息是Gossip协议通信的载体,之后的节点通信部分做进一步介绍,它的主要作用是节点彼此交换状态数据信息
。
对节点6379和6380分别执行cluster nodes
命令,可以看到它们彼此已经感知到对方的存在。
我们只需要在集群内任意节点上执行cluster meet命令加入新节点,握手状态会通过消息在集群内传播,这样其他节点会自动发现新节点并发起握手流程。
节点建立握手之后集群还不能正常工作,这时集群处于下线状态,所有的数据读写都被禁止。
通过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,由于目前所有的槽没有分配到节点,因此集群无法完成槽到节点的映射。只有当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集群模式下的主从复制使用了之前介绍的Redis复制流程,依然支持全量和部分复制。
目前为止,我们依照Redis协议手动建立一个集群。它由6个节点构成,3个主节点负责处理槽和相关数据,3个从节点负责故障转移。手动搭建集群便于理解集群建立的流程和细节,不过也从中发现集群搭建需要很多步骤,当集群节点众多时,必然会加大搭建集群的复杂度和运维成本。因此Redis官方提供了redis-trib.rb
工具方便我们快速搭建集群。
使用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
参数指定集群中每个主节点配备几个从节点,这里设置为1。
这里需要注意给redis-trib.rb的节点地址必须是不包含任何槽/数据的节点,否则会拒绝创建集群。
在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息。Redis集群采用Gossip协议,Gossip协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息。
通信过程说明:
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;
消息体在Redis内部采用clusterMsgData
结构声明。消息体clusterMsgData定义发送消息的数据。
union clusterMsgData {
/* ping,meet,pong消息体*/
struct {
/* gossip消息结构数组 */
clusterMsgDataGossip gossip[1];
} ping;
/* FAIL 消息体 */
struct {
clusterMsgDataFail about;
} fail;
// ...
};
其中ping、meet、pong都采用cluster MsgDataGossip
数组作为消息体数据,实际消息类型使用消息头的type
属性区分。每个消息体包含该节点的多个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消息时,执行解析消息头和消息体流程:
虽然Gossip协议的信息交换机制具有天然的分布式特性,但它是有成本的。由于内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。因此节点每次选择需要通信的节点列表变得非常重要。通信节点选择过多虽然可以做到信息及时交换但成本过高。节点选择过少会降低集群内所有节点彼此信息交换频率,从而影响故障判定、新节点发现等需求的速度。因此Redis集群的Gossip协议需要兼顾信息交换实时性和成本开销。
集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用于保证Gossip信息交换的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最近一次接受pong消息的时间大于cluster_node_timeout/2
,则立刻发送ping消息,防止该节点信息太长时间未更新。
cluster_node_timeout参数对消息发送的节点数量影响非常大。当我们的带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。过度调大cluster_node_timeout会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。
每个ping消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是myslots[CLUSTER_SLOTS/8] (节点负责的槽信息),占用2KB,这块空间占用相对固定。消息体会携带一定数量的其他节点信息用于信息交换。消息体携带数据量跟集群的节点数息息相关,更大的集群每次消息通信的成本也就更高,因此对于Redis集群来说并不是大而全的集群更好
Redis集群提供了灵活的节点扩容和收缩方案。在不影响集群对外服务的情况下,可以为集群添加节点进行扩容也可以下线部分节点进行缩容。
如下三个主节点(6379、6380、6381
)分别维护自己负责的槽和对应的数据,如果希望加入1个节点(6385
)实现集群扩容时,需要通过相关命令把一部分槽和数据迁移给新节点。
图中每个节点把一部分槽和数据迁移到新的节点6385,每个节点负责的槽和数据相比之前变少了从而达到了集群扩容的目的。理解集群的水平伸缩的上层原理:集群伸缩=槽和数据在节点之间的移动。
Redis集群扩容操作可分为如下步骤:
1)准备新节点。
2)加入集群。
3)迁移槽和数据。
需要提前准备好新节点并运行在集群模式下,新节点建议跟集群内的节点配置保持一致,便于管理统一。准备好配置后启动两个节点命令如下:
redis-server conf/redis-6385.conf
redis-server conf/redis-6386.conf
启动后的新节点作为孤儿节点运行,并没有其他节点与之通信。
新节点依然采用cluster meet命令加入到现有集群中。在集群内任意节点执行cluster meet命令让6385和6386节点加入进来,命令如下:
127.0.0.1:6379> cluster meet 127.0.0.1 6385
127.0.0.1:6379> cluster meet 127.0.0.1 6386
集群内新旧节点经过一段时间的ping/pong消息通信之后,所有节点会发现新节点并将它们的状态保存到本地。
新节点刚开始都是主节点状态,但是由于没有负责的槽,所以不能接受任何读写操作。
对于新节点的后续操作我们一般有两种选择:
redis-trib.rb工具也实现了为现有集群添加新节点的命令,还实现了直接添加为从节点的支持,命令如下:
redis-trib.rb add-node new_host:new_port existing_host:existing_port --slave --master-id <arg>
运维提示
正式环境建议使用redis-trib.rb add-node
命令加入新节点,该命令内部会执行新节点状态检查,如果新节点已经加入其他集群或者包含数据,则放弃集群加入操作。如果我们手动执行cluster meet命令加入已经存在于其他集群的节点,会造成被加入节点的集群合并到现有集群的情况,从而造成数据丢失和错乱,后果非常严重,线上谨慎操作。
加入集群后需要为新节点迁移槽和相关数据,槽在迁移过程中集群可以正常提供读写服务,迁移过程是集群扩容最核心的环节。
(1)槽迁移计划
槽是Redis集群管理数据的基本单位,首先需要为新节点制定槽的迁移计划,确定原有节点的哪些槽需要迁移到新节点。迁移计划需要确保每个节点负责相似数量的槽,从而保证各节点的数据均匀。例如,在集群中加入6385节点后,原有节点负责的槽数量从6380变为4096个。
(2)迁移数据
数据迁移过程是逐个槽进行的,流程说明:
cluster setslot {slot} importing {sourceNodeId}
命令,让目标节点准备导入槽的数据。cluster setslot {slot} migrating {targetNodeId}
命令,让源节点准备迁出槽的数据。cluster getkeysinslot {slot} {count}
命令,获取count个属于槽{slot}的键。migrate {targetIp} {targetPort} {timeout} {keys...}
命令,把获取的键通过流水线(pipeline)机制批量迁移到目标节点cluster setslot {slot} node {targetNodeId}
命令,通知槽分配给目标节点。为了保证槽节点映射变更及时传播,需要遍历发送给所有主节点更新被迁移的槽指向新节点。根据以上流程,我们手动使用命令把源节点6379负责的槽4096迁移到目标节点6385中,流程如下:
1)目标节点准备导入槽4096数据:
127.0.0.1:6385>cluster setslot 4096 importing cfb28ef1deee4e0fa78da86abe5d24566744411e
OK
2)源节点准备导出槽4096数据:
127.0.0.1:6379>cluster setslot 4096 migrating 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756
OK
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"
批量迁移这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
转向错误,ASK转向负责引导客户端找到数据所在的节点。
127.0.0.1:6379> mget key:test:5028 key:test:68253 key:test:79212
(error) ASK 4096 127.0.0.1:6385
通知所有主节点槽4096指派给目标节点6385(6385的nodeId为1a205dd8b2819a00dd1e8b6be40a8e2abe77b756):
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
实际操作时肯定涉及大量槽并且每个槽对应非常多的键。因此redis-trib提供了槽重分片功能
,命令如下:
redis-trib.rb 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-trib.rb rebalance
命令检查节点之间槽的均衡性
收缩集群意味着缩减规模,需要从现有集群中安全下线部分节点。
流程说明:
1)首先需要确定下线节点是否有负责的槽,如果是,需要把槽迁移到其他节点,保证节点下线后整个集群槽节点映射的完整性。
2)当下线节点不再负责槽或者本身是从节点时,就可以通知集群内其他节点忘记下线节点,当所有的节点忘记该节点后可以正常关闭。
由于集群内的节点不停地通过Gossip消息彼此交换节点状态,因此需要通过一种健壮的机制让集群内所有节点忘记下线的节点。也就是说让其他节点不再与要下线节点进行Gossip消息交换。Redis提供了cluster forget {downNodeId}
命令实现该功能。
当节点接收到cluster forget {down NodeId}命令后,会把nodeId指定的节点加入到禁用列表
中,在禁用列表内的节点不再发送Gossip消息。禁用列表有效期是60秒,超过60秒节点会再次参与消息交换。也就是说当第一次forget命令发出后,我们有60秒的时间让集群内的所有节点忘记下线节点。
线上操作不建议直接使用cluster forget命令下线节点,需要跟大量节点命令交互,实际操作起来过于繁琐并且容易遗漏forget节点。建议使用redis-trib.rb del-node {host:port} {downNodeId}
命令。
下线主节点具有从节点时需要把该从节点指向到其他主节点,因此对于主从节点都下线的情况,建议先下线从节点再下线主节点,防止不必要的全量复制。
本节介绍了Redis集群伸缩的原理和操作方式,它是Redis集群化之后最重要的功能,熟练掌握集群伸缩技巧后,可以针对线上的数据规模和并发量做到从容应对。
在集群模式下,Redis接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为MOVED重定向
。具体格式MOVED {slot} {ip} {port}
。
使用redis-cli命令时,可以加入-c
参数支持自动重定向,简化手动发起重定向操作。
可以借助cluster keyslot {key}
命令返回key所对应的槽
客户端键命令执行步骤主要分两步:计算槽,查找槽所对应的节点。下面分别介绍。
Redis首先需要计算键所对应的槽。根据键的有效部分使用CRC16函数
计算出散列值,再取对16383的余数,使每个键都可以映射到0~16383槽范围内。
如果键内容包含{
和}
大括号字符,则计算槽的有效部分是括号内的内容;否则采用键的全内容计算槽。这个叫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的门槛。
Redis计算得到键对应的槽后,需要查找槽所对应的节点。集群内通过消息交换每个节点都会知道所有节点的槽信息,内部保存在clusterState结构中。
根据MOVED重定向机制,客户端可以随机连接集群内任一Redis获取键所在节点,这种客户端又叫Dummy(傀儡)客户端,它优点是代码实现简单,对客户端协议影响较小,只需要根据重定向信息再次发送请求即可。但是它的弊端很明显,每次执行键命令前都要到Redis上进行重定向才能找到要执行命令的节点,额外增加了IO开销,这不是Redis集群高效的使用方式。正因为如此通常集群客户端都采用另一种实现:Smart(智能)客户端。
Smart客户端通过在内部维护slot→node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot→node映射。
我们以Java的Jedis为例,说明Smart客户端操作集群的流程。
1)首先在JedisCluster初始化时会选择一个运行节点,初始化槽和节点映射关系,使用cluster slots
命令完成。
2)JedisCluster解析cluster slots结果缓存在本地,并为每个节点创建唯一的JedisPool连接池。映射关系在JedisClusterInfoCache类中。
public class JedisClusterInfoCache {
private Map<String, JedisPool> nodes = new HashMap<String, JedisPool>();
private Map<Integer, JedisPool> slots = new HashMap<Integer, JedisPool>();
...
}
3)JedisCluster执行键命令的过程有些复杂,但是理解这个过程对于开发人员分析定位问题非常有帮助,部分代码如下:
public abstract class JedisClusterCommand<T> {
// 集群节点连接处理器
private JedisClusterConnectionHandler connectionHandler;
// 重试次数, 默认5次
private int redirections;
// 模板回调方法
public abstract T execute(Jedis connection);
public T run(String key) {
if (key == null) {
throw new JedisClusterException("No way to dispatch this command to
Redis Cluster.");
}
return runWithRetries(SafeEncoder.encode(key), this.redirections, false,
false);
}
// 利用重试机制运行键命令
private T runWithRetries(byte[] key, int redirections, boolean tryRandomNode,
boolean asking) {
if (redirections <= 0) {
throw new JedisClusterMaxRedirectionsException("Too many Cluster redi
rections");
}
Jedis connection = null;
try {
if (tryRandomNode) {
// 随机获取活跃节点连接
connection = connectionHandler.getConnection();
} else {
// 使用slot缓存获取目标节点连接
connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.
getSlot(key));
}
return execute(connection);
} catch (JedisConnectionException jce) {
// 出现连接错误使用随机连接重试
return runWithRetries(key, redirections - 1, true/*开启随机连接*/, asking);
} catch (JedisRedirectionException jre) {
if (jre instanceof JedisMovedDataException) {
// 如果出现MOVED重定向错误,在连接上执行cluster slots命令重新初始化slot缓存
this.connectionHandler.renewSlotCache(connection);
}
// slot初始化后重试执行命令
return runWithRetries(key, redirections - 1, false, asking);
} finally {
releaseConnection(connection);
}
}
}
键命令执行流程:
1)计算slot并根据slots缓存获取目标节点连接,发送命令。
2)如果出现连接错误,使用随机连接重新执行键命令,每次命令重试对redi-rections参数减1。
3)捕获到MOVED重定向错误,使用cluster slots命令更新slots缓存(renewSlotCache方法)。
4)重复执行1)~3)步,直到命令执行成功,或者当redirections<=0时抛出Jedis ClusterMaxRedirectionsException异常。
(1)JedisCluster的定义
Jedis为Redis Cluster提供了Smart客户端,对应的类是JedisCluster,它的初始化方法如下:
public JedisCluster(Set<HostAndPort> jedisClusterNode, int connectionTimeout, int
soTimeout, int maxAttempts, final GenericObjectPoolConfig poolConfig) {
...
}
其中包含了5个参数:
// 初始化所有节点(例如6个节点)
Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
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的使用需要注意以下几点:
(2)多节点命令和操作
Redis Cluster虽然提供了分布式的特性,但是有些命令或者操作,诸如keys、flushall、删除指定模式的键,需要遍历所有节点才可以完成。下面代码实现了从Redis Cluster删除指定模式键的功能:
// 从RedisCluster批量删除指定pattern的数据
public void delRedisClusterByPattern(JedisCluster jedisCluster, String pattern,
int scanCounter) {
// 获取所有节点的JedisPool
Map<String, JedisPool> jedisPoolMap = jedisCluster.getClusterNodes();
for (Entry<String, JedisPool> 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<String> scanResult = jedis.scan(cursor, params);
// 删除的key列表
List<String> 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;
}