05-Redis 多机之3 - 集群
1 Redis 集群
Redis的高可用技术有:持久化、主从复制和哨兵,但这些方案仍有不足,其中最主要的问题是存储能力受单机限制,以及无法实现写操作的负载均衡。
Redis集群解决了上述问题,实现了较为完善的高可用方案。集群,即Redis Cluster,是Redis 3.0开始引入的分布式存储方案。
上图展示了 Redis Cluster 典型的架构图,集群中的每一个 Redis 节点都 互相两两相连,客户端任意 直连 到集群中的 任意一台,就可以对其他 Redis 节点进行 读写 的操作。
2 基本原理
Redis 集群中内置了 16384
个哈希槽。当客户端连接到 Redis 集群之后,会同时得到一份关于这个 集群的配置信息,当客户端具体对某一个 key
值进行操作时,会计算出它的一个 Hash 值,然后把结果对 16384
求余数,这样每个 key
都会对应一个编号在 0-16383
之间的哈希槽,Redis 会根据节点数量 大致均等 的将哈希槽映射到不同的节点。
再结合集群的配置信息就能够知道这个 key
值应该存储在哪一个具体的 Redis 节点中,如果不属于自己管,那么就会使用一个特殊的 MOVED
命令来进行一个跳转,告诉客户端去连接这个节点以获取数据:
1 |
|
MOVED
指令第一个参数 3999
是 key
对应的槽位编号,后面是目标节点地址,MOVED
命令前面有一个减号,表示这是一个错误的消息。客户端在收到 MOVED
指令后,就立即纠正本地的 槽位映射表,那么下一次再访问 key
时就能够到正确的地方去获取了。
3 集群的主要作用
数据分区: 数据分区(或称数据分片)是集群最核心的功能。集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加;
另一方面 每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。
Redis 单机内存大小受限问题,例如,如果单机内存太大,bgsave
和bgrewriteaof
的fork
操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出。高可用: 集群支持主从复制和主节点的 自动故障转移 (与哨兵类似),当任一节点发生故障时,集群仍然可以对外提供服务。
4 快速体验
我们将搭建一个简单的集群:共6个节点,3主3从。方便起见:所有节点在同一台服务器上,以端口号进行区分;配置从简。端口号分别为:7000/7001/7002/7003/7004/7005。
4.1 第一步:创建集群节点配置文件
首先我们找一个地方创建一个名为 redis-cluster
的目录:
1 |
|
然后按照上面的方法,创建六个配置文件,分别命名为:redis_7000.conf
/redis_7001.conf
…..redis_7005.conf
,然后根据不同的端口号修改对应的端口值就好了:
1 |
|
记得把对应上述配置文件中根端口对应的配置都修改掉 (port/ pidfile/ cluster-config-file)。
4.2 第二步:分别启动 6 个 Redis 实例
1 |
|
然后执行 ps -ef | grep redis
查看是否启动成功:
可以看到 6
个 Redis 节点都以集群的方式成功启动了,但是现在每个节点还处于独立的状态,也就是说它们每一个都各自成了一个集群,还没有互相联系起来,我们需要手动地把他们之间建立起联系。
4.3 第三步:建立集群
执行下列命令:(redis5以前的版本集群是依靠ruby脚本redis-trib.rb实现)
1 |
|
--replicas 1
的意思是:我们希望为集群中的每个主节点创建一个从节点。
观察控制台输出:
看到 [OK]
的信息之后,就表示集群已经搭建成功了,可以看到,这里我们正确地创建了三主三从的集群。
4.4 第四步:验证集群
我们先使用 redic-cli
任意连接一个节点:
1 |
|
-c
表示集群模式;-h
指定 ip 地址;-p
指定端口。
然后随便 set
一些值观察控制台输入:
1 |
|
可以看到这里 Redis 自动帮我们进行了 Redirected
操作跳转到了 7001
这个实例上。
我们再使用 cluster info
(查看集群信息) 和 cluster nodes
(查看节点列表) 来分别看看:_(任意节点输入均可)_
1 |
|
5 集群的基本原理
5.1 数据分区方案
5.2 节点通信机制简析
集群的建立离不开节点之间的通信,例如我们在刚启动六个集群节点之后通过 redis-cli
命令帮助我们搭建起来了集群,实际上背后每个集群之间的两两连接是通过了 CLUSTER MEET <ip> <port>
命令发送 MEET
消息完成的,下面我们展开详细说说。
5.2.1 两个端口
在 哨兵系统 中,节点分为 数据节点 和 哨兵节点:前者存储数据,后者实现额外的控制功能。在 集群 中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个 TCP 端口:
- 普通端口: 即我们在前面指定的端口 (7000等)。普通端口主要用于为客户端提供服务 (与单机节点类似);但在节点间数据迁移时也会使用。
- 集群端口: 端口号是普通端口 + 10000 (10000是固定值,无法改变),如
7000
节点的集群端口为17000
。
集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。
5.2.2 Gossip 协议
节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。重点是广播和 Gossip 的对比。
- 广播是指向集群内所有节点发送消息。
- 优点 是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的)。
- 缺点 是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
- Gossip 协议的特点是:在节点数量有限的网络中,每个节点都 “随机” 的与部分节点通信 (并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。
- 优点 是负载(比广播)低、去中心化、容错性高(因为通信有冗余)等;
- 缺点 主要是集群的收敛速度慢。
5.2.3 消息类型
集群中的节点采用 固定频率(每秒10次) 的 定时任务 进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。
节点间发送的消息主要分为 5
种:meet 消息
、ping 消息
、pong 消息
、fail 消息
、publish 消息
。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的:
- MEET 消息: 在节点握手阶段,当节点收到客户端的
CLUSTER MEET
命令时,会向新加入的节点发送MEET
消息,请求新节点加入到当前集群;新节点收到 MEET 消息后会回复一个PONG
消息。 - PING 消息: 集群里每个节点每秒钟会选择部分节点发送
PING
消息,接收者收到消息后会回复一个PONG
消息。PING 消息的内容是自身节点和部分其他节点的状态信息,作用是彼此交换信息,以及检测节点是否在线。PING
消息使用 Gossip 协议发送,接收节点的选择兼顾了收敛速度和带宽成本,具体规则如下:(1)随机找 5 个节点,在其中选择最久没有通信的 1 个节点;(2)扫描节点列表,选择最近一次收到PONG
消息时间大于cluster_node_timeout / 2
的所有节点,防止这些节点长时间未更新。 - PONG消息:
PONG
消息封装了自身状态数据。可以分为两种:第一种 是在接到MEET/PING
消息后回复的PONG
消息;第二种 是指节点向集群广播PONG
消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播PONG
消息。 - FAIL 消息: 当一个主节点判断另一个主节点进入
FAIL
状态时,会向集群广播这一FAIL
消息;接收节点会将这一FAIL
消息保存起来,便于后续的判断。 - PUBLISH 消息: 节点收到
PUBLISH
命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该PUBLISH
命令。
5.3 数据结构简析
节点需要专门的数据结构来存储集群的状态。所谓集群的状态,是一个比较大的概念,包括:集群是否处于上线状态、集群中有哪些节点、节点是否可达、节点的主从状态、槽的分布……
节点为了存储集群状态而提供的数据结构中,最关键的是 clusterNode
和 clusterState
结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。
5.3.1 clusterNode 结构
clusterNode
结构保存了 一个节点的当前状态,包括创建时间、节点 id、ip 和端口号等。每个节点都会用一个 clusterNode
结构记录自己的状态,并为集群内所有其他节点都创建一个 clusterNode
结构来记录节点状态。
下面列举了 clusterNode
的部分字段,并说明了字段的含义和作用:
1 |
|
除了上述字段,clusterNode
还包含节点连接、主从复制、故障发现和转移需要的信息等。
5.3.2 clusterState 结构
clusterState
结构保存了在当前节点视角下,集群所处的状态。主要字段包括:
1 |
|
除此之外,clusterState
还包括故障转移、槽迁移等需要的信息。
5.4 集群命令的实现
这一部分将以cluster meet(节点握手)、cluster addslots(槽分配)为例,说明节点是如何利用上述数据结构和通信机制实现集群命令的。
5.4.1 cluster meet
假设要向A节点发送cluster meet命令,将B节点加入到A所在的集群,则A节点收到命令后,执行的操作如下:
- A为B创建一个clusterNode结构,并将其添加到clusterState的nodes字典中
- A向B发送MEET消息
- B收到MEET消息后,会为A创建一个clusterNode结构,并将其添加到clusterState的nodes字典中
- B回复A一个PONG消息
- A收到B的PONG消息后,便知道B已经成功接收自己的MEET消息
- 然后,A向B返回一个PING消息
- B收到A的PING消息后,便知道A已经成功接收自己的PONG消息,握手完成
- 之后,A通过Gossip协议将B的信息广播给集群内其他节点,其他节点也会与B握手;一段时间后,集群收敛,B成为集群内的一个普通节点
通过上述过程可以发现,集群中两个节点的握手过程与TCP类似,都是三次握手:A向B发送MEET;B向A发送PONG;A向B发送PING。
5.4.2 cluster addslots
集群中槽的分配信息,存储在clusterNode的slots数组和clusterState的slots数组中,两个数组的结构前面已做介绍;二者的区别在于:前者存储的是该节点中分配了哪些槽,后者存储的是集群中所有槽分别分布在哪个节点。
cluster addslots命令接收一个槽或多个槽作为参数,例如在A节点上执行cluster addslots {0..10}命令,是将编号为0-10的槽分配给A节点,具体执行过程如下:
- 遍历输入槽,检查它们是否都没有分配,如果有一个槽已分配,命令执行失败;方法是检查输入槽在clusterState.slots[]中对应的值是否为NULL。
- 遍历输入槽,将其分配给节点A;方法是修改clusterNode.slots[]中对应的比特为1,以及clusterState.slots[]中对应的指针指向A节点
- A节点执行完成后,通过节点通信机制通知其他节点,所有节点都会知道0-10的槽分配给了A节点
6 集群伸缩
实践中常常需要对集群进行伸缩,如访问量增大时的扩容操作。Redis集群可以在不影响对外服务的情况下实现伸缩;
伸缩的核心是槽迁移:修改槽与节点的对应关系,实现槽(即数据)在节点之间的移动。 例如,如果槽均匀分布在集群的3个节点中,此时增加一个节点,则需要从3个节点中分别拿出一部分槽给新节点,从而实现槽在4个节点中的均匀分布。
6.1 增加节点
假设要增加7006和7007节点,其中8003是7003的从节点;步骤如下:
启动节点:方法参见集群搭建。
节点握手:可以使用cluster meet命令,但在生产环境中建议使用redis-trib.rb的add-node工具,其原理也是cluster meet,但它会先检查新节点是否已加入其它集群或者存在数据,避免加入到集群后带来混乱。
1
2redis-trib.rb add-node 192.168.72.128:7006 192.168.72.128 7000
redis-trib.rb add-node 192.168.72.128:7007 192.168.72.128 7000迁移槽:推荐使用redis-trib.rb的reshard工具实现。reshard自动化程度很高,只需要输入
redis-trib.rb reshard ip:port
(ip和port可以是集群中的任一节点),然后按照提示输入以下信息,槽迁移会自动完成:- 待迁移的槽数量:16384个槽均分给4个节点,每个节点4096个槽,因此待迁移槽数量为4096
- 目标节点id:7006节点的id
- 源节点的id:7000/7001/7002节点的id
指定主从关系:方法参见集群搭建
6.2 减少节点
假设要下线7006/7007 节点,可以分为两步:
迁移槽:使用reshard将7006节点中的槽均匀迁移到7001/7002/7003节点
下线节点:使用
redis-trib.rb del-node
工具;应先下线从节点再下线主节点,因为若主节点先下线,从节点会被指向其他主节点,造成不必要的全量复制。1
2redis-trib.rb del-node 192.168.72.128:7007
redis-trib.rb del-node 192.168.72.128:7006
7 故障转移
在哨兵一文中,介绍了哨兵实现故障发现和故障转移的原理。虽然细节上有很大不同,但集群的实现与哨兵思路类似:通过定时任务发送PING消息检测其他节点状态;节点下线分为主观下线和客观下线;客观下线后选取从节点进行故障转移。
与哨兵一样,集群只实现了主节点的故障转移;从节点故障时只会被下线,不会进行故障转移。因此,使用集群时,应谨慎使用读写分离技术,因为从节点故障会导致读服务不可用,可用性变差。
这里不再详细介绍故障转移的细节,只对重要事项进行说明:
节点数量: 在故障转移阶段,需要由主节点投票选出哪个从节点成为新的主节点;从节点选举胜出需要的票数为N/2+1;其中N为主节点数量(包括故障主节点),但故障主节点实际上不能投票。因此为了能够在故障发生时顺利选出从节点,集群中至少需要3个主节点(且部署在不同的物理机上)。
故障转移时间: 从主节点故障发生到完成转移,所需要的时间主要消耗在主观下线识别、主观下线传播、选举延迟等几个环节;具体时间与参数cluster-node-timeout有关,一般来说:
故障转移时间(毫秒) ≤ 1.5 \* cluster-node-timeout + 1000
cluster-node-timeout的默认值为15000ms(15s),因此故障转移时间会在20s量级。
8. 集群的限制及应对方法
由于集群中的数据分布在不同节点中,导致一些功能受限,包括:
key批量操作受限:例如mget、mset操作,只有当操作的key都位于一个槽时,才能进行。
针对该问题,一种思路是在客户端记录槽与key的信息,每次针对特定槽执行mget/mset;另外一种思路是使用Hash Tag,将在下一小节介绍。keys/flushall等操作:keys/flushall等操作可以在任一节点执行,但是结果只针对当前节点,例如keys操作只返回当前节点的所有键。
针对该问题,可以在客户端使用cluster nodes获取所有节点信息,并对其中的所有主节点执行keys/flushall等操作。事务/Lua脚本:集群支持事务及Lua脚本,但前提条件是所涉及的key必须在同一个节点。Hash Tag可以解决该问题。
数据库:单机Redis节点可以支持16个数据库,集群模式下只支持一个,即db0。
复制结构:只支持一层复制结构,不支持嵌套。
9 Hash Tag
Hash Tag原理是:当一个key包含 {} 的时候,不对整个key做hash,而仅对 {} 包括的字符串做hash。
Hash Tag可以让不同的key拥有相同的hash值,从而分配在同一个槽里;这样针对不同key的批量操作(mget/mset等),以及事务、Lua脚本等都可以支持。不过Hash Tag可能会带来数据分配不均的问题,这时需要:
- 调整不同节点中槽的数量,使数据分布尽量均匀;
- 避免对热点数据使用Hash Tag,导致请求分布不均。
下面是使用Hash Tag的一个例子;通过对product加Hash Tag,可以将所有产品信息放到同一个槽中,便于操作。
10 集群方案设计
设计集群方案时,至少要考虑以下因素:
高可用要求:根据故障转移的原理,至少需要3个主节点才能完成故障转移,且3个主节点不应在同一台物理机上;每个主节点至少需要1个从节点,且主从节点不应在一台物理机上;因此高可用集群至少包含6个节点。
数据量和访问量:估算应用需要的数据量和总访问量(考虑业务发展,留有冗余),结合每个主节点的容量和能承受的访问量(可以通过benchmark得到较准确估计),计算需要的主节点数量。
节点数量限制:Redis官方给出的节点数量限制为1000,主要是考虑节点间通信带来的消耗。在实际应用中应尽量避免大集群;如果节点数量不足以满足应用对Redis数据量和访问量的要求,可以考虑:(1)业务分割,大集群分为多个小集群;(2)减少不必要的数据;(3)调整数据过期策略等。
适度冗余:Redis可以在不影响集群服务的情况下增加节点,因此节点数量适当冗余即可,不用太大。
11 参数优化
cluster_node_timeout
它的默认值是15s,影响包括:- 影响PING消息接收节点的选择:值越大对延迟容忍度越高,选择的接收节点越少,可以降低带宽,但会降低收敛速度;应根据带宽情况和应用要求进行调整。
- 影响故障转移的判定和时间:值越大,越不容易误判,但完成转移消耗时间越长;应根据网络状况和应用要求进行调整。
cluster-require-full-coverage
前面提到,只有当16384个槽全部分配完毕时,集群才能上线。这样做是为了保证集群的完整性,但同时也带来了新的问题:当主节点发生故障而故障转移尚未完成,原主节点中的槽不在任何节点中,此时会集群处于下线状态,无法响应客户端的请求。cluster-require-full-coverage
参数可以改变这一设定:如果设置为no,则当槽没有完全分配时,集群仍可以上线。参数默认值为yes,如果应用对可用性要求较高,可以修改为no,但需要自己保证槽全部分配。