【Redis】集群
Redis Cluster是Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。
数据分布
数据分布理论
分布式数据库会把整个数据集按照分区规则映射到多个节点上,即把数据集划分到多个节点上,每个节点负责整体数据的一个子集。
常见的分区规则有哈希分区和顺序分区两种。区别如下表所示:
分区方式 | 特点 |
---|---|
哈希分区 | 离散度好;数据分布与业务无关;无法顺序访问 |
顺序分区 | 离散度易倾斜;数据分布与业务有关;可顺序访问 |
Redis Cluster采用的是哈希分区规则,下面重点讨论哈希分区,常见的哈希分区规则有以下几种:
- 节点取余分区
使用特定的数据,如Redis的键或者用户id,根据节点数量N使用公式:hash(key) % N 计算出哈希值,用来决定数据映射到哪一个节点上。
这种方式存在一个问题:当节点数量变化时,如扩容或收缩节点,数据节点映射关系需要重新计算,会导致数据的重新迁移。
节点取余分区使用简单,常用于数据库的分库分表规则,一般采用预分区的方式,提前规划好分区数,扩容时采用翻倍扩容,避免数据映射全部被打乱导致全量迁移的情况。
- 一致性哈希分区
一致性哈希分区(Distributed Hash Table)实现思路是为系统的每个节点分配一个token,范围一般在0 ~ 2^32,这些token构成一个哈希环。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点。
一致性哈希分区相比节点取余最大的好处在于新增或删除节点只影响哈希环中相邻的节点,对其他节点没有影响。但是一致性哈希分区存在以下几个问题:
- 加减节点会造成哈希环中部分数据无法命中,需要手动处理或忽略这部分数据,因此一致性哈希常用于缓存场景
- 当使用少量节点时,节点变换将大范围影响哈希环数据映射,因此不适合少量数据节点的分布式方案
- 普通的一致性哈希分区在增减节点时需要增加一倍或减少一半节点才能保证数据和负载的均衡
- 虚拟槽分区
虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。这个范围一般远大于节点数,比如Redis Cluster槽范围是0 ~ 16383。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展。每个节点会负责一定数量的槽。
Redis数据分区
Redis Cluster采用虚拟槽分区,所有的键根据哈希函数映射到0 ~ 16383整数槽内,计算公式:
slot = CRC16(key) & 16383
每一个节点负责维护一部分槽以及槽所映射的键值数据。
Redis虚拟槽分区特点如下:
- 解耦数据和节点之间的关系,简化了节点扩容和收缩难度
- 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据
- 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景
集群功能限制
Redis集群相对单机在功能上存在一些限制:
- key批量操作支持有限。如mset/mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mget、mset等操作可能存在于多个节点上因此不被支持。
- key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
- key作为数据分区的最小粒度。
- 不支持多数据库空间。单机下Redis可以支持16个数据库,集群模式下只能使用一个数据库即db0。
- 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
搭建集群
搭建集群分为以下三个步骤:
- 准备节点
- 节点握手
- 分配槽
准备节点
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
Redis节点第一次启动时如果没有配置文件则会自动创建,文件名由cluster-config-file参数项控制,建议使用node-{port}.conf格式定义,使用端口号区分不同节点,防止同一机器侠多个节点彼此覆盖,造成集群信息异常。如果启动时存在集群配置文件,节点会使用配置文件内容初始化集群信息。启动过程如下图所示:
集群文件中记录着集群的状态信息,其中最重要的是节点Id,是一个40位16进制的字符串,用于唯一标识集群内的节点,节点Id重启不会变化。
节点握手
节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信,达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命令:cluster meet {ip} {port}
,cluster meet是一个异步命令,执行后立刻返回。节点内部发起与目标节点进行握手通信,如下图所示:
- 节点6379本地创建6380节点信息对象,并发送meet消息
- 节点6380接收到meet消息后,保存6379节点信息并回复pong消息
- 之后节点6379与6380彼此定期通过ping/pong消息进行节点通信
只需要在集群内任意节点执行cluster meet命令加入新节点,握手状态会通过消息在集群内传播,这样其他节点会自动发现新节点并发起握手流程。最后使用cluster nodes命令确认6个节点彼此感知并组成集群。
分配槽
在节点建立握手后,集群还不能正常工作,集群处于下线状态,数据读写被禁止。使用cluster info命令可以获取集群当前状态。只有当16384个槽全部分配给节点后,集群才进入在线状态。
Redis把所有的数据映射到16348个槽中。每个key会映射为一个固定的槽,只有当节点分配了槽,才能响应和这些槽关联的命令。
通过cluster addslots命令为节点分配槽。利用bash特性批量设置槽命令如下:
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命令可查看集群状态。
集群模式下,Reids节点角色分为主节点和从节点。使用cluster replicate {nodeId} 命令让剩余三个节点分别成为从节点,nodeId是要复制主节点的节点Id。
Redis官方另外提供redis-trib.rb工具方便快速搭建集群
节点通信
通信流程
Redis集群采用P2P的Gossip(流言)协议维护节点信息等元数据信息。Gossip协议的工作原理是节点彼此不断通信交换信息,一段时间后所有节点就会知道集群完整信息,这种方式类似于流言传播。
通信过程如下:
- 集群中每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口上加10000
- 每个节点在固定周期内通过特定规则选择几个节点发送ping信息
- 接收到ping信息的节点用pong消息响应
Gossip消息
Gossip协议的主要职责是信息交换。消息交换的载体是节点彼此发送的Gossip消息。
常用的Gossip消息可分为:ping消息、pong消息、meet消息、fail消息等。它们之间的通信模式如下图所示:
- meet消息:用于通知新节点加入。
- ping消息:用于检测节点是否在线和交换彼此状态信息,ping消息发送封装了自身节点和其他节点的状态信息。ping消息是集群内交换最频繁的消息。
- pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
- fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到后把对应节点更新为下线状态。
所有的消息格式划分为:消息头和消息体。消息头中包含发送节点关键数据,如节点id、槽映射、节点标识(主从角色,是否下线)等。
集群伸缩
扩容集群
Redis集群扩容操作分为三步:
- 准备新节点
- 加入集群
- 迁移槽和数据
准备新节点
redis-server conf/redis-6385.conf
redis-server conf/redis-6386.conf
新启动的节点作为孤儿节点运行,并没有与其他节点通信。
加入集群
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消息通信后,所有节点会发现新节点并保存状态到本地。新节点由于没有分配槽所以还不能接受读写操作。
迁移槽和数据
迁移计划需要确保每个节点负责相似数量的槽,从而保证各个节点数据均匀分布。
数据迁移过程是逐个槽进行的,流程如下:
- 对目标节点发送
cluster setslot {slot} importing {sourceNodeId}
命令,让目标节点准备导入槽的数据。 - 对源节点发送
cluster setslot {slot} migrating {targerNodeId}
命令让源节点准备迁出槽数据。 - 源节点循环执行
cluster getkeysinslot {slot} {count}
命令,获取count个属于槽{slot}的键。 - 在源节点上执行
migrate {targetIp} {targetPort} "" 0 {timeout} keys {keys...}
命令,把获取的键通过流水线(pipeline)机制批量迁移到目标节点。 - 重复步骤3、4直到槽下所有键值数据迁移到目标节点。
- 向集群内所有主节点发送
cluster setslot {slot} node {targetNodeId}
命令,通知槽分配给目标节点。
迁移流程实际操作时涉及大量槽并且每个槽对应非常多的键。因此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提供cluster forget {downNodeId}
命令让集群内所有节点忘记下线的节点。当节点收到此命令后,会把nodeId指定的节点加入到禁用列表,在禁用列表的节点不再发送Gossip消息。禁用列表有效期是60秒。
线上操作cluster forget命令下线节点过于繁琐,建议使用redis-trib.rb del-node {host:port} {downNodeId}
命令。
请求路由
请求重定向
集群模式下,Redis接收任何键相关的命令时首先要计算键对应的槽,再根据槽找到所对应的节点。如果节点是自身,则处理命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为MOVED重定向。
使用cluster keyslot {key}
命令可以获得key对应的槽。
键命令执行分为两步:
- 计算槽:根据key的有效部分使用CRC16函数计算出散列值,再取对16383的余数
- key有效部分默认是整个key,可以使用hash_tag方式({}包含)指定有效部分
- 槽节点查找:在集群信息中查找槽对应的节点
Smart客户端
大多数开发语言的Redis客户端都采用Smart客户端支持集群协议,通过在内部维护slot -> node的映射关系,本地实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot -> node映射。
ASK重定向
客户端ASK重定向流程
Redis集群支持在线迁移槽(slot)和数据来完成水平伸缩,当slot对应的数据从源节点到目标节点迁移过程中,客户端需要做到智能识别,保证键命令可正常执行。例如当一个slot数据从源节点迁移到目标节点时,期间可能出现一部分数据在源节点,而另一部分在目标节点,当出现上述情况时,客户端键命令执行流程将发生变化,如下所示:
- 客户端根据本地slots缓存发送命令到源节点,如果存在键对象则直接执行并返回结果给客户端。
- 如果键对象不存在,则可能存在于目标节点,这时源节点会回复ASK重定向异常。格式如下:(error) ASK {slot} {targetIP}:{targetPort}。
- 客户端从ASK重定向异常提取出目标节点信息,发送asking命令到目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不存在则返回不存在信息。
节点内部处理
为了支持ASK重定向,源节点和目标节点在内部结构中维护当前正在迁移的槽信息,用于识别槽迁移情况。
故障转移
故障发现
集群故障发现通过消息传播机制实现,主要环节包括:主观下线(pfail)和客观下线(fail)。
主观下线
主观下线指某个节点认为另一个节点不可用,存在误判的情况。
集群中每个节点都会定时向其他节点发送ping消息,接收节点回复pong消息。如果在cluster-node-timeout时间内通信一直失败,则发送节点认为接收节点存在故障,把接收节点标记为主观下线。
客观下线
客观下线指标记一个节点真正的下线,集群内多个节点认为该节点不可用。
当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群中传播。当半数以上持有槽的主节点都标记某个节点主观下线时,触发客观下线。
故障恢复
故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程:
- 资格检查
- 准备选举时间
- 发起选举
- 选举投票
- 替换主节点
资格检查
每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。参数cluster-slave-validity-factor用于从节点的有效因子,默认为10。
准备选举时间
当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。
发起选举
当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后,发起选举流程如下:
- 更新配置纪元:配置纪元是一个只增不减的整数,用来标示集群内每个主节点的不同版本和当前集群最大的版本。
- 广播选举消息:在集群内广播选举消息,并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。
选举投票
只有持有槽的主节点才会处理故障选举消息,每个持有槽的节点在一个配置纪元内都有唯一的一张选票。
Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。
当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作。
替换主节点
当从节点收集到足够的选票之后,触发替换主节点操作:
- 当前从节点取消复制变为主节点。
- 执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。
- 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。
故障转移时间
故障转移时间,如下:
failover-time(毫秒) <= cluster-node-timeout + cluster-node-timeout/2 + 1000
其中:
- 主观下线(pfail)识别时间 = cluster-node-timeout
- 主观下线状态消息传播时间 <= cluster-node-timeout / 2
- 从节点转移时间 <= 1000毫秒