redis之集群模式
redis 集群模式
redis集群是redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。
redis集群的优点
- 高可用: 节点故障时,能自动进行故障转移,保证服务的持续可用。
- 负载均衡: 工作负载能够被分发到不同的节点上,有效分摊单节点访问压力。
- 容灾恢复:通过主从复制、哨兵机制,节点故障时能够快速进行故障恢复
- 数据分片: 集群模式下,可以由多个主节点执行写入操作
- 易于扩展:可以根据业务需求和系统负载,动态的添加或减少节点,实现水平扩展。
集群的实现原理
一个redis集群通常由多个节点组成。在集群建立前,每个节点都是相互独立的,都处于一个只包含自己的集群中。在组件集群时,需要将每个独立的节点连接起来,构成一个包含多个节点的集群。
节点
一个节点就是一个运行在集群模式下的redis服务器,服务器在启动时回一句cluster-enabled
配置来决定是否开启服务器的集群模式。
1 | ./src/redis-server --cluster-enabled yes # 开启集群模式 |
运行在集群模式下的服务器,会继续使用所有在单机模式中使用的服务器组件。例如:
- 使用文件事件处理器来处理命令请求和返回命令回复。
- 使用时间事件处理器执行
serverCron
函数,在集群模式下继续调用clusterCron
函数执行集群模式下的常规操作。 - 继续使用数据库保存键值对数据。
- 继续使用
rdb
和aof
进行持久化。 - 继续使用发布订阅机制执行
publish、subscribe
命令。 - 继续使用复制模块进行节点的复制工作。
- 使用
lua
脚本环境来执行客户端输入的lua
脚本。
集群创建
在节点A
上执行cluster meet ip port
,其中ip|port
指向节点B
,可以让节点A
向节点B
进行握手,当握手成功时,节点A
就会将B
节点添加到A
节点所在的集群中。
假设目前有A|B|C
三个节点,分别对应<ip_a|port_a>、<ip_b|port_b>、<ip_c|port_c>
,在未组建集群前,可以理解为三个集群。如果此时需要将三个节点组成一个集群,则需要执行如下命令:
1 | ip_a:port_a>cluster meet ip_b port_b #将b节点加入到a节点所在集群中 |
在客户端中,可以通过cluster nodes
获取当前集群的节点信息。
1 | 192.167.98.52:6379> cluster nodes |
集群数据结构
clusterNode
每一个节点都会使用一个clusterNode
结构记录自身状态,并为集群中的其他节点创建一个相应的clusterNode
结构,以此记录其他节点的状态。
1 | typedef struct clusterNode { |
clusterNodeFailReport
用于记录与当前节点握手过的其他节点的下线报告。
1 | typedef struct clusterNodeFailReport { |
clusterState
clusterState
结构则记录了在当前节点视角下,集群目前所处的状态。如,集群状态、集群节点数量、分片
1 | typedef struct clusterState { |
cluster meet 命令执行过程
通过向节点A
发送cluster meet <ip_b> <port_b>
,可以将节点B
纳入到节点A
所在的集群中。
节点A
收到命令后,将与节点B
进行握手以确定彼此存在,并继续执行如下操作:
- 节点
A
为节点B
创建一个clusterNode
结构,并将该结构保存至自己的clusterState.nodes
字典中。 - 节点
A
依据cluster meet <ip_b> <port_b>
命令中的IP地址和端口号向节点B
发送一条MEET消息
。(后续会介绍消息格式) - 如果一切顺利,节点
B
接收到节点A
发送的meet
消息,节点B
会为节点A
创建一个clusterNode
结构,并将此结构添加到自己的clusterState.nodes
字典中。 - 节点
B
向节点A
发送PONG
消息。 - 如果节点
A
收到节点B
发送的PONG
消息,则节点A
可获悉节点B
已经收到自己发送的meet
消息。 - 节点
A
向节点B
返回一条PING
命令。 - 如果顺利,节点
B
收到节点A
返回的PING
消息,以此获悉节点A
已经成功接收到节点B
返回的PONG
消息。此时,握手完成。
节点A|B
集群组建过程的时序图如下:
槽分派
redis集群通过分片
的方式来保存数据库中的键值对,集群的整个数据库被分为16384
个槽位,数据库中的每个键都属于这些槽中的一个,集群中的每个节点可以处理0个或者16384
个槽位。
当数据库中的16384
个槽位都有节点在处理时,集群属于上线状态;否则,集群属于下线状态。
集群创建完毕后,可以使用如下命令将槽分配给节点a:
1 | ip_a:port_a>cluster addslots <slot> [slot ...] |
当16384
个槽位都有相应节点处理时,集群进入上线状态。可以使用cluster info
查看集群状态。
1 | 192.167.98.52:6379> cluster info |
相关命令实现原理
cluster addslots
该命令的实现可以理解为对clusterNode.slots
和clusterState.slots
的更新标注。
执行命令过程
当客户端向节点发送与数据库有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽位,并检查该槽位是否由当前节点处理。
- 如果键所在槽位指派给当前节点,当前节点直接执行命令。
- 否则,节点向客户端返回
MOVED
错误,指引客户端重定向到正确的节点,并再次发送待执行的命令。
1 | 192.167.98.52:6379> set mqray181162 hh |
通过cluster keyslot
可以查阅键所属的槽位:
1 | 192.167.76.255:6379> cluster keyslot mqray181162 |
前面通过cluster nodes
已获悉当前节点处理的槽位信息,可以看到
1 | 192.167.76.255:6379> cluster nodes |
可以看到,192.167.98.52:6379
节点负责处理的槽位区间为10923-16383
,接收到命令时,检测到当前命令操作的键所指向的节点为192.167.76.255:6379
后,返回了MOVED
错误,重定向后重新执行该命令。
实际上,一个集群客户端通常会维护多个节点的套接字连接,而所谓的重定向只是换一个套接字来发送命令。
key对应槽位的计算方式
使用cluster keyslot key
可以获取键所属槽位,其计算公式如下:
1 | slot = CRC16(key) % 16384 |
节点如何记录槽位
clusterNode.slots
clusterNode
中通过如下两个属性记录该节点负责处理的槽位:
1 | typedef struct clusterNode { |
其中slots
是一个二进制位数组,数组长度为16384/8=2048
字节。
reis以0为起始索引,对slots
数组中的16384
个二进制位进行编号,根据索引上的二进制位来判断当前节点是否需要处理槽i
。
对于一个节点而言,检查具体某一个槽位是否被当前节点处理的时间复杂度是O(1)。
而numslots
即为这个二进制数组中1的个数。
clusterState.slots
一个节点除了将自己所负责的槽位记录在slots
数组中,还会将自己的slots
数组通过消息的方式发送给集群中的其他节点,以通知其他节点,当前节点负责处理哪些槽位。
集群中的其他节点收到此消息,会更新该节点视角下的clusterState.nodes
更新其中传递该消息的节点所负责的槽位分配情况。
因此,集群中的每个节点都知道数据库中16384
个槽位的分配情况。
集群槽位分配记录
clusterState
结构中记录了如下信息:
1 | typedef struct clusterState { |
slots
数组长达16384
,记录了每个槽位被分配的节点clusterNode
的指针。
如果slots[i]==null
,则表示当前槽位尚未分配;如果指向某个clusterNode
,则表示当前槽位被分配给该节点。
为什么需要将节点槽位信息存储在 clusterNode中又需要存在 clusterState中?
如果只在clusterNode.slots
中记录,则无法高效处理如下情况:
- 检测槽位
i
是否被分配/检测槽位i
被分配给了哪个节点: 这两种场景需要遍历集群中的每个节点的clusterNode.slots
数组,直到找到该槽位被分配的节点。时间复杂度为O(N)
。而如果clusterState.slots
存储了,则时间复杂度为O(1)
。
那么是否可以只将槽位分派信息记录在clusterNode.slots
中呢?
由于redis槽位分配中,某节点的槽位分配会通过消息传递给集群中的其他节点,传递消息时只需要将clusterNode.slots
传递出去即可。而如果只存在clusterState.slots
中,那么每次传播槽位分派信息时,需要遍历clusterState.slots
以获取当前的槽位分派信息。
节点数据库的实现
节点和单机服务器在数据方面的区别是: 节点只能使用0号数据库,而单机服务器则没有此限制。
除了将键值对保存在数据库中,还会用clusterState。slots_to_keys
记录槽位和键之间的关系。注意,在当前版本中,这一结构被移动到clusterInit.slotToKeyInit
函数中。
1 | /* Initialize slots-keys map of given db. */ |
重新分片
redis集群的重新分片是指可以将任意数量的已经指派给某个节点A
(源节点)的槽位重新指派给节点B
(目标节点),并且相关槽位所属的键值对也会从源节点移动到目标节点。重新分片
操作过程中集群不需要下线,且源节点和目标节点可以继续处理命令请求。
重新分片的实现原理
集群中的重新分配操作是由redis的集群管理软件redis-trib
负责执行的。redis提供了进行重新分片所需要的命令,而redis-trib
通过向源目标节点发送命令来进行重新分片。redis-trib
对集群的单个槽位进行分片的过程如下:
另外注意到,clusterState
结构中记录了集群得重新分片信息。
1 | typedef struct clusterState { |
其中,migrating_slots_to
记录了当前节点正在迁移至其他节点的槽。而importing_slots_from
则记录了当前节点正在从哪些节点导入槽。
ASK错误
重新分片过程中,可能存在这样一种场景:
待迁移的槽位中键值对部分存在于源节点中,另一部分被存储在目标节点中。
当客户端向源节点发送一个于数据库键有关的命令,并且命令要处理的数据库键恰好属于正在被迁移的槽时:
- 源节点会先在自己的数据库中查找键,找到则执行客户端命令;
- 如果未找到,则该键有可能已经被迁移到了目标节点,源节点将向客户端返回一个
ASK错误
,指引这个客户端重定向到正在导入槽的目标节点,并再次发送之前想要执行的命令。(查看clusterState.migrating_slots_to[i]
已检查是否在进行迁移,如果指向不为null,则重定向到指针指向的节点)
ASKING
打开发送该命令的客户端的redis_asking
标识,以使得客户端在遇到moved错误时能够破例在当前节点中执行关于槽i的命令一次。注意该标识是一次性的,当节点执行了一个带有redis_asking
标识的客户端发送的命令后,该标志位将被移除。
ASK错误 和 MOVED 错误
- MOVED错误: 槽的指派关系发生变化,使得客户端需要从 MOVED错误返回的 ip port 中获取到最新的槽位负责节点然后执行命令
- ASK错误:是两个节点在迁移槽的过程中的临时措施,当客户端收到关于槽i的
ASK错误
后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发送至发生ASK错误
所指示的节点。但对该客户端之后的命令请求无效。
复制与故障转移
集群模式中,节点分为主节点和从节点,主节点负责处理槽,从节点负责复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
集群中有节点A|b|c
,各有三个从节点,此时节点A
下线,则将由B|C
负责从节点A
的从节点中选出一个作为新的主节点,由这个被选中的节点负责处理原先节点A
负责的槽位,并继续处理客户端的命令请求。
配置从节点
在前文中,集群架构还只是顶层的多个主节点。集群模式当然是支持为节点分配从节点的。使用如下命令可以让接收命令的节点成为node_id
所指定的从节点,并开始对主节点进行复制。
1 | cluster replicate <node_id > |
从节点如何复制主节点
- 收到命令的节点从
clusterNode.nodes
中找到noed_id
对应节点的clusterNode
结构,并且将自己的clusterState.myself.slaveof
指向该节点,以记录当前正在复制的主节点。另外,将clusterState.myself.flags
修改为REDIS_NODE_slave
,以标识当前是从节点。 - 根据
clusterState.myself.slaveof
指向的clusterNode
中保存的IP|port
对主节点进行复制。slaveof ip pport
- 一个节点成为从节点,并且开始复制主节点这一信息会
通过消息
发送给集群中的其他节点
,最终所有节点都会知道这一消息。
故障检测
疑似下线 pfail
集群中的每个节点都会定期向集群中的其他节点发送ping
消息,以此检测对方是否在线,如果该命令没有在规定时间内响应,那么发送消息的节点会将收到消息的节点标记为pfail, 疑似下线
。
同理,集群中的各个节点会通过互相发消息的方式来交换集群中各个节点的状态信息。
如果主节点A
通过消息得知主节点B
认为主节点C
进入疑似下线状态,则主节点a
会将clusterNode.nodes
中找到节点C
,并将节点B
报告的下线报告加入到clusterNode.fail_reports
链表中。
每个下线报告中记录了如下信息:
1 | /* This structure represent elements of node->fail_reports. */ |
已下线 fail
如果在一个集群中,半数以上负责处理槽的主节点都将某个主节点X
报告为疑似下线,那么这个节点X
将被标记为已下线
,将主节点标记为已下线的节点会向集群广播
一条关于主节点X
的fail
消息,收到此消息的所有节点会立即将clusterNode.nodes
中节点X
的状态修改为已下线。
故障转移
如果从节点检测到自己正在复制的主节点进入了下线状态,从节点将开始对下线主节点进行故障转移:
- 复制下线节点的所有从节点中选出一个。
- 被选中的从节点执行
slaveof no one
,成为主节点 - 原先指派给下线主节点的槽位重新指派给当前节点。
- 向集群广播一条
pong
消息,通知集群中的其他节点,当前节点已成为主节点,并且负责接管下线节点所负责的槽位。 - 新的主节点开始接收和自己负责槽位相关的命令请求,故障转移完成。
集群选主
从节点检测到主节点下线后如何进行选主?
- 集群中的某个节点开始一次故障转移操作时,集群的配置纪元
+1
. - 在每个配置纪元中,集群中每个负责处理槽的主节点拥有一次投票机会,第一个向此主节点请求投票的节点将获得投票。
- 从节点发现复制的主节点下线时,将广播
CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
消息,要求所有收到消息且具有投票权的主节点进行投票。 - 具有投票权且尚未投票的主节点,将返回消息
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
。 - 每个从节点统计自己获取的投票数量,如果得票数大于等于
N/2+1
,则此从节点将当选成为新的主节点。N是集群中具有投票权的主节点数量。 - 如果一个配置纪元中无法选出主节点,则将进行下一次选举。
消息
节点发送的消息主要有如下五种:
MEET
: 发送者通过客户端向接受者发送,标识请PING
: 集群中的每个节点默认每隔一秒从已知的节点列表中随机选出5
个节点,对最长时间没有发送过ping
消息的节点发送ping
消息,以此检测被选中的节点是否在线。如果节点a
最后一次收到节点b
发送的PONG
消息的时间距当前时间已超出节点a
设置的cluster-node-timeout
的一般,那么节点a
也会向节点B
发送ping
消息。PONG
:接收者回复meet|ping
命令;或者向节点广播PONG
命令以通知其他节点当前节点状态变化。FAIL
:主节点A
判断主节点B
进入Fail
状态,节点A
会向集群广播一条关于节点B
fail的消息,所有收到此消息的节点都会立即将节点B
标记为已下线。publish
: 节点收到publish
命令时,节点会执行命令,并向集群广播一条publish
消息,所有接收到此消息的节点都会执行相同的publish
命令。
gossip协议
扩容 缩容
扩容方案
- 将新节点纳入集群, 使用
cluster meet
或者redis-trib add node
- 确定 加入的新节点 所负责的槽位, 同时查询
clusterState.slots
查询该槽位的原先被指派的主节点。 - 遍历 所有槽位, 将每个槽位关联的节点中的 键值对 迁移到 新加入的节点中。
- 该主节点负责的槽位全部迁移完毕,向集群广播当前的节点状态,负责迁移后槽位,以及相关命令的执行。
缩容方案
- 是否是主节点。
- 是主节点,有无被分派的槽位。
- 有分配的槽位,先将当前负责的槽位分配到其他节点。
- 所有槽位完成重新指派并完成数据库键值对的迁移后,广播当前节点准备下线。
- 原先该主节点的从节点下线。
思考?
为什么redis支持16384个槽位?
前面我们提到过,redis中计算一个键所对应的槽位的计算方式是:
1 | slot = CRC16(key) % 16384 |
而CRC16能够获得65535个值,那为什么redis只支持16384个呢?
原因在于,redis的各种检测命令、广播命令中,都会将携带slots
信息。两者的开销分别是 2^16/8=8KB
和2^14/8=2KB
。
而redis集群模式最多支持1000
个分片,故而选择16384相对65535是更合理的选择。
为什么要传全量的slot状态?
因为分布式场景,基于状态的设计更合理,状态的传播具有幂等性
为什么不考虑压缩?
集群规模较小的场景下,每个分片负责大量的slot,很难压缩。
详见https://github.com/redis/redis/issues/2576
为什么 集群模式中不适用 发布订阅
所有的publish命令都会向所有节点(包括从节点)进行广播,造成每条publish数据都会在集群内所有节点传播一次,加重了带宽负担,对于在有大量节点的集群中频繁使用pub,会严重消耗带宽。