目录
CLUSTER SETSLOT IMPORTING命令的实现
CLUSTER SETSLOT MIGRATING命令的实现
集群简介
Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共 享,并提供复制和故障转移功能。
集群由多个节点(Node)组成,Redis的数据分布在这些节点中。集群中的节点分为主节点和从节点:只有主节点负责读写请求和集群信息的维护;从节点只进行主节点数据和状态信息的复制。
上图 展示了 Redis Cluster 典型的架构图,集群中的每一个 Redis 节点都 互相两两相连,客户端任意 直连 到集群中的 任意一台,就可以对其他 Redis 节点进行 读写 的操作。
集群的作用,可以归纳为两点:
1、数据分区:数据分区(或称数据分片)是集群最核心的功能。
集群将数据分散到多个节点,一方面突破了Redis单机内存大小的限制,存储容量大大增加;另一方面每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。
Redis单机内存大小受限问题,在介绍持久化和主从复制时都有提及;例如,如果单机内存太大,bgsave和bgrewriteaof的fork操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出……。
2、高可用:集群支持主从复制和主节点的自动故障转移(与哨兵类似);当任一节点发生故障时,集群仍然可以对外提供服务。
Redis Cluster与客户端
Redis Cluster的「路由」是做在客户端的(SDK已经集成了路由转发的功能)
是这样的,在集群的中每个Redis实例都会向其他实例「传播」自己所负责的哈希槽有哪些。这样一来,每台Redis实例就可以记录着「所有哈希槽与实例」的关系了(:
有了这个映射关系以后,客户端也会「缓存」一份到自己的本地上,那自然客户端就知道去哪个Redis实例上操作了
在集群里也可以新增或者删除Redis实例啊,这个怎么整?
当集群删除或者新增Redis实例时,那总会有某Redis实例所负责的哈希槽关系会发生变化
发生变化的信息会通过消息发送至整个集群中,所有的Redis实例都会知道该变化,然后更新自己所保存的映射关系
但这时候,客户端其实是不感知的:
所以,当客户端请求时某Key时,还是会请求到「原来」的Redis实例上。而原来的Redis实例会返回「moved」命令,告诉客户端应该要去新的Redis实例上去请求啦
客户端接收到「moved」命令之后,就知道去新的Redis实例请求了,并且更新「缓存哈希槽与实例之间的映射关系」
总结起来就是:数据迁移完毕后被响应,客户端会收到「moved」命令,并且会更新本地缓存
那数据还没完全迁移完呢?
如果数据还没完全迁移完,那这时候会返回客户端「ask」命令。也是让客户端去请求新的Redis实例,但客户端这时候不会更新本地缓存
数据分区方案简析
方案一:哈希值 % 节点数
哈希取余分区思路非常简单:计算 key
的 hash 值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。
不过该方案最大的问题是,当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要 重新计算映射关系,引发大规模数据迁移。
方案二:一致性哈希分区
一致性哈希算法将 整个哈希值空间 组织成一个虚拟的圆环,范围是 [0 - 232 - 1],对于每一个数据,根据 key
计算 hash 值,确数据在环上的位置,然后从此位置沿顺时针行走,找到的第一台服务器就是其应该映射到的服务器:
与哈希取余分区相比,一致性哈希分区将 增减节点的影响限制在相邻节点。以上图为例,如果在 node1
和 node2
之间增加 node5
,则只有 node2
中的一部分数据会迁移到 node5
;如果去掉 node2
,则原 node2
中的数据只会迁移到 node4
中,只有 node4
会受影响。
一致性哈希分区的主要问题在于,当 节点数量较少 时,增加或删减节点,对单个节点的影响可能很大,造成数据的严重不平衡。还是以上图为例,如果去掉 node2
,node4
中的数据由总数据的 1/4
左右变为 1/2
左右,与其他节点相比负载过高。
方案三:带有虚拟节点的一致性哈希分区
该方案在 一致性哈希分区的基础上,引入了 虚拟节点 的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为 槽(slot)。槽是介于数据和实际节点之间的虚拟概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。
在使用了槽的一致性哈希分区中,槽是数据管理和迁移的基本单位。槽 解耦 了 数据和实际节点 之间的关系,增加或删除节点对系统的影响很小。仍以上图为例,系统中有 4
个实际节点,假设为其分配 16
个槽(0-15);
槽 0-3 位于 node1;4-7 位于 node2;以此类推....
如果此时删除 node2
,只需要将槽 4-7 重新分配即可,例如槽 4-5 分配给 node1
,槽 6 分配给 node3
,槽 7 分配给 node4
;可以看出删除 node2
后,数据在其他节点的分布仍然较为均衡。
为什么哈希槽是16384个吗?
是这样的,Redis实例之间「通讯」会相互交换「槽信息」,那如果槽过多(意味着网络包会变大),网络包变大,那是不是就意味着会「过度占用」网络的带宽
另外一块是,Redis作者认为集群在一般情况下是不会超过1000个实例
那就取了16384个,即可以将数据合理打散至Redis集群中的不同实例,又不会在交换数据时导致带宽占用过多
为什么用哈希槽,而不是一致性哈希算法
在我理解下,一致性哈希算法就是有个「哈希环」,当客户端请求时,会对Key进行hash,确定在哈希环上的位置,然后顺时针往后找,找到的第一个真实节点
一致性哈希算法比「传统固定取模」的好处就是:如果集群中需要新增或删除某实例,只会影响一小部分的数据
但如果在集群中新增或者删除实例,在一致性哈希算法下,就得知道是「哪一部分数据」受到影响了,需要进行对受影响的数据进行迁移
而哈希槽的方式,我们通过上面已经可以发现:在集群中的每个实例都能拿到槽位相关的信息
当客户端对key进行hash运算之后,如果发现请求的实例没有相关的数据,实例会返回「重定向」命令告诉客户端应该去哪儿请求
集群的扩容、缩容都是以「哈希槽」作为基本单位进行操作,总的来说就是「实现」会更加简单(简洁,高效,有弹性)。过程大概就是把部分槽进行重新分配,然后迁移槽中的数据即可,不会影响到集群中某个实例的所有数据。
节点
一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独 立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将 各个独立的节点连接起来,构成一个包含多个节点的集群。
连接各个节点的工作可以使用CLUSTER MEET命令来完成,该命令的格式如下:
CLUSTER MEET <ip> <port>
向一个节点node发送CLUSTER MEET命令,可以让node节点与ip和port所指定 的节点进行握手(handshake), 当握手成功时,node节点就会将ip和port所指定的节点 添加到node节点当前所在的集群中。
举个例子,假设现在有三个独立的节点127.0.0.1:7000、127.0.0.1:7001、 127.0.0.1:7002 (下文省略IP地址,直接使用端口号来区分各个节点),我们首先使用 客户端连上节点7000, 通过发送CLUSTER NODE命令可以看到,集群目前只包含7000自 己一个节点:
通过向节点7000发送以下命令,我们可以将节点7001添加到节点7000所在的集群 里面:
127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7001
继续向节点7000发送以下命令,我们可以将节点7002也添加到节点7000和节点7001 所在的集群里面:
启动节点
一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据 cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式,如图
节点(运行在集群模式下的Redis服务器)会继续使用所有在单机模式中使用的服务器 组件,比如说:
节点会继续使用文件事件处理器来处理命令请求和返回命令回复。
节点会继续使用时间事件处理器来执行serverCron函数,而serverCron函数 又会调用集群模式特有的clusterCron函数。clusterCron函数负责执行在集 群模式下需要执行的常规操作,例如向集群中的其他节点发送Gossip消息,检查 节点是否断线,或者检查是否需要对下线节点进行自动故障转移等。
节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象。
节点会继续使用RDB持久化模块和AOF持久化模块来执行持久化工作。
节点会继续使用发布与订阅模块来执行PUBLISH、SUBSCRIBE等命令。
节点会继续使用复制模块来进行节点的复制工作。
节点会继续使用Lua脚本环境来执行客户端输入的Lua脚本。
除此之外,节点会继续使用redisServer结构来保存服务器的状态,使用 redisClient结构来保存客户端的状态,至于那些只有在集群模式下才会用到的数据,节点 将它们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构,以 及cluster.h/clusterState结构里面。
集群数据结构
clusterNode结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、 节点当前的配置纪元、节点的IP地址和端口号等等。
每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其 他节点(包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态:
clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点 所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区:
redisClient结构和clusterlink结构的相同和不同之处
redisClient结构和clusterLink结构都有自己的套接字描述符和输入、输出 缓冲区,这两个结构的区别在于,redisClient结构中的套接宇和缓冲区是用于连接 客户端的,而clusterLink结构中的套接字和缓冲区则是用于连接节点的。
最后,每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视 角下,集群目前所处的状态 ,例如集群是在线还是下线,集群包含多少个节点,集群当前的 配置纪元,诸如此类:
以前面介绍的7000、7001、7002三个节点为例,图展示了节点7000创建的 clusterState结构,这个结构从节点7000的角度记录了集群以及集群包含的三个节点的 当前状态(为了空间考虑,图中省略了clusterNode结构的一部分属性):
结构的currentEpoch属性的值为0, 表示集群当前的配置纪元为0。
结构的size属性的值为0, 表示集群目前没有任何节点在处理槽,因此结构的 state属性的值为REDIS_CLUSTER_FAIL, 这表示集群目前处于下线状态。
结构的nodes字典记录了集群目前包含的三个节点,这三个节点分别由三个 clusterNode结构表示,其中myself指针指向代表节点7000的clusterNode 结构,而字典中的另外两个指针则分别指向代表节点7001和代表节点7002的 clusterNode结构,这两个节点是节点7000已知的在集群中的其他节点。
三个节点的clusterNode结构的flags属性都是REDIS_NODE_MASTER, 说明三 个节点都是主节点。
节点7001和节点7002也会创建类似的clusterState结构:
不过在节点7001创建的clusterState结构中,myself指针将指向代表节点 7001的clusterNode结构,而节点7000和节点7002则是集群中的其他节点。
而在节点7002创建的clusterstate结构中,myself指针将指向代表节点7002 的clusterNode结构,而节点7000和节点7001则是集群中的其他节点。
CLUSTER MEET命令的实现
通过向节点A发送CLUSTER MEET命令,客户端可以让接收命令的节点A将另一个节 点B添加到节点A当前所在的集群里面:
CLUSTER MEET <ip> <port>
收到命令的节点A将与节点B进行握手(handshake) , 以此来确认彼此的存在,并为 将来的进一步通信打好基础:
1)节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的 clusterState.nodes字典里面。
2)之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送 一条MEET消息(message)。
3)如果一切顺利,节点B将接收到节点A发送的MEET消息,节点B会为节点A创 建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
4)之后,节点B将向节点A返回一条PONG消息。
5)如果一切顺利,节点A将接收到节点B返回的PONG消息,通过这条PONG消息节 点A可以知道节点B已经成功地接收到了自已发送的MEET消息。
6)之后,节点A将向节点B返回一条PING消息。
7)如果一切顺利,节点B将接收到节点A返回的PING消息,通过这条PING消息节 点B可以知道节点A已经成功地接收到了自己返回的PONG消息,握手完成。
图展示了以上步骤描述的握手过程。
之后,节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节 点也与节点B进行握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识。
槽指派
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384 个槽(slot), 数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处 理0个或最多16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok); 相反地,如果 数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。
我们使用CLUSTER MEET命令将7000、7001、7002三个节点连接到了同 一个集群里面,不过这个集群目前仍然处于下线状态,因为集群中的三个节点都没有在处理 任何槽:
通过向节点发送CLUSTER ADDSLOTS命令,我们可以将一个或多个槽指派(assign) 给节点负责:
为了让7000、7001、7002三个节点所在的集群进入上线状态,我们继续执行以下命令,
将槽5001至槽10000指派给节点7001负责:
127.0.0.1:7001> CLUSTER ADDSLOTS 5001 5002 5003 5004 ••• 10000 OK
然后将槽10001至槽16383指派给7002负责:
127.0.0.1:7002> CLUSTER ADDSLOTS 10001 10002 10003 10004 ••• 16383 OK
当以上三个CLUSTER ADDSLOTS命令都执行完毕之后,数据库中的16384个槽都已经 被指派给了相应的节点,集群进入上线状态:
记录节点的槽指派信息
clusterNode结构的slots属性和numslot属性记录了节点负责处理哪些槽:
slots属性是一个二进制位数组(bit array), 这个数组的长度为16384/8=2048个字 节,共包含16384个二进制位。
Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,并根据索引i上的二进制位的值来判断节点是否负责处理槽i:
如果slots数组在索引i上的二进制位的值为1, 那么表示节点负责处理槽i。
如果slots数组在索引i上的二进制位的值为0, 那么表示节点不负责处理槽i。
图展示了一个slots数组示例:这个数组索引0至索引7上的二进制位的值都为 1,其余所有二进制位的值都为0, 这表示节点负责处理槽0至槽7。
因为取出和设置slots数组中的任意一个二进制位的值的复杂度仅为O(1), 所以对于 一个给定节点的slots数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指 派给节点负责,这两个动作的复杂度都是O(1)。
至于numslots属性则记录节点负责处理的槽的数量,也即是slots数组中值为1的 二进制位的数量。
比如说,对于图所示的slots数组来说,节点处理的槽数量为8。
传播节点的槽指派信息
一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,它还会将自己的slots数组 通过消息发送给集群中的其他节点,以此来告知其他 节点自己目前负责处理哪些槽。
举个例子,对于前面展示的包含1000、1001、 1002三个节点的集群来说:
节点1000会通过消息向节点1001和节点 1002发送自己的slots数组,以此来告知这 两个节点,自己负责处理槽0至槽5000, 如 图所示。
节点1001会通过消息向节点1000和节点1002发送自己的slots数组,以此来告知这两个节点,自己负责处理槽5001至槽 10000, 如图所示。
节点1002会通过消息向节点1000和节点1001发送自己的slots数组,以此来告 知这两个节点,自己负责处理槽10001至槽16383, 如图所示。
当节点A通过消息从节点B那里接收到节点B的slots数组时,节点A会在自己的 clusterState.nodes字典中查找节点B对应的clusterNode结构,并对结构中的 slots数组进行保存或者更新。
因为集群中的每个节点都会将自己的slots数组通过消息发送给集群中的其他节点, 并且每个接收到slots数组的节点都会将数组保存到相应节点的clusterNode结构里面, 因此,集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。
记录集群所有槽的指派信息
clusterState结构中的slots数组记录了集群中所有16384个槽的指派信息:
slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针:
如果slots[i]指针指向NULL, 那么表示槽i尚未指派给任何节点。
如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了 clusterNode结构所代表的节点。
举个例子,对于7000、7001、7002三个节点来说,它们的cluster State结构的 slots数组将会是图所示的样子:
数组项slots[0]至slots[5000]的指针都指向代表节点7000的clusterNode 结构,表示槽0至5000都指派给了节点7000。
数组项slots[5001]至slots[10000]的指针都指向代表节点7001的 clusterNode结构,表示槽5001至10000都指派给了节点7001。
数组项slots[10001]至slots[16383]的指针都指向代表节点7002的 clusterNode结构,表示槽10001至16383都指派给了节点7002。
如果只将槽指派信息保存在各个节点的clusterNode.slots数组里,会出现一些无 法高效地解决的问题,而clusterState.slots数组的存在解决了这些问题:
如果节点只使用clusterNode.slots数组来记录槽的指派信息,那么为 了知道槽i是否已经被指派,或者槽i被指派给了哪个节点,程序需要遍历 clusterState.nodes字典中的所有clusterNode结构,检查这些结构的 slots数组,直到找到负责处理槽i的节点为止,这个过程的复杂度为O(N), 其中 N为clusterState.nodes字典保存的clusterNode结构的数量。
而通过将所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽 i是否已经被指派,又或者取得负责处理槽i的节点,只需要访问clusterState. slots[i]的值即可,这个操作的复杂度仅为O(1)。
要说明的一点是,虽然clusterState.slots数组记录了集群中所有槽的指派信息, 但使用clusterNode结构的slots数组来记录单个节点的槽指派信息仍然是有必要的:
因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要 将相应节点的clusterNode.slots数组整个发送出去就可以了。
另一方面,如果Redis不使用clusterNode.slots数组,而单独使用 clusterState.slots数组的话,那么每次要将节点A的槽指派信息传播给其 他节点时,程序必须先遍历整个clusterState.slots数组,记录节点A负责 处理哪些槽,然后才能发送节点A的槽指派信息,这比直接发送clusterNode. slots数组要麻烦和低效得多。
clusterState.slots数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构所代表的节点的槽指派信息,这是两个slots数 组的关键区别所在。
CLUSTER ADDSLOTS命令的实现
CLUSTER ADDSLOTS命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收 该命令的节点负责:
举个例子,图展示了一个节点的clusterState结构,clusterState. slots数组中的所有指针都指向NULL, 并且clusterNode.slots数组中的所有二进制 位的值都是0, 这说明当前节点没有被指派任何槽,并且集群中的所有槽都是未指派的。
当客户端对所示的节点执行命令:
CLUSTER ADDSLOTS 1 2
将槽1和槽2指派给节点之后,节点的clusterState结构将被更新成图所示 的样子:
clusterState.slots数组在索引1和索引2上的指针指向了代表当前节点的 clusterNode结构。
并且clusterNode.slots数组在索引1和索引2上的位被设置成了1。
最后,在CLUSTER ADDSLOTS命令执行完毕之后,节点会通过发送消息告知集群中的 其他节点,自己目前正在负责处理哪些槽。
在集群中执行命令
在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就 可以向集群中的节点发送数据命令了。
当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的 数据库键属于哪个槽,并检查这个槽是否指派给了自己:
如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错 误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令。
图展示了这两种情况的判断流程。
举个例子,如果我们在之前提到的,由7000、7001、7002三个节点组成的集群中,用客户端连上节点7000, 并发送以下命令,那么命令会直接被节点7000执行:
这是因为键msg所在的槽6257是由节点7001负责处理的,而不是由最初接收命令的 节点7000负责处理:
当客户端第一次向节点7000发送SET命令的时候,节点7000会向客户端返回 MOVED错误,指引客户端转向至节点7001。
当客户端转向到节点7001之后,客户端重新向节点7001发送SET命令,这个命令 会被节点7001成功执行。
计算键属于哪个槽
节点使用以下算法来计算给定键key属于哪个槽:
其中CRC16 (key)语句用于计算键key的CRC16校验和,而& 16383语句则用于计算出一个介于0至16383之间的整数作为键key的槽号。
使用CLUSTER KEYSLOT <key>命令可以查看一个给定键属于哪个槽:
判断槽是否由当前节点负责处理
当节点计算出键所属的槽i之后,节点就会检查自己在clusterState.slots数组 中的项i, 判断键所在的槽是否由自己负责:
1)如果clusterState.slots[i]等于clusterState.myself, 那么说明槽i 由当前节点负责,节点可以执行客户端发送的命令。
2)如果clusters tate.slots[i]不等于clusterState.myself, 那么说明槽 i并非由当前节点负责,节点会根据clusterState.slots[i]指向的clusterNode 结构所记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽 i的节点。
举个例子,假设图为节点7000的clusterState结构:
当客户端向节点7000发送命令SET date "2013-12-31"的时候,节点首先 计算出键date属于槽2022, 然后检查得出clusterState.slots[2022]等于 clusterState.myself, 这说明槽2022正是由节点7000负责,于是节点7000 直接执行这个SET命令,并将结果返回给发送命令的客户端。
当客户端向节点7000发送命令SET msg "happy new year!"的时候,节点首 先计算出键msg属于槽6257, 然后检查clusterState.slots[6257]是否等 于clusterState.myself, 结果发现两者并不相等:这说明槽6257并非由节 点7000负责处理,于是节点7000访问clusterState.slots[6257]所指向 的clusterNode结构,并根据结构中记录的IP地址127.0.0.1和端口号7001, 向客户端返回错误MOVED 6257 127. 0. 0. 1: 7001, 指引节点转向至正在负责处 理槽6257的节点7001。
MOVED 错误
当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个MOVED 错误,指引客户端转向至正在负责槽的节点。
表示槽789正由IP地址为127.0.0.1, 端口号为7000的节点负责。
当客户端接收到节点返回的MOVED错误时,客户端会根据MOVED错误中提供的IP地 址和端口号,转向至负责处理槽slot的节点,并向该节点重新发送之前想要执行的命令。 以前面的客户端从节点7000转向至7001的情况作为例子:
一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上 就是换一个套接字来发送命令。
如果客户端尚未与想要转向的节点创建套接字连接,那么客户端会先根据MOVED错误 提供的IP地址和端口号来连接节点,然后再进行转向。
被隐藏的MOVED错误
集群模式的redis-cli客户端在接收到MOVED错误时,并不会打印出MOVED错 误,而是根据MOVED错误自动进行节点转向,并打印出转向信息,所以我们是看不见 节点返回的MOVED错误的:
但是,如果我们使用单机(stand alone)模式的redis-cli客户端,再次向节点 7000发送相同的命令,那么MOVED错误就会被客户端打印出来:
这是因为单机模式的redis-cli客户端不清楚MOVED错误的作用,所以它只会 直接将MOVED错误直接打印出来,而不会进行自动转向。
节点数据库的实现
集群节点保存键值对以及键值对过期时间的方式,与单机Redis服务 器保存键值对以及键值对过期时间的方式完全相同。
节点和单机服务器在数据库方面的一个区别是,节点只能使用0号数据库,而单机 Redis服务器则没有这一限制。
举个例子,图展示了节点7000的数据库状态,数据库中包含列表键"lst“,哈 希键"book", 以及字符串键"date", 其中键"1st"和键"book"带有过期时间。
另外,除了将键值对保存在数据库里面之外,节点还会用clusterState结构中的 slots_to_keys跳跃表来保存槽和键之间的关系:
slots_to_keys跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员 (member)都是一个数据库键:
每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联 到slots_to_keys跳跃表。
当节点删除数据库中的某个键值对时,节点就会在slots_to_keys跳跃表解除被 删除键与槽号的关联。
举个例子,对于图所示的数据库,节点7000将创建类似图所示的slots_to_keys跳跃表:
键"book"所在跳跃表节点的分值为B37.0, 这表示键"book"所在的槽为1337。
键"date"所在跳跃表节点的分值为2022.0, 这表示键"date"所在的槽为2022。
键"1st"所在跳跃表节点的分值为3347.0, 这表示键"1st"所在的槽为3347。
通过在slots_to_keys跳跃表中记录各个数据库键所属的槽,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作,例如命令CLUSTER GETKEYSINSLOT <slot> <count>命令可以返回最多count个属于槽slot的数据库键,而这个命令就是通 过遍历slots_to_keys跳跃表来实现的。
重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指 派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且 源节点和目标节点都可以继续处理命令请求。
举个例子,对于之前提到的,包含7000、7001、7002三个节点的集群来说,我们可 以向这个集群添加一个IP为127.0.0.1, 端口号为7003的节点(后面简称节点7003):
然后通过重新分片操作,将原本指派给节点7002的槽15001至16383改为指派给节点 7003。
以下是重新分片操作执行之后,节点的槽分配状态:
重新分片的实现原理
Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的, Redis提供了进行重新分片所需的所有命令,而redistrib则通过向源节点和目标节点 发送命令来进行重新分片操作。
redis-trib对集群的单个槽slot进行重新分片的步骤如下:
1) redis-trib对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_ id>命令,让目标节点准备好从源节点导入(import)属于槽slot的键值对。
2) redis-trib对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id> 命令,让源节点准备好将属于槽slot的键值对迁移(migrate)至目标节点。
3) redis-trib向源节点发送CLUSTER GETKEYSINSLOT <slot> <count>命令, 获得最多count个属于槽slot的键值对的键名(key name)。
4)对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE<target_ip> <target_port> <key name> 0 <timeout>命令,将被选中的键原子地从源节点迁移至目标节点。
5)重复执行步骤3和步骤4, 直到源节点保存的所有属于槽slot的键值对都被迁移 至目标节点为止。每次迁移键的过程如图所示。
6) redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT <slot> NODE <target _jd>命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群, 最终集群中的所有节点都会知道槽slot已经指派给了目标节点。
如果重新分片涉及多个槽,那么redis-trib将对每个给定的槽分别执行上面给出的 步骤。
平滑数据迁移方案
由于生产环境的各种原因,我们需要对现有服务器进行迁移,包括线上正在运行的 redis 集群环境 如何去做?
涉及到数据源变动,原有数据如何平滑迁移到新实例,从而可以实现无缝迁移?
基于 redis 自身的RDB/AOF 备份机制
执行 save\bgsave
触发数据持久化 RDB
文件
拷贝redis备份文件(dump.rdb)到目标机器
重启目标实例重新load
RDB 文件
关于 save/bgsave 的区别
命令 | save | bgsave |
---|---|---|
IO阻塞 | 同步 | 异步 |
复杂度 | O(n) | O(n) |
缺点 | 阻塞客户端 | 需要fork,消耗内存 |
基于 redis-dump
导入导出 json
备份
redis-dump 基于JSON 备份还原Redis的数据https://github.com/delano/redis-dump
# 导出命令
redis-dump –u 127.0.0.1:6379 > lengleng.json
# 导出指定数据库数据
redis-dump -u 127.0.0.1:6379 -d 15 > lengleng.json
# 如果redis设有密码
redis-dump –u :password@127.0.0.1:6379 > lengleng.json
# 导入命令
< lengleng.json redis-load
# 指定redis密码
< lengleng.json redis-load -u :password@127.0.0.1:6379
基于 redis-shake
实现 redis-cluster
迁移
redis-shake是阿里云Redis&MongoDB团队开源的用于redis数据同步的工具https://github.com/alibaba/RedisShake。
基于 Docker
创建两个集群
docker run --name redis-cluster1 -e CLUSTER_ANNOUNCE_IP=192.168.0.31 -p 8000-8005:7000-7005 -p 18000-18005:17000-17005 pig4cloud/redis-cluster:4.0
docker run --name redis-cluster2 -e CLUSTER_ANNOUNCE_IP=192.168.0.31 -p 8000-8005:7000-7005 -p 18000-18005:17000-17005 pig4cloud/redis-cluster:4.0
配置 redis-shake.conf
source.type: cluster
source.address: master@192.168.0.31:7000 #配置一个节点自动发现
target.type: cluster
target.address: master@192.168.0.31:8000 #配置一个节点自动发现
执行全量、增量同步
restful监控指标
# 用户可以通过restful监控指标查看内部运行状况,默认的restful端口是9320:
http://127.0.0.1:9320/metric
ASK错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情 况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节 点里面。
当客户端向源节点发送一 个与数据库键有关的命令,并 且命令要处理的数据库键恰好 就属于正在被迁移的槽时:
源节点会先在自己的数 据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已 经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正 在导入槽的目标节点,并再次发送之前想要执行的命令。
图展示了源节点判断是否需要向客户端发送ASK错误的整个过程。
举个例子,假设节点7002正在向节点7003迁移槽16198, 这个槽包含"is"和"love" 两个键,其中键"is"还留在节点7002, 键"love"已经被迁移到了节点7003。
如果我们向节点7002发送关于键"is"的命令,那么这个命令会直接被节点7002执行:
而如果我们向节点7002发送关于键"love"的命令,那么客户端会先被转向至节点 7003, 然后再次执行命令:
被隐藏的ASK错误
和接到MOVED错误时的情况类似,集群模式的redis-cli在接到ASK错误时也 不会打印错误,而是自动根据错误提供的IP地址和端口进行转向动作。如果想看到节点 发送的ASK错误的话,可以使用单机模式的redis-cli客户端:
注意:在写这篇文章的时候,集群模式的redis-cli并未支持ASK自动转向,上面展示的ASK自动转向行为实际上是根据MOVED自动转向行为虚构出来的。因此,当集群模式的 redis-cli真正支持ASK自动转向时,它的行为和上面展示的行为可能会有所不同。
CLUSTER SETSLOT IMPORTING命令的实现
clusterstate结构的importing_slots_from数组记录了当前节点正在从其他 节点导入的槽:
如果importing_slots_from[i]的值不为NULL, 而是指向一个clusterNode 结构,那么表示当前节点正在从clusterNode所代表的节点导入槽i。
在对集群进行重新分片的时候,向目标节点发送命令:
可以将目标节点clusterstate.importing slots from[i]的值设置为 source_id所代表节点的clusterNode结构。
举个例子,如果客户端向节点7003发送以下命令:
那么节点7003的clusterstate.importing slots from数组将变成图所 示的样子。
CLUSTER SETSLOT MIGRATING命令的实现
cluster State结构的migrating_slots_to数组记录了当前节点正在迁移至其他 节点的槽:
如果migrating_slots_to[i]的值不为NULL, 而是指向一个clusterNode结构, 那么表示当前节点正在将槽 i 迁移至clusterNode所代表的节点。
那么节点7002的clusterState.migrating_slots_to数组将变成图所示 的样子。
ASK错误
如果节点收到一个关于键key的命令请求,并且键key所属的槽i正好就指派给了这 个节点,那么节点会尝试在自己的数据库里查找键key, 如果找到了的话,节点就直接执行 客户端发送的命令。
与此相反,如果节点没有在自己的数据库里找到键key, 那么节点会检查自己的 clusterState.migrating_slots_to [ i] , 看键key所属的槽i是否正在进行迁移, 如果槽i的确在进行迁移的话,那么节点会向客户端发送一个ASK错误,引导客户端到正 在导入槽i的节点去查找键key。
举个例子,假设在节点7002向节点7003迁移槽161~8期间,有一个客户端向节点7002
GET "love"
因为键"love"正好属于槽16198, 所以节点7002会首先在自己的数据库中查找 键"love", 但并没有找到,通过检查自己的clusterState.migrating_slots_ t0[16198], 节点7002发现自己正在将槽16198迁移至节点7003, 于是它向客户端返 回错误:
ASK 16198 127.0.0.1:7003
这个错误表示客户端可以尝试到IP为127.0.0.1. 端口号为7003的节点去执行和槽 16198有关的操作,如图所示。
接到ASK错误的客户端会根据错误提供的IP地址和端口号,转向至正在导入槽的目标
节点,然后首先向目标节点发送一个ASKING命令,之后再重新发送原本想要执行的命令。
以前面的例子来说,当客户端接收到节点7002返回的以下错误时:
ASK 16198 127.0.0.1:7003
客户端会转向至节点7003, 首先发送命令:
ASKING
然后再次发送命令:
GET "love"
并获得回复:
"you getthe key'love'"
整个过程如图所示。
ASKING命令
ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识,以下 是该命令的伪代码实现:
在一般情况下,如果客户端向节点发送一个关于槽i的命令,而槽i又没有指 派给这个节点的话,那么节点将向客户端返回一个MOVED错误
但是,如果节点的 clusterState.importing_slots_from[i]显示节点正在导入槽i, 并且发送命令 的客户端带有REDIS_ASKING标识,那么节点将破例执行这个关于槽i的命令一次,图展示了这个判断过程。
当客户端接收到ASK错误并转向至正在导入槽的节点时,客户端会先向节点发送一个 ASKING命令,然后才重新发送想要执行的命令,这是因为如果客户端不发送ASKING命 令,而直接发送想要执行的命令的话,那么客户端发送的命令将被节点拒绝执行,并返回 MOVED错误。
举个例子,我们可以使用普通模式的redis-cli客户端,向正在导入槽16198的节点 7003发送以下命令:
虽然节点7003正在导入槽16198, 但槽16198目前仍然是指派给了节点7002, 所以节 点7003会向客户端返回MOVED错误,指引客户端转向至节点700'.2。
但是,如果我们在发送GET命令之前,先向节点发送一个ASKING命令,那么这个 GET命令就会被节点7003执行:
另外要注意的是,客户端的REDIS_ASKING标识是一个一次性标识,当节点执行了一 个带有REDIS_ASKING标识的客户端发送的命令之后,客户端的REDIS_ASKING标识就 会被移除。
举个例子,如果我们在成功执行GET命令之后,再次向节点7003发送GET命令,那 么第二次发送的GET命令将执行失败,因为这时客户端的REDIS_ASKING标识已经被 移除:
ASK错误和MOVED错误的区别
ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:
MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点:
在客户端收到 关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直 接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽i的 节点。
与此相反,ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:
在客户 端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽 i的命令请求发送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关 于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求发送至目前 负责处理槽i的节点,除非ASK错误再次出现。
复制与故障转移
Redis集群中的节点分为主节点(master)和从节点 (slave), 其中主节点用于处理槽,而从节点则用于复制 某个主节点,并在被复制的主节点下线时,代替下线主节 点继续处理命令请求。
举个例子,对于包含7000、7001、7002、7003四个 主节点的集群来说,我们可以将7004、7005两个节点添 加到集群里面,并将这两个节点设定为节点7000的从节 点,如图所示(图中以双圆形表示主节点,单圆形 表示从节点)。
如果这时,节点7000进入下线状态,那么集群中仍在正常运作的几个主节点将在节点7000的两个从节点一节点7004和节点7005中选 出一个节点作为新的主节点,这个新的主节点将接管 原来节点7000负责处理的槽,并继续处理客户端发送的命令请求。
例如,如果节点7004被选中为新的主节点,那 么节点7004将接管原来由节点7000负责处理的槽0 至槽5000, 节点7005也会从原来的复制节点7000, 改为复制节点7004, 如图所示(图中用虚线包围的节点为己下线节点)。
表记录了在对节点7000进行故障转移之后, 集群各个节点的当前状态,以及它们正在做的工作。
如果在故障转移完成之后,下线的节点7000重新上线,那么它将成为节点7004的从节 点,如图所示。
设置从节点
向一个节点发送命令:
可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制:
接收到该命令的节点首先会在自己的clusterState.nodes字典中找到node_id 所对应节点的clusterNode结构,并将自己的clusterState.myself.slaveOF 指针指向这个结构,以此来记录这个节点正在复制的主节点:
然后节点会修改自己在clusterState.myself.flags中的属性,关闭原本的 REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,表示这个节点已 经由原来的主节点变成了从节点。
最后,节点会调用复制代码,并根据clusterState.myself.slaveOF指向的 clusterNode结构所保存的IP地址和端口号,对主节点进行复制。因为节点的复 制功能和单机Redis服务器的复制功能使用了相同的代码,所以让从节点复制主节 点相当于向从节点发送命令SLAVEOF <master_ip> <master port>。
图展示了节点7004在复制节点7000时的clusterState结构:
clusterState.myself.flags属性的值为REDIS_NODE_SLAVE, 表示节点 7004是一个从节点。
clusterState.myself.slaveOF指针指向代表节点7000的结构,表示节点 7004正在复制的主节点为节点7000。
一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他 节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点。
集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves 属性中记录正在复制这个主节点的从节点名单:
举个例子,图记录了节点7004和节点7005成为节点7000的从节点之后,集群 中的各个节点为节点7000创建的clusterNode结构的样子:
代表节点7000的clusterNode结构的numslaves属性的值为2, 这说明有两个 从节点正在复制节点7000。
代表节点7000的clusterNode结构的slaves数组的两个项分别指向代表节点 7004和代表节点7005的clusterNode结构,这说明节点7000的两个从节点分别 是节点7004和节点7005。
故障检测
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方 是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返 回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线 (probable fail, PFAIL)。
举个例子,如果节点7001向节点7000发送了一条PING消息,但是节点7000没 有在规定的时间内,向节点7001返回一条PONG消息,那么节点7001就会在自己的 clusterState.nodes字典中找到节点7000所对应的clusterNode结构,并在结构的 flags属性中打开REDIS_NODE_PFAIL标识,以此表示节点7000进入了疑似下线状态, 如图所示。
集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如 某个节点是处于在线状态、疑似下线状态(PFAIL), 还是已下线状态(FAIL)。
当一个主节点A通过消息得知主节点B认为主节点C进入了疑似下线状态时,主节点 A会在自己的clusterState.nodes字典中找到主节点C所对应的clusterNode结构, 并将主节点B的下线报告(failure report)添加到clusterNode结构的fail_reports 链表里面:
每个下线报告由一个clusterNodeFailReport结构表示:
举个例子,如果主节点7001在收到主节点7002、主节点7003发送的消息后得知,主 节点7002和主节点7003都认为主节点7000进入了疑似下线状态,那么主节点7001将为 主节点7000创建图所示的下线报告。
如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线, 那么这个主节点x将被标记为己下线(FAIL), 将主节点x标记为己下线的节点会向集群广播 一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记 为己下线。
举个例子,对于图所示的下线报告来说,主节点7002和主节点7003都认为主 节点7000进入了下线状态,并且主节点7001也认为主节点7000进入了疑似下线状态(代 表主节点7000的结构打开了REDIS_NODE_PFAIL标识),综合起来,在集群四个负责处 理槽的主节点里面,有三个都将主节点7000标记为下线,数量已经超过了半数,所以主节 点7001会将主节点7000标记为己下线,并向集群广播一条关于主节点7000的FAIL消息, 如图所示。
故障转移
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主 节点进行故障转移,以下是故障转移的执行步骤:
1)复制下线主节点的所有从节点里面,会有一个从节点被选中。
2)被选中的从节点会执行SLAVEOF no one命令,成为新的主节点。
3)新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
4)新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点 立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由己下线节煮负 责处理的槽。
5)新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
选举新的主节点
新的主节点是通过选举产生的。
以下是集群选举新的主节点的方法:
1)集群的配置纪元是一个自增计数器,它的初始值为0。
2)当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一。
3)对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一 个向主节点要求投票的从节点将获得主节点的投票。
4)当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。
5)如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其 他从节点,那么主节点将向要求投票的从节点返回一条CLUSTERMSG_TYPE_FAILOVER_ AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点。
6)每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消 息,并根据自己收到了多少条这种消息来统计自已获得了多少主节点的支持。
7)如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1 张支持票时,这个从节点就会当选为新的主节点。
8)因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有 N个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新 的主节点只会有一个。
9)如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个 新的配置纪元,并再次进行选举,直到选出新的主节点为止。
这个选举新主节点的方法和选举领头Sentinel的方法非常相似,因为两 者都是基于Raft算法的领头选举(leader election)方法来实现的。
节点通信机制
集群要作为一个整体工作,离不开节点之间的通信。
两个端口
在哨兵系统中,节点分为数据节点和哨兵节点:前者存储数据,后者实现额外的控制功能。在集群中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个TCP端口:
普通端口:即我们在前面指定的端口(7000等)。普通端口主要用于为客户端提供服务(与单机节点类似);但在节点间数据迁移时也会使用。
集群端口:端口号是普通端口+10000(10000是固定值,无法改变),如7000节点的集群端口为17000。集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。
Gossip协议
节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip协议等。重点是广播和Gossip的对比。
广播是指向集群内所有节点发送消息;优点是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),缺点是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
Gossip协议的特点是:在节点数量有限的网络中,每个节点都“随机”的与部分节点通信(并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip协议的优点有负载(比广播)低、去中心化、容错性高(因为通信有冗余)等;缺点主要是集群的收敛速度慢。
消息类型
集群中的各个节点通过发送和接收消息(message)来进行 通信,我们称发送消息的节点为发送者(sender), 接收消息的节点为接收者(receiver), 如图所示。
集群中的节点采用固定频率(每秒10次)的定时任务进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。
节点间发送的消息主要分为5种:meet消息、ping消息、pong消息、fail消息、publish消息。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的。
MEET消息:当发送者接到客户端发送的CLUSTER MEET命令时,发送者会向接收者发送MEET消息,请求接收者加入到发送者当前 所处的集群里面。
PING消息:PING消息使用Gossip协议发送,接收节点的选择兼顾了收敛速度和带宽成本。
集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五 个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消 息,以此来检测被选中的节点是否在线。
除此之外,扫描节点列表,如果节点A最后一次收到节点 B发送的PONG消息的时间,距离当前时间已经超过了节点A的cluster-nodetimeout选项设置时长的一半,那么节点A也会向节点B发送PING消息,这可 以防止节点A因为长时间没有随机选中节点B作为PING消息的发送对象而导致对 节点B的信息更新滞后。
PONG消息:当接收者收到发送者发来的MEET消息或者PING消息时,为了向发送 者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG 消息。另外,一个节点也可以通过向集群广播自己的PONG消息来让集群中的其他 节点立即刷新关于这个节点的认识,例如当一次故障转移操作成功执行之后,新的主节点会向集群广播一条PONG消息,以此来让集群中的其他节点立即知道这个节 点已经变成了主节点,并且接管了已下线节点负责的槽。
FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A 会向集群广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将 节点B标记为己下线。
PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向 集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同 的PUBLISH命令。
一条消息由消息头(header)和消息正文(data)组成,接下来的内容将首先介绍消息 头,然后再分别介绍上面提到的五种不同类型的消息正文。
消息头
节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消 息发送者自身的一些信息,因为这些信息也会被消息接收者用到,所以严格来讲,我们可以 认为消息头本身也是消息的一部分。
每个消息头都由一个cluster.h/clusterMsg结构表示:
clusterMsg.data属性指向联合cluster.h/clusterMsgData, 这个联合就是 消息的正文:
clusterMsg结构的currentEpoch、sender、myslots等属性记录了发送者自身 的节点信息,接收者会根据这些信息,在自己的clusterState.nodes字典里找到发送 者对应的clusterNode结构,并对结构进行更新。
举个例子,通过对比接收者为发送者记录的槽指派信息,以及发送者在消息头的 myslots属性记录的槽指派信息,接收者可以知道发送者的槽指派信息是否发生了变化。
又或者说,通过对比接收者为发送者记录的标识值,以及发送者在消息头的flags属性 记录的标识值,接收者可以知道发送者的状态和角色是否发生了变化,例如节点状态由原来 的在线变成了下线,或者由主节点变成了从节点等等。
MEET、PING、PONG消息的实现
Redis集群中的各个节点通过Gossip协议来交换各自关于不同节点的状态信息,其中 Gossip协议由MEET、PING、PONG三种消息实现,这三种消息的正文都由两个cluster. h/clusterMsgDataGossip结构组成:
因为MEET、PING、PONG三种消息都使用相同的消息正文,所以节点通过消息头的 type属性来判断一条消息是MEET消息、PING消息还是PONG消息。
每次发送MEET、PING、PONG消息时,发送者都从自己的已知节点列表中随机选 出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个 clusterMsgDataGossip结构里面。
clusterMsgDataGossip结构记录了被选中节点的名字,发送者与被选中节点最后 一次发送和接收PING消息和PONG消息的时间戳,被选中节点的IP地址和端口号,以及 被选中节点的标识值:
当接收者收到MEET、PING、PONG消息时,接收者会访问消息正文中的两个 clusterMsgDataGossip结构,并根据自己是否认识clusterMsgDataGossip结构中 记录的被选中节点来选择进行哪种操作:
如果被选中节点不存在于接收者的已知节点列表,那么说明接收者是第一次接触到 被选中节点,接收者将根据结构中记录的IP地址和端口号等信息,与被选中节点进 行握手。
如果被选中节点已经存在于接收者的已知节点列表,那么说明接收者之前已经与被 选中节点进行过接触,接收者将根据clusterMsgDataGossip结构记录的信息, 对被选中节点所对应的clusterNode结构进行更新。
举个发送PING消息和返回PONG消息的例子,假设在一个包含A、B、C、D、E、F六个 节点的集群里:
节点A向节点D发送PING消息,并且消息里面包含了节点B和节点C的信息,当 节点D收到这条PING消息时,它将更新自己对节点B和节点C的认识。
之后,节点D将向节点A返回一条PONG消息,并且消息里面包含了节点E和节点F 的消息,当节点A收到这条PONG消息时,它将更新自己对节点E和节点F的认识。
整个通信过程如图所示。
FAIL消息的实现
当集群里的主节点A将主节点B标记为己下线(FAIL)时,主节点A将向集群广播一 条关于主节点B的FAIL消息,所有接收到这条FAIL消息的节点都会将主节点B标记为 已下线。
在集群的节点数量比较大的情况下,单纯使用Gossip协议来传播节点的已下线信息会 给节点的信息更新带来一定延迟,因为Gossip协议消息通常需要一段时间才能传播至整个 集群,而发送FAIL消息可以让集群里的所有节点立即知道某个主节点已下线,从而尽快判 断是否需要将集群标记为下线,又或者对下线主节点进行故障转移。
FAIL消息的正文由cluster.h/clusterMsgDataFail结构表示,这个结构只包 含一个nodename属性,该属性记录了已下线节点的名字:
因为集群里的所有节点都有一个独一无二的名字,所以FAIL消息里面只需要保存下线 节点的名字,接收到消息的节点就可以根据这个名字来判断是哪个节点下线了。
举个例子,对于包含7000、7001、7002、7003四个主节点的集群来说:
如果主节点7001发现主节点7000已下线,那么主节点7001将向主节点7002和主节 点7003发送FAIL消息,其中FAIL消息中包含的节点名字为主节点7000的名字, 以此来表示主节点7000已下线。
当主节点7002和主节点7003都接收到主节点7001发送的FAIL消息时,它们也会 将主节点7000标记为己下线。
因为这时集群已经有超过一半的主节点认为主节点7000已下线,所以集群剩下的几 个主节点可以判断是否需要将集群标记为下线,又或者开始对主节点7000进行故障 转移。
图展示了节点发送和接收FAIL消息的整个过程。
PUBLISH消息的实现
当客户端向集群中的某个节点发送命令:
PUBLISH <channel> <message>的时候,接收到PUBLISH命令的节点不仅会向channel频道发送消息message, 它还会 向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会向channel 频道发送message消息。
换句话说,向集群中的某个节点发送命令:
PUBLISH <channel> <message>
将导致集群中的所有节点都向channel频道发送message消息。
举个例子,对于包含7000、7001、7002、7003四个节点的集群来说,如果节点7000 收到了客户端发送的PVIJLISH命令,那么节点7000将向7001、7002、7003三个节点发送PUBLISH消息,如图所示。
PUBLISH消息的正文由cluster.h/clusterMsgDataPublish结构表示:
clusterMsgDataPublish结构的 bulk_data属性是一个字节数组,这个字节数组保存了客户端通过PUBLISH命令发送给节点接收到PUBLISH命令的节点7000的channel参数和message参数,而结构的 channel_len和message_len则分别保存了channel参数的长度和message参数的长度:
其中bulk_data的0字节至channel len-1字节保存的是channel参数。 □ 而hulk data的channel_len字节channel_len+message_len-1字节 保存的则是message参数。
举个例子,如果节点收到的PUBLISH命令为:
那么节点发送的PUBLISH消息的clusterMsgOataPublish结构将如图所示: 其中bulk_data数组的前t个字节保存了channel参数的值"news.it", 而bulk_ data数组的后五个字节则保存了message参数的值"hello"。
Redis集群与涉及多节点的操作
可以参考 【面试】吃透了这些Redis知识点,面试官一定觉得你很NB(干货 | 建议珍藏) - 编程新说(李新杰) - 博客园
https://img2018.cnblogs.com/blog/978070/201904/978070-20190419092057997-344242939.png
集群的搭建
这一部分我们将搭建一个简单的集群:共6个节点,3主3从。方便起见:所有节点在同一台服务器上,以端口号进行区分;配置从简。3个主节点端口号:7000/7001/7002,对应的从节点端口号:8000/8001/8002。
集群的搭建有两种方式:(1)手动执行Redis命令,一步步完成搭建;(2)使用Ruby脚本搭建。二者搭建的原理是一样的,只是Ruby脚本将Redis命令进行了打包封装;在实际应用中推荐使用脚本方式,简单快捷不容易出错。下面分别介绍这两种方式。
执行Redis命令搭建集群
集群的搭建可以分为四步:(1)启动节点:将节点以集群模式启动,此时节点是独立的,并没有建立联系;(2)节点握手:让独立的节点连成一个网络;(3)分配槽:将16384个槽分配给主节点;(4)指定主从关系:为从节点指定主节点。
实际上,前三步完成后集群便可以对外提供服务;但指定从节点后,集群才能够提供真正高可用的服务。
启动节点
集群节点的启动仍然是使用redis-server命令,但需要使用集群模式启动。下面是7000节点的配置文件(只列出了节点正常工作关键配置,其他配置(如开启AOF)可以参照单机节点进行):
#redis-7000.conf
port 7000
cluster-enabled yes
cluster-config-file "node-7000.conf"
logfile "log-7000.log"
dbfilename "dump-7000.rdb"
daemonize yes
其中的cluster-enabled和cluster-config-file是与集群相关的配置。
cluster-enabled yes:Redis实例可以分为单机模式(standalone)和集群模式(cluster);cluster-enabled yes可以启动集群模式。在单机模式下启动的Redis实例,如果执行info server命令,可以发现redis_mode一项为standalone,如下图所示:
集群模式下的节点,其redis_mode为cluster,如下图所示:
cluster-config-file:该参数指定了集群配置文件的位置。每个节点在运行过程中,会维护一份集群配置文件;每当集群信息发生变化时(如增减节点),集群内所有节点会将最新信息更新到该配置文件;当节点重启后,会重新读取该配置文件,获取集群信息,可以方便的重新加入到集群中。也就是说,当Redis节点以集群模式启动时,会首先寻找是否有集群配置文件,如果有则使用文件中的配置启动,如果没有,则初始化配置并将配置保存到文件中。集群配置文件由Redis节点维护,不需要人工修改。
编辑好配置文件后,使用redis-server命令启动该节点:
redis-server redis-7000.conf
节点启动以后,通过cluster nodes命令可以查看节点的情况,如下图所示。
其中返回值第一项表示节点id,由40个16进制字符串组成,节点id与 主从复制 一文中提到的runId不同:Redis每次启动runId都会重新创建,但是节点id只在集群初始化时创建一次,然后保存到集群配置文件中,以后节点重新启动时会直接在集群配置文件中读取。
其他节点使用相同办法启动,不再赘述。需要特别注意,在启动节点阶段,节点是没有主从关系的,因此从节点不需要加slaveof配置。
节点握手
节点启动以后是相互独立的,并不知道其他节点存在;需要进行节点握手,将独立的节点组成一个网络。
节点握手使用cluster meet {ip} {port}命令实现,例如在7000节点中执行cluster meet 192.168.72.128 7001,可以完成7000节点和7001节点的握手;注意ip使用的是局域网ip而不是localhost或127.0.0.1,是为了其他机器上的节点或客户端也可以访问。此时再使用cluster nodes查看:
在7001节点下也可以类似查看:
同理,在7000节点中使用cluster meet命令,可以将所有节点加入到集群,完成节点握手:
cluster meet 192.168.72.128 7002
cluster meet 192.168.72.128 8000
cluster meet 192.168.72.128 8001
cluster meet 192.168.72.128 8002
执行完上述命令后,可以看到7000节点已经感知到了所有其他节点:
通过节点之间的通信,每个节点都可以感知到所有其他节点,以8000节点为例:
分配槽
在Redis集群中,借助槽实现数据分区,具体原理后文会介绍。集群有16384个槽,槽是数据管理和迁移的基本单位。当数据库中的16384个槽都分配了节点时,集群处于上线状态(ok);如果有任意一个槽没有分配节点,则集群处于下线状态(fail)。
cluster info命令可以查看集群状态,分配槽之前状态为fail:
分配槽使用cluster addslots命令,执行下面的命令将槽(编号0-16383)全部分配完毕:
redis-cli -p 7000 cluster addslots {0..5461}
redis-cli -p 7001 cluster addslots {5462..10922}
redis-cli -p 7002 cluster addslots {10923..16383}
此时查看集群状态,显示所有槽分配完毕,集群进入上线状态:
指定主从关系
集群中指定主从关系不再使用slaveof命令,而是使用cluster replicate命令;参数使用节点id。
通过cluster nodes获得几个主节点的节点id后,执行下面的命令为每个从节点指定主节点:
redis-cli -p 8000 cluster replicate be816eba968bc16c884b963d768c945e86ac51ae
redis-cli -p 8001 cluster replicate 788b361563acb175ce8232569347812a12f1fdb4
redis-cli -p 8002 cluster replicate a26f1624a3da3e5197dde267de683d61bb2dcbf1
此时执行cluster nodes查看各个节点的状态,可以看到主从关系已经建立。
至此,集群搭建完毕。
使用Ruby脚本搭建集群
在{REDIS_HOME}/src目录下可以看到redis-trib.rb文件,这是一个Ruby脚本,可以实现自动化的集群搭建。
(1)安装Ruby环境
以Ubuntu为例,如下操作即可安装Ruby环境:
apt-get install ruby #安装ruby环境
gem install redis #gem是ruby的包管理工具,该命令可以安装ruby-redis依赖
(2)启动节点
与第一种方法中的“启动节点”完全相同。
(3)搭建集群
redis-trib.rb脚本提供了众多命令,其中create用于搭建集群,使用方法如下:
./redis-trib.rb create --replicas 1 192.168.72.128:7000 192.168.72.128:7001
192.168.72.128:7002 192.168.72.128:8000 192.168.72.128:8001 192.168.72.128:8002
其中:--replicas=1表示每个主节点有1个从节点;后面的多个{ip:port}表示节点地址,前面的做主节点,后面的做从节点。使用redis-trib.rb搭建集群时,要求节点不能包含任何槽和数据。
执行创建命令后,脚本会给出创建集群的计划,如下图所示;计划包括哪些是主节点,哪些是从节点,以及如何分配槽。
输入yes确认执行计划,脚本便开始按照计划执行,如下图所示。
至此,集群搭建完毕。
集群方案设计
设计集群方案时,至少要考虑以下因素:
(1)高可用要求:根据故障转移的原理,至少需要3个主节点才能完成故障转移,且3个主节点不应在同一台物理机上;每个主节点至少需要1个从节点,且主从节点不应在一台物理机上;因此高可用集群至少包含6个节点。
(2)数据量和访问量:估算应用需要的数据量和总访问量(考虑业务发展,留有冗余),结合每个主节点的容量和能承受的访问量(可以通过benchmark得到较准确估计),计算需要的主节点数量。
(3)节点数量限制:Redis官方给出的节点数量限制为1000,主要是考虑节点间通信带来的消耗。在实际应用中应尽量避免大集群;如果节点数量不足以满足应用对Redis数据量和访问量的要求,可以考虑:(1)业务分割,大集群分为多个小集群;(2)减少不必要的数据;(3)调整数据过期策略等。
(4)适度冗余:Redis可以在不影响集群服务的情况下增加节点,因此节点数量适当冗余即可,不用太大。
客户端访问集群
在集群中,数据分布在不同的节点中,客户端通过某节点访问数据时,数据可能不在该节点中;下面介绍集群是如何处理这个问题的。
redis-cli
当节点收到redis-cli发来的命令(如set/get)时,过程如下:
(1)计算key属于哪个槽:CRC16(key) & 16383
集群提供的cluster keyslot命令也是使用上述公式实现,如:
(2)判断key所在的槽是否在当前节点:假设key位于第i个槽,clusterState.slots[i]则指向了槽所在的节点,如果clusterState.slots[i]==clusterState.myself,说明槽在当前节点,可以直接在当前节点执行命令;否则,说明槽不在当前节点,则查询槽所在节点的地址(clusterState.slots[i].ip/port),并将其包装到MOVED错误中返回给redis-cli。
(3)redis-cli收到MOVED错误后,根据返回的ip和port重新发送请求。
下面的例子展示了redis-cli和集群的互动过程:在7000节点中操作key1,但key1所在的槽9189在节点7001中,因此节点返回MOVED错误(包含7001节点的ip和port)给redis-cli,redis-cli重新向7001发起请求。
上例中,redis-cli通过-c指定了集群模式,如果没有指定,redis-cli无法处理MOVED错误:
Smart客户端
redis-cli这一类客户端称为Dummy客户端,因为它们在执行命令前不知道数据在哪个节点,需要借助MOVED错误重新定向。与Dummy客户端相对应的是Smart客户端。
Smart客户端(以Java的JedisCluster为例)的基本原理:
(1)JedisCluster初始化时,在内部维护slot->node的缓存,方法是连接任一节点,执行cluster slots命令,该命令返回如下所示:
(2)此外,JedisCluster为每个节点创建连接池(即JedisPool)。
(3)当执行命令时,JedisCluster根据key->slot->node选择需要连接的节点,发送命令。如果成功,则命令执行完毕。如果执行失败,则会随机选择其他节点进行重试,并在出现MOVED错误时,使用cluster slots重新同步slot->node的映射关系。
下面代码演示了如何使用JedisCluster访问集群(未考虑资源释放、异常处理等):
public static void test() {
Set<HostAndPort> nodes = new HashSet<>();
nodes.add(new HostAndPort("192.168.72.128", 7000));
nodes.add(new HostAndPort("192.168.72.128", 7001));
nodes.add(new HostAndPort("192.168.72.128", 7002));
nodes.add(new HostAndPort("192.168.72.128", 8000));
nodes.add(new HostAndPort("192.168.72.128", 8001));
nodes.add(new HostAndPort("192.168.72.128", 8002));
JedisCluster cluster = new JedisCluster(nodes);
System.out.println(cluster.get("key1"));
cluster.close();
}
注意事项如下:
(1)JedisCluster中已经包含所有节点的连接池,因此JedisCluster要使用单例。
(2)客户端维护了slot->node映射关系以及为每个节点创建了连接池,当节点数量较多时,应注意客户端内存资源和连接资源的消耗。
(3)Jedis较新版本针对JedisCluster做了一些性能方面的优化,如cluster slots缓存更新和锁阻塞等方面的优化,应尽量使用2.8.2及以上版本的Jedis。
实践须知
前面介绍了集群正常运行和访问的方法和原理,下面是一些重要的补充内容。
集群伸缩
实践中常常需要对集群进行伸缩,如访问量增大时的扩容操作。Redis集群可以在不影响对外服务的情况下实现伸缩;伸缩的核心是槽迁移:修改槽与节点的对应关系,实现槽(即数据)在节点之间的移动。例如,如果槽均匀分布在集群的3个节点中,此时增加一个节点,则需要从3个节点中分别拿出一部分槽给新节点,从而实现槽在4个节点中的均匀分布。
增加节点
假设要增加7003和8003节点,其中8003是7003的从节点;步骤如下:
(1)启动节点:方法参见集群搭建
(2)节点握手:可以使用cluster meet命令,但在生产环境中建议使用redis-trib.rb的add-node工具,其原理也是cluster meet,但它会先检查新节点是否已加入其它集群或者存在数据,避免加入到集群后带来混乱。
redis-trib.rb add-node 192.168.72.128:7003 192.168.72.128 7000
redis-trib.rb add-node 192.168.72.128:8003 192.168.72.128 7000
(3)迁移槽:推荐使用redis-trib.rb的reshard工具实现。reshard自动化程度很高,只需要输入redis-trib.rb reshard ip:port (ip和port可以是集群中的任一节点),然后按照提示输入以下信息,槽迁移会自动完成:
待迁移的槽数量:16384个槽均分给4个节点,每个节点4096个槽,因此待迁移槽数量为4096
目标节点id:7003节点的id
源节点的id:7000/7001/7002节点的id
(4)指定主从关系:方法参见集群搭建
减少节点
假设要下线7000/8000节点,可以分为两步:
(1)迁移槽:使用reshard将7000节点中的槽均匀迁移到7001/7002/7003节点
(2)下线节点:使用redis-trib.rb del-node工具;应先下线从节点再下线主节点,因为若主节点先下线,从节点会被指向其他主节点,造成不必要的全量复制。
redis-trib.rb del-node 192.168.72.128:7001 {节点8000的id}
redis-trib.rb del-node 192.168.72.128:7001 {节点7000的id}
ASK错误
集群伸缩的核心是槽迁移。在槽迁移过程中,如果客户端向源节点发送命令,源节点执行流程如下:
客户端收到ASK错误后,从中读取目标节点的地址信息,并向目标节点重新发送请求,就像收到MOVED错误时一样。但是二者有很大区别:ASK错误说明数据正在迁移,不知道何时迁移完成,因此重定向是临时的,SMART客户端不会刷新slots缓存;MOVED错误重定向则是(相对)永久的,SMART客户端会刷新slots缓存。
故障转移
但集群的实现与哨兵思路类似:通过定时任务发送PING消息检测其他节点状态;节点下线分为主观下线和客观下线;客观下线后选取从节点进行故障转移。
与哨兵一样,集群只实现了主节点的故障转移;从节点故障时只会被下线,不会进行故障转移。因此,使用集群时,应谨慎使用读写分离技术,因为从节点故障会导致读服务不可用,可用性变差。
这里不再详细介绍故障转移的细节,只对重要事项进行说明:
节点数量:在故障转移阶段,需要由主节点投票选出哪个从节点成为新的主节点;从节点选举胜出需要的票数为N/2+1;其中N为主节点数量(包括故障主节点),但故障主节点实际上不能投票。因此为了能够在故障发生时顺利选出从节点,集群中至少需要3个主节点(且部署在不同的物理机上)。
故障转移时间:从主节点故障发生到完成转移,所需要的时间主要消耗在主观下线识别、主观下线传播、选举延迟等几个环节;具体时间与参数cluster-node-timeout有关,一般来说:
故障转移时间(毫秒) ≤ 1.5 * cluster-node-timeout + 1000
cluster-node-timeout的默认值为15000ms(15s),因此故障转移时间会在20s量级。
集群的限制及应对方法
由于集群中的数据分布在不同节点中,导致一些功能受限,包括:
(1)key批量操作受限:例如mget、mset操作,只有当操作的key都位于一个槽时,才能进行。针对该问题,一种思路是在客户端记录槽与key的信息,每次针对特定槽执行mget/mset;另外一种思路是使用Hash Tag,将在下一小节介绍。
(2)keys/flushall等操作:keys/flushall等操作可以在任一节点执行,但是结果只针对当前节点,例如keys操作只返回当前节点的所有键。针对该问题,可以在客户端使用cluster nodes获取所有节点信息,并对其中的所有主节点执行keys/flushall等操作。
(3)事务/Lua脚本:集群支持事务及Lua脚本,但前提条件是所涉及的key必须在同一个节点。Hash Tag可以解决该问题。
(4)数据库:单机Redis节点可以支持16个数据库,集群模式下只支持一个,即db0。
(5)复制结构:只支持一层复制结构,不支持嵌套。
Hash Tag
Hash Tag原理是:当一个key包含 {} 的时候,不对整个key做hash,而仅对 {} 包括的字符串做hash。
Hash Tag可以让不同的key拥有相同的hash值,从而分配在同一个槽里;这样针对不同key的批量操作(mget/mset等),以及事务、Lua脚本等都可以支持。不过Hash Tag可能会带来数据分配不均的问题,这时需要:(1)调整不同节点中槽的数量,使数据分布尽量均匀;(2)避免对热点数据使用Hash Tag,导致请求分布不均。
下面是使用Hash Tag的一个例子;通过对product加Hash Tag,可以将所有产品信息放到同一个槽中,便于操作。
参数优化
cluster_node_timeout
cluster_node_timeout参数在前面已经初步介绍;它的默认值是15s,影响包括:
(1)影响PING消息接收节点的选择:值越大对延迟容忍度越高,选择的接收节点越少,可以降低带宽,但会降低收敛速度;应根据带宽情况和应用要求进行调整。
(2)影响故障转移的判定和时间:值越大,越不容易误判,但完成转移消耗时间越长;应根据网络状况和应用要求进行调整。
cluster-require-full-coverage
前面提到,只有当16384个槽全部分配完毕时,集群才能上线。这样做是为了保证集群的完整性,但同时也带来了新的问题:当主节点发生故障而故障转移尚未完成,原主节点中的槽不在任何节点中,此时会集群处于下线状态,无法响应客户端的请求。
cluster-require-full-coverage参数可以改变这一设定:如果设置为no,则当槽没有完全分配时,集群仍可以上线。参数默认值为yes,如果应用对可用性要求较高,可以修改为no,但需要自己保证槽全部分配。
redis-trib.rb
redis-trib.rb提供了众多实用工具:创建集群、增减节点、槽迁移、检查完整性、数据重新平衡等;通过help命令可以查看详细信息。在实践中如果能使用redis-trib.rb工具则尽量使用,不但方便快捷,还可以大大降低出错概率。
Codis
服务端路由一般指的就是,有个代理层专门对接客户端的请求,然后再转发到Redis集群进行处理
现在比较流行的是Codis。它与Redis Cluster最大的区别就是,Redis Cluster是直连Redis实例的,而Codis则客户端直连Proxy,再由Proxy进行分发到不同的Redis实例进行处理
在Codis对Key路由的方案跟Redis Cluster很类似,Codis初始化出1024个哈希槽,然后分配到不同的Redis服务器中
哈希槽与Redis实例的映射关系由Zookeeper进行存储和管理,Proxy会通过Codis DashBoard得到最新的映射关系,并缓存在本地上
扩容Codis Redis实例
简单来说就是:把新的Redis实例加入到集群中,然后把部分数据迁移到新的实例上
大概的过程就是:1.「原实例」某一个Solt的部分数据发送给「目标实例」。2.「目标实例」收到数据后,给「原实例」返回ack。3.「原实例」收到ack之后,在本地删除掉刚刚给「目标实例」的数据。4.不断循环1、2、3步骤,直至整个solt迁移完毕
Codis也是支持「异步迁移」的,针对上面的步骤2,「原实例」发送数据后,不等待「目标实例」返回ack,就继续接收客户端的请求。
未迁移完的数据标记为「只读」,不会影响到数据的一致性。如果对迁移中的数据存在「写操作」,那会让客户端进行「重试」,最后会写到「目标实例」上
还有就是,针对 bigkey,异步迁移采用了「拆分指令」的方式进行迁移,比如有个set元素有10000个,那「原实例」可能就发送10000条命令给「目标实例」,而不是一整个bigkey一次性迁移(因为大对象容易造成阻塞)
二种架构对比
主从、哨兵、集群各自架构的优点和缺点对比
单机模式
Redis 单副本,采用单个 Redis 节点部署架构,没有备用节点实时同步数据,不提供数据持久化和备份策略,适用于数据可靠性要求不高的纯缓存业务场景。
优点:
架构简单,部署方便。
高性价比:缓存使用时无需备用节点(单实例可用性可以用 supervisor 或 crontab 保证),当然为了满足业务的高可用性,也可以牺牲一个备用节点,但同时刻只有一个实例对外提供服务。
高性能。
缺点:
不保证数据的可靠性。
在缓存使用,进程重启后,数据丢失,即使有备用的节点解决高可用性,但是仍然不能解决缓存预热问题,因此不适用于数据可靠性要求高的业务。
高性能受限于单核 CPU 的处理能力(Redis 是单线程机制),CPU 为主要瓶颈,所以适合操作命令简单,排序、计算较少的场景。也可以考虑用 Memcached 替代。
主从架构
主(master)和 从(slave)部署在不同的服务器上,当主节点服务器写入数据时会同步到从节点的服务器上,一般主节点负责写入数据,从节点负责读取数据。
从节点设置只读属性,而主节点没有只写属性,因此,主节点可读可以写
优点:
读写分离,提高效率
主节点负责写操作,从节点负责读操作;如果写少读多场景,配置多个从节点的话,效率非常高
数据热备份,提供多个副本。
从节点宕机,影响较小
缺点:
主节点故障,集群则无法进行工作,可用性比较低,从节点升主节点需要人工手动干预。
因为只有主节点能进行写操作,一旦主节点宕机,整个服务就无法使用。当然此时从节点仍可以进行读操作,但是对于整个服务流程来说,是无法使用的。
Master的写的压力难以降低。
如果写操作比较多,那么只有一个主节点的话,无法分担压力。
主节点存储能力受到单击限制。
主节点只能有一个,因此单节点内存大小不会太大,因此存储数据量受限。
主从数据同步,可能产生部分的性能影响甚至同步风暴。
风暴问题,对于任何集群分布式来说都存在,要合理分布节点。
哨兵
为了解决这两个问题,在2.8版本之后redis正式提供了sentinel架构。
在redis3.0以前的版本要实现集群一般是借助哨兵sentinel工具来监控master节点的状态。
如果master节点异常,则会做主从切换,将某一台slave作为master,哨兵的配置略微复杂,并且性能和高可用性等各方面表现一般。
哨兵模式核心还是主从复制,只不过在相对于主从模式在主节点宕机导致不可写的情况下,多了一个竞选机制:从所有的从节点竞选出新的主节点。竞选机制的实现,是依赖于在系统中启动一个sentinel进程。
哨兵本身也有单点故障的问题,所以在一个一主多从的Redis系统中,可以使用多个哨兵进行监控,哨兵不仅会监控主数据库和从数据库,哨兵之间也会相互监控。每一个哨兵都是一个独立的进程,作为进程,它会独立运行。
优点:
对节点进行监控,来完成自动的故障发现与转移
缺点:
哨兵模式是中心化的集群实现方案,每个从机和主机的耦合度很高,master宕机到salve选举master恢复期间服务不可用。等待时间比较长,至少十来秒不可用。
哨兵模式始终只有一个Redis主机来接收和处理写请求,写操作还是受单机瓶颈影响,没有实现真正的分布式架构,没法支持很高的并发
单个主节点内存也不宜设置得过大,否则会导致持久化文件过大,影响数据恢复或主从同步的效率。
哨兵模式下每台 Redis 服务器都存储相同的数据,很浪费内存空间
与主从相比,哨兵仅解决了手动切换主从节点问题,至于其他的问题,基本上仍然存在。
哨兵的主要问题还是由于中心架构,仅存在一个master节点引起的,写的效率太低。
集群模式
Redis Cluster 是 3.0 版后推出的 Redis 分布式集群解决方案,主要解决 Redis 分布式方面的需求,比如,当遇到单机内存,并发和流量等瓶颈的时候,Redis Cluster 能起到很好的负载均衡的目的。
Redis Cluster是一种服务器Sharding技术(分片和路由都是在服务端实现),采用多主多从,每一个分区都是由一个Redis主机和多个从机组成,片区和片区之间是相互平行的。Redis Cluster集群采用了P2P的模式,完全去中心化。
Redis Cluster 集群节点最小配置 6 个节点以上(3 主 3 从),其中主节点提供读写操作,从节点作为备用节点,不提供请求,只作为故障转移使用。Redis Cluster 采用虚拟槽分区,所有的键根据哈希函数映射到 0~16383 个整数槽内,每个节点负责维护一部分槽以及槽所印映射的键值数据。
注意:集群模式下 从节点不提供读写,与主从模式不一样。 总结一下经验,分布式场景下:集群模式一般从节点不参与读写,仅作为备用节点。而主从一般都要负责读或写,都要参与具体的工作。
redis cluster主要是针对海量数据+高并发+高可用的场景,海量数据,如果你的数据量很大,那么建议就用redis cluster,数据量不是很大时,使用sentinel就够了。redis cluster的性能和高可用性均优于哨兵模式。
Redis Cluster采用虚拟哈希槽分区而非一致性hash算法,预先分配一些卡槽,所有的键根据哈希函数映射到这些槽内,每一个分区内的master节点负责维护一部分槽以及槽所映射的键值数据。
优点:
无中心架构。
即有多个master节点,不像哨兵模式下仅有一个。这样写的压力就可以分散了;并且存储量也可以扩展了,因为多个主节点都可以存储一部分数据,总量要远大于单主节点架构。
数据按照 slot 存储分布在多个节点,节点间数据共享,可动态调整数据分布。
可扩展性:可线性扩展到 1000 多个节点,节点可动态添加或删除。
高可用性:部分节点不可用时,集群仍可用。通过增加 Slave 做 standby 数据副本,能够 实现故障自动 failover,节点之间通过 gossip 协议交换状态信息,用投票机制完成 Slave 到 Master 的角色提升。
当然,如果某个槽归属的小群内都不可用时,整个服务仍然是不可用的!通过cluster-require-full-coverageyes 控制该特性, 默认yes 即需要集群完整,方可对外提供服务,设置为no ,其他的小集群仍然可以对外提供服务。
缺点:
如果主节点A和它的从节点A1都宕机了,那么该集群就无法再提供服务了。