Redis Cluster
Redis Cluster是Redis提供的分布式解决方案,有效地解决了Redis单机内存、并发、流量等瓶颈限制,实现数据负载均衡。
Redis数据分区
Redis Cluster采用哈希分区规则,常见的哈希分区规则有以下三种:
- 节点剩余分区:使用特定的数据,例如键或者ID,再根据节点数量N来计算hash(key)%N的哈希值,用来决定分配到哪个节点上,当节点数量发生改变,映射关系需要重新计算
- 一致性哈希分区:为系统中每个节点分配一个token,这些token构成一个哈希环。数据读写执行节点查找时,现根据key计算hash值,然后顺时针找到第一个大于等于该hash的token节点
- 虚拟槽分区:Redis采用的虚拟槽分区,它使用分散度良好的哈希函数把所有数据都映射到一个固定范围的整数集合中,整数定义为槽(slot),槽是集群内数据管理和迁移的基本单位
Redis Cluster槽的范围在0~16383,其计算公式为slot=CRC16(key)&16383,每一个节点负责维护部分槽以及槽其中映射的键值数据。Redis虚拟槽分区的特点如下:
- 解耦数据和节点之间的关系,简化了节点增减的难度
- 节点自身维护槽的映射关系,不需要客户端和代理服务去维护分区
- 支持节点、槽、键之间的映射查询
当然,Redis Cluster也存在一些功能限制,在使用之前需要对其进行了解。
- 批量操作key支持有限,目前只支持具有相同slot值得key执行批量操作
- 事务操作支持有限,只支持同一节点上对多个key执行事务操作
- key作为数据分区的最小粒度,不能将hash或list映射到不同节点
- 不支持多个数据库,只能使用db 0
- 复制结构只支持一层,不允许树状结构复制
搭建Redis Cluster
Redis Cluster一般由多个节点组成,节点数量至少保持6个才能作为完整的高可用集群。每个节点都需要在参数中开启cluster-enabled yes,让redis运行在cluster模式下。
在config参数文件中加入集群参数
cluster-enabled yes |
启动节点实例
[root@t-luhx03-v-szzb conf]# redis-server /service/redis/conf/redis-6379.conf\ |
第一次启动时如果没有集群配置文件,它会按照cluster-config-file指定的名称创建一份,当集群内节点通信信息发生变化,节点会自动保存集群状态到配置文件中。配置文件中保存了一个40位16进制的字符串作为节点ID,节点ID和运行ID不同的是节点ID是在初始化时分配的,重启并不会改变。
在节点启动后,节点之间并没有关联起来形成一个集群,需要通过Gossip协议彼此通信,由客户端发起cluster meet {ip} {port}
10.0.139.161:6379> cluster meet 10.0.139.161 6380 |
在建立Gossip协议通信后,集群目前还不能正常使用,还需要进行分配槽。分配槽可以通过cluster addslots命令分配指定slot
[root@t-luhx01-v-szzb ~]# redis-cli -h 10.0.139.161 -p 6379 -a 'Abcd123#' cluster addslots {0..5461} |
查看集群状态
10.0.139.161:6379> cluster info |
查看槽的分配情况
10.0.139.161:6379> cluster nodes |
目前分配了槽的只有三个节点,还有三个节点没有使用,我们可以让它们从节点去复制主节点槽信息和相关数据,提供故障转移功能。通过cluster replicate {nodeId}命令可以让一个节点成为从节点
10.0.139.161:6380> cluster replicate 441573a36248d4c3977946632523b3b95a7850ad |
查看集群信息
10.0.139.163:6380> cluster nodes |
redis-trib.rb
redis-trib.rb是采用ruby开发的Redis Cluster管理工具,它能简化集群创建、检查、槽迁移和均衡等常见维护任务,使用之前需要安装Ruby环境
[root@t-luhx01-v-szzb Redis]# tar -xvf ruby-2.7.0.tar.gz |
安装Ruby Redis依赖
[root@t-luhx01-v-szzb Redis]# gem install -l redis-4.1.3.gem |
安装redis-trib.rb
[root@t-luhx01-v-szzb src]# scp /media/Redis/redis-4.0.9/src/redis-trib.rb /usr/local/redis/bin/ |
创建群集
启动Redis节点后,使用redis-trib create命令完成握手和槽分配。
redis-trib.rb create --replicas 1 10.0.139.161:6379 10.0.139.161:6380 10.0.139.162:6379 10.0.139.162:6380 10.0.139.163:6379 10.0.139.163:6380 |
–replicas参数用于指定集群中每个主节点配备几个从节点,这里设置为1。redis-trib会尽可能的保证主从不在同一台机器下面,因此会重新排序节点顺序,执行过程中会询问是否同意主从安排计划。
集群完整性检查
集群完整性指所有的槽都分配到存活节点上,只要有一个槽未分配则表示群集不完整。
[root@t-luhx01-v-szzb conf]# redis-trib.rb check 10.0.139.161:6379 |
需要注意的是如果集群设置了AUTH认证,而redis-trib.rb不支持显示输入密码,直接执行会报[ERR]Sorry, can’t connect to node,这种情况我们可以修改文件client.rb中的password参数
[root@t-luhx01-v-szzb conf]# vi /usr/local/ruby/lib/ruby/gems/2.7.0/gems/redis-4.1.3/lib/redis/client.rb |
查看集群信息
[root@t-luhx01-v-szzb conf]# redis-trib.rb info 10.0.139.161:6379 |
修复集群
目前fix可以修复两种异常:
- 节点中存在处于迁移状态(importing或migrating)的slot
- 节点中存在未分配的slot
[root@t-luhx01-v-szzb conf]# redis-trib.rb fix 10.0.139.161:6379
扩容集群
添加节点
redis-trib.rb add-node new_host:new_port existing_host:existing_port --slave --master-id <arg> |
- new_host:new_port表示新加入的节点IP和端口
- existing_host:exsiting_port表示集群已经存在的任意节点
- –slave –master-id表示添加从节点,并指定master的nodeID,添加从节点时,这两个参数应该在参数列表的头部
正式环境建议采用redis-trib.rb addnode的命令,因为它会执行新节点状态检查,如果新节点已加入其它集群或者非空,则放弃集群加入操作,如果手动执行cluster meet加入已经存在于其它集群的节点,会造成被加入节点的集群合并到现有集群的情况,从而造成数据丢失和错误,后果严重。
加入集群的节点因为还未分配槽,无法接收任何读写操作,因此我们可以选择迁移slot到新节点上或者作为其它主节点的从节点负责故障转移。
加入新节点后3个主节点变为4个主节点,按照平均分配每个主节点应当拥有4096个槽。关于槽迁移的步骤为:
- 对目标节点发送cluster setslot {slot} importing {sourceNodeId}命令,让目标端准备导入槽的数据
- 对源节点发送cluster setslot {slot} migrating {targerNodeId}命令,让源节点准备迁出槽的数据
- 源节点循环执行cluster getKeysinslot {slot} {count}命令获取count个属于{slot}槽的数据
- 在源节点执行migrate {targetIp} {targetProt} “” 0 {timeout} keys {keys…}命令,把获取的键通过pipeline批量迁移到目标节点,在Redis 3.0.6版本之前只能单个键迁移。
- 重复步骤3和步骤4,直到slot下面的键都迁移完成
- 向集群内所有节点发送cluster setslot {slot} node {targetNodeId}命令,通知槽分配给目标节点。
当然我们也可以使用redis-trib.rb来完成slot迁移工作,具体语法如下:
redis-trib.rb reshard [host]:[port] --from [arg] --to [arg] --slots [arg] --yes --timeout [arg] --pipeline [arg] |
- host:port :可以设置为集群内任意节点
- from:源节点ID,多个值用逗号分隔,如果为all则表示除目标节点外的所有主节点
- to:目标节点ID
- slot:迁移槽的数量
- yes:迁移无需手动确认
- timeout:控制每次migrate的超时时间,默认为60000毫秒
- pipeline:控制每次批量迁移的key数量,默认为10
删除节点
如果有节点要下线,需要先查看下线节点是否有负责的槽,如果还存在分配的槽需要先通过reshard将槽迁移到其它节点,当整个节点的槽完全迁移出去,其对应的slave节点也会随之更新为目标节点的slave,因此建议先下线从节点再下线主节点。
在将节点上的槽都迁移出去后,通过cluster forget {NodeID}命令通知集群下线该节点,下线节点60秒内不会再接收到Gossip消息,超过60秒会再次参与消息交换。生产环境不建议通过cluster forget来下线,可以采用redis-trib.rb del-node {host:port} {downNodeID}命令来进行下线。
节点slot均衡
redis-trib.rb rebalance host:port --weight <arg> --auto-weights --use-empty-masters --timeout <arg> --simulate --pipeline <arg> --threshold <arg> |
- –weight
:节点的权重,格式为nodeID=weight,多个节点则需要设置多个参数,默认权重为1 - –auto-weights:自动将每个节点的权重设置为1,会覆盖weight参数
- –threshold
:只有节点需要迁移的slot的数量超过threshold,才会执行rebalance - –use-empty-masters:默认没有分配slot的节点是不参与rebalance的,如果要让其参与,需要添加该参数
- timeout
:设置migrate命令的超时时间 - simluate:设置该参数,只会输出要迁移的slot,并不会真正执行迁移
- pipeline
:定义cluster getKeysinslot命令一次获取的key数量,默认为10
在所有节点上执行命令
redis-trib.rb call host:port command arg arg |
数据迁移
当需要把单机redis数据迁移到集群环境,redis-trib.rb提供了导入功能
redis-trib.rb import host:port --from <arg> --copy --replace |
redis-trib.rb import命令内部采用批量scan和migrate的的方式迁移数据,这种方式只能单节点向集群环境导入,不支持在线迁移数据,不支持定点续传,单线程迁移数据过慢。这里更推荐redis-migrate-tool。
故障转移
故障发现
Redis集群内节点通过PING/PONG消息实现节点通信,消息内封装了节点信息,同步状态等信息。故障发现就是通过消息传播实现的,也包含主观下线(pfail)和客观下线(fail)。
- 主观下线:指某个节点任务另一个节点不可用,只能代表一个节点的意见,并非完全准确
- 客观下线:集群内多个节点都认为一个节点不可用,对节点进行下线判定达成共识,如果下线节点有slot,则需要进行从节点故障转移。
在cluster-node-time * 2的时间内未收到半数以上槽节点的下线报告,那么之前的下线报告将过期,当主管下线上报速度赶不上下线报告过期的速度,则故障转移群会失败,因此cluster-node-time不建议设置的过小。
需要注意的是,尽管存在消息传递机制,但是在出现网络分区的情况下,可能会形成一个大群集和一个小集群,大集群超过半数能够完成客观下线,但小集群内无法收到fail消息,如果主从节点都在小集群内则无法完成故障转移,因此在规划时应当避免这种情况。
故障恢复
故障节点客观下线后,如果下线节点是有slot槽的主节点,则需要从它的从节点中选出一个进行故障转移,从而保证集群高可用。故障转移的步骤如下:
- 资格检查:每个从节点都需要检查最后和主节点断线的时间,判断是否有资格成为新的主节点。如果短线时间超过cluster-node-time * cluster-slave-validity-factor,则当前从节点不具备资格。cluster-slave-validity-factor为冲节点有效因子,默认为10
- 准备选举:当节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。集群采用延迟触发机制通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量最大,同步延迟就越低,就应该有更高的优先级来做候补主节点。
- 发起选举:当从节点定时任务检测到达故障选举时间(failover_auth_time)后,发起选举。选举流程主要包括更新配置纪元和广播选举消息。
纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元标识当前主节点的版本,所有主节点的纪元都不相等,从节点会复制主节点的纪元。整个群集又维护一个全局的配置纪元,用于记录集群内所有主节点配置纪元的最大版本。配置纪元会跟随PING/PONG消息传播,如果发送方和接收方都是主节点并且纪元冲突了,nodeID大的一方会递增全局纪元并赋值给当前节点。配置纪元的作用主要是:(1)标识主节点的不同版本和集群最大的版本 (2)每次出现新的主节点,都会递增全局配置纪元并赋值给主节点,用于记录关键事件 (3)主节点具有更大的配置纪元代表了更新的集群装填,因此节点进行PING/PONG消息时,如出现slot信息不一致时,已配置纪元更大的为准。
在集群内广播选举消息,并记录已发送消息的状态,保证从节点在一个配置单元内只能发起一次选举 - 选举投票:只有持有slot的主节点才会处理故障选举,每个主节点一张票,当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,从节点需要获得N/2+1的选票
- 替换主节点:从节点获得足够的票数后,取消复制成为主节点,并把故障主节点的slot委派给自己。向集群广播自己的PONG消息,通知所有节点当前已经成为主节点并接管了故障主节点。
手动故障转移
集群提供了手动故障转移功能,由从节点执行cluster failover命令发起转移流程,主节点角色进行切换,从节点晋升为主节点对外提供服务,旧主节点成为新主节点的从节点。主节点转移后新的从节点由于之前没有缓存主节点信息无法使用部分复制功能,所以会发起全量复制,消耗CPU和网络资源,需谨慎使用。
cluster failover命令提供了两个参数:force和takeover。force用于主节点宕机无法自动完成故障转移的情况,从节点直接发起选举,不再跟主节点确认复制偏移量,复制延迟的部分数据丢失;takeover用于集群超过一半以上主节点故障的场景,因为从节点无法收到半数以上的投票,所以无法完成选举工作,从节点不再进行选举过程而是直接更新本地配置纪元并替换主节点,可能会存在纪元冲突,到时会以nodeID较大的为准,部分数据丢失。
Tips:故障发现到完成转移期间整个集群都是不可用状态,建议将参数cluster-require-full-coverage设置为0,当主节点故障时只影响它负责的slot数据,不影响其它节点
附录
节点通信
在分布式存储中需要维护节点元数据信息,即节点负责哪些数据,是否故障等信息,Redis Cluster采用P2P的Gossip协议,其工作原理就是节点之间不断通信交换信息。其通信过程如下:
- 集群中的节点都开辟个TCP通道,用于节点之间通信,通信端口在PORT上增加10000
- 每个节点在固定周期内通过特定规则选择几个节点发送PING
- 接收到PING的节点回复PONG作为响应
常用的Gossip消息可以分为:ping、pong、meet、fail消息等。ping消息用于检测检点是否在线,节点之间交换信息;meet消息用于通知节点加入;pong消息用于回复确认ping消息、meet消息,并且封装了自身的状态信息。Fail消息即下线通知,接收到的节点都将其标记为下线。
所有的的消息都包含消息头和消息体,其中消息头包含了节点自身的状态信息,接收节点根据消息头就能获取到发送消息的节点信息,其结构如下:
typedef struct { |
虽然Gossip协议能够实现分布式,但由于内部需要频繁进行节点信息交换,而PING/PONG消息都会封装自身节点的状态数据,其负担也是有成本的。Redis Cluster内部采用每秒执行10次的固定频率,选择部分节点进行消息交换。
选择节点的规则就是每秒会选取五个节点找出最久没有通信的节点发送PING消息,每100毫秒都会扫描本地节点列表,如果发现节点最近一次接收PONG消息的时间大于cluster_node_timeout/2,则立即发送PING命令,防止节点太长时间未更新,因此每秒需要发送PING消息的数量 = 1 + 10 * num(node.pong_received > cluster_node_timeout/2),其中cluster_node_timeout参数影响较大。
请求路由
在集群环境下,Redis接收任何键相关的请求都会先计算键对应的槽,再根据槽找出对应的节点,如果是当前节点就处理命令,否则回复MOVED重定向错误,通知客户端请求正确的节点,这个过程称之为MOVED重定向。在使用redis-cli时,可以加入-c参数支持自动重定向,redis-cli会自动帮我们连接到正确的节点执行命令。
当slot正在从源节点迁移到目标节点时,客户端需要智能识别通过ASK重定向保证命令能够正常执行。
- 客户端根据本地slots缓存发送命令到源节点,如果存在则直接执行并返回客户端
- 如果键不存在源节点,那可能就存在于目标节点,这时源节点会回复ASK重定向异常
- 客户端从ASK重定向异常提取目标节点信息,发送asking命令到目标节点打开客户端连接标识,再执行键命令
ASK和MOVED都是重定向控制,但ASK重定向说明集群正在进行slot迁移,客户端不知道什么时候完成,因此不会更新slots缓存,而MOVED是非常明确的说明对应的槽的节点,因此需要更新slots缓存。
当执行mget、mset等批量操作时,slot迁移期间由于无法保证在统一节点,会导致大量错误,因此需要用pipeline批量执行时捕获重定向信息并连接到目标节点执行。
计算键的槽是根据键的有效部分使用CRC16函数计算出散列值,再取对16383的余数,其中如果键包含大括号字符,则计算槽的有效部分是括号内的内容,括号内的内容又叫hash_tag,它提供不同的键可以具备相同slot的功能,例如我们通过mget执行批量操作时,键列表必须具有相同的slot,否则会报错,这时就可以利用hash_tag让他不同的键具有相同的slot。hash_tag同样也适用于pipeline
10.0.139.161:6379> mget user:26:lu user:26:heng |
smart客户端
smart客户端通过在内部维护slot-node的映射关系,本地就可以实现键到节点的查找,从而保证IO效率的最大化,MOVED重定向负责协助smart更新slot-node映射。
1、jedisCluster初始化时会选择一个运行节点,通过cluster slots命令初始化槽和节点的映射关系
10.0.139.161> cluster slots |
2、jedisCluster解析cluster slots结果缓存在本地,并为每个节点创建唯一的jedisPool连接池,映射关系在JedisClusterInfoCache类中
public class JedisClusterInfoCache { |
3、JedisCluster执行键命令
public abstract class JedisClusterCommand<T> { |
整个执行过程如下:
- 计算slot并根据slots缓存获取目标节点连接,发送命令
- 如果出现连接错误,使用随机连接重新执行命令
- 捕获到MOVED重定向错误,使用cluster slots命令更新slots缓存
- 重复执行步骤1到步骤3,直到命令执行成功或者redirections<=0抛出异常
Jedis建议使用2.8.2以上的版本,防止cluster slots风暴和写锁阻塞的问题。
4、JedisCluster初始化
public JedisCluster(Set<HostAndPort> jedisClusterNode,int connectionTimeout,int soTimeout,int maxAttempts,final GenericObjectPoolConfig poolconfig) |
- Set
:所有Redis Cluster节点信息,也可以是一部分,自动通过slots发现 - int connectionTimeout:连接超时
- int soTimeout:读写超时
- int maxAttempts:重试次数
- GenericObjectPoolConfig:连接池参数
Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>(); |
JedisCluster包含了所有节点的连接池,建议使用单例。JedisCluster一般不需要执行close操作,内部会执行destroy操作
5、多节点命令操作
Redis Cluster虽然提供了分布式,但是有些命令操作需要遍历所有的节点才能完成。下面是一个删除指定模式的功能代码
public void delRedisbyPattern(JedisCluster jedisCluster,String pattern,int scanCounter) { |
集群倾斜
为避免集群倾斜,应当遵循以下规则:
- 不要使用热键做hash_tag,避免映射到同一个槽
- 合理设计键,对于大集合对象进行拆分
- slot分配不均衡时可以通过redis-trib.rb info查看并用redis-trib.rb rebalance均衡一下
读写分离
集群从节点默认不支持读写请求,如果需要从节点分担读压力,可以设置readonly命令打开客户端只读状态,slave-read-only参数不生效,readonly是会话级别的,每次连接都需要设置,取消只读状态则执行readwrite关闭连接只读状态
集群模式做读写分离,同样会发生数据延迟,读到过期数据,从节点故障等情况,针对从节点故障,客户端需要维护可用节点列表,提供了命令cluster slaves {nodeID}返回对应的所有从节点信息,读写分离成本较高,可以直接横向扩展,所以通常不建议集群模式下做读写分离
参考链接
[1] 《Redis开发与运维》