【正值春节之际,祝各位新的一年,身体康健,大吉大利。】
我们在前面的文章里分析了几种 Redis 高可用性的解决方案。包括:「主从模式」、「哨兵机制」以及「哨兵集群」。
Redis 从最简单的单机版,经过数据持久化、主从多副本、哨兵集群,通过这么一番的优化,不管是性能还是稳定性,都越来越高。
但是随着时间的发展,公司业务体量迎来了爆炸性增长,此时的架构模型,还能够承担这么大的流量吗?
比如有这么一个需求:要用 Redis 保存 5000 万
个键值对,每个键值对大约是 512B
,为了能快速部署并对外提供服务,我们采用云主机来运行 Redis 实例,那么,该如何选择云主机的内存容量呢?
通过计算,这些键值对所占的内存空间大约是 25GB(5000 万 *512B)。
想到的第一个方案就是:选择一台 32GB 内存的云主机来部署 Redis。因为 32GB 的内存能保存所有数据,而且还留有 7GB,可以保证系统的正常运行。
同时,还采用 RDB 对数据做持久化,以确保 Redis 实例故障后,还能从 RDB 恢复数据。
但是,在使用的过程中会发现,Redis 的响应有时会非常慢。通过 INFO命令
查看 Redis 的latest_fork_usec
指标值(表示最近一次 fork 的耗时),结果发现这个指标值特别高。
这跟 Redis 的持久化机制有关系。
在使用 RDB 进行持久化时,Redis 会 fork
子进程来完成,fork
操作的用时和 Redis 的数据量是正相关的,而 fork
在执行时会阻塞主线程。数据量越大,fork 操作造成的主线程阻塞的时间越长。
所以,在使用 RDB
对 25GB 的数据进行持久化时,数据量较大,后台运行的子进程在 fork
创建时阻塞了主线程,于是就导致 Redis 响应变慢了。
显然这个方案是不可行的,我们必须要寻找其他的方案。
为了保存大量数据,我们一般有两种方法:「纵向扩展」和「横向扩展」:
首先,「纵向扩展」的好处是,实施起来简单、直接。不过,这个方案也面临两个潜在的问题。
fork
子进程时就可能会阻塞。与「纵向扩展」相比,「横向扩展」是一个扩展性更好的方案。这是因为,要想保存更多的数据,采用这种方案的话,只用增加 Redis 的实例个数就行了,不用担心单个实例的硬件和成本限制。
Redis 集群就是基于「横向扩展」实现的 ,通过启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。
Redis 集群是一种分布式数据库方案,集群通过分片
(sharding
,也可以叫切片
)来进行数据共享,并提供复制和故障转移功能。
回到我们刚刚的场景中,如果把 25GB 的数据平均分成 5 份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。如下图所示:
那么,在切片集群中,实例在为 5GB 数据生成 RDB 时,数据量就小了很多,fork
子进程一般不会给主线程带来较长时间的阻塞。
采用多个实例保存数据切片后,我们既能保存 25GB 数据,又避免了 fork
子进程阻塞主线程而导致的响应突然变慢。
在实际应用 Redis 时,随着业务规模的扩展,保存大量数据的情况通常是无法避免的。而 Redis 集群,就是一个非常好的解决方案。
下面我们开始研究如何搭建一个 Redis 集群?
一个 Redis 集群通常由多个节点组成,在刚开始的时候,每个节点都是相互独立地,节点之间没有任何关联。要组建一个可以工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多节点的集群。
我们可以通过 CLUSTER MEET
命令,将各个节点连接起来:
CLUSTER MEET <ip> <port>
命令说明:通过向一个节点 A 发送 CLUSTER MEET
命令,可以让接收命令的节点 A 将另一个节点 B 添加到节点 A 所在的集群中。
这么说有点抽象,下面看一个例子。
假设现在有三个独立的节点 127.0.0.1:7001
、 127.0.0.1:7002
、 127.0.0.1:7003
。
我们首先使用客户端连上节点 7001
:
$ redis-cli -c -p 7001
然后向节点 7001
发送命令,将节点 7002
添加到 7001
所在的集群里:
127.0.0.1:7001> CLUSTER MEET 127.0.0.1 7002
同样的,我们向 7003
发送命令,也添加到 7001
和 7002
所在的集群。
127.0.0.1:7001> CLUSTER MEET 127.0.0.1 7003
通过
CLUSTER NODES
命令可以查看集群中的节点信息。
现在集群中已经包含 7001
、 7002
和 7003
三个节点。不过,在使用单个实例的时候,数据存在哪儿,客户端访问哪儿,都是非常明确的。但是,切片集群不可避免地涉及到多个实例的分布式管理问题。
要想把切片集群用起来,我们就需要解决两大问题:
接下来,我们就一个个地解决。
在切片集群中,数据需要分布在不同实例上,那么,数据和实例之间如何对应呢?
这就和接下来要讲的 Redis Cluster
方案有关了。不过,我们要先弄明白切片集群和 Redis Cluster
的联系与区别。
在 Redis 3.0 之前,官方并没有针对切片集群提供具体的方案。从 3.0 开始,官方提供了一个名为
Redis Cluster
的方案,用于实现切片集群。
实际上,切片集群是一种保存大量数据的通用机制,这个机制可以有不同的实现方案。 Redis Cluster
方案中就规定了数据和实例的对应规则。
具体来说, Redis Cluster
方案采用 哈希槽(Hash Slot),来处理数据和实例之间的映射关系。
在 Redis Cluster
方案中,一个切片集群共有 16384
个哈希槽(2^14),这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。
在上面我们分析的,通过 CLUSTER MEET
命令将 7001
、7002
、7003
三个节点连接到同一个集群里面,但是这个集群目前是处于下线状态的,因为集群中的三个节点没有分配任何槽。
那么,这些哈希槽又是如何被映射到具体的 Redis 实例上的呢?
我们可以使用 CLUSTER MEET
命令手动建立实例间的连接,形成集群,再使用CLUSTER ADDSLOTS
命令,指定每个实例上的哈希槽个数。
CLUSTER ADDSLOTS <slot> [slot ...]
Redis5.0 提供
CLUSTER CREATE
命令创建集群,使用该命令,Redis 会自动把这些槽平均分布在集群实例上。
举个例子,我们通过以下命令,给 7001
、7002
、7003
三个节点分别指派槽。
将槽 0 ~ 槽5000 指派给 给 7001
:
127.0.0.1:7001> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
将槽 5001 ~ 槽10000 指派给 给 7002
:
127.0.0.1:7002> CLUSTER ADDSLOTS 5001 5002 5003 5004 ... 10000
将槽 10001~ 槽 16383 指派给 给 7003
:
127.0.0.1:7003> CLUSTER ADDSLOTS 10001 10002 10003 10004 ... 16383
当三个 CLUSTER ADDSLOTS
命令都执行完毕之后,数据库中的 16384 个槽都已经被指派给了对应的节点,此时集群进入上线状态。
通过哈希槽,切片集群就实现了数据到哈希槽、哈希槽再到实例的分配。
但是,即使实例有了哈希槽的映射信息,客户端又是怎么知道要访问的数据在哪个实例上呢?
一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。
那么,客户端是如何可以在访问任何一个实例时,就能获得所有的哈希槽信息呢?
Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。
客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。
当客户端向节点请求键值对时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:
MOVED
错误,然后重定向(redirect)到正确的节点,并再次发送之前待执行的命令。节点通过以下算法来定义 key
属于哪个槽:
crc16(key,keylen) & 0x3FFF;
通过
CLUSTER KEYSLOT
命令可以查看 key 属于哪个槽。
当节点计算出 key 所属的 槽 i
之后,节点会判断 槽 i
是否被指派了自己。那么如何判断呢?
每个节点会维护一个 「slots数组」,节点通过检查 slots[i]
,判断 槽 i
是否由自己负责:
slots[i]
对应的节点是当前节点的话,那么说明 槽 i
由当前节点负责,节点可以执行客户端发送的命令;slots[i]
对应的不是当前节点,节点会根据 slots[i]
所指向的节点向客户端返回 MOVED
错误,指引客户端转到正确的节点。格式:
MOVED :
比如:MOVED 10086 127.0.0.1:7002
,表示,客户端请求的键值对所在的哈希槽 10086
,实际是在 127.0.0.1:7002
这个实例上。
通过返回的 MOVED
命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。
这样一来,客户端就可以直接和 7002
连接,并发送操作请求了。
同时,客户端还会更新本地缓存,将该槽与 Redis 实例对应关系更新正确。
集群模式的
redis-cli
客户端在接收到MOVED
错误时,并不会打印出MOVED
错误,而是根据MOVED
错误自动进行节点转向,并打印出转向信息,所以我们是看不见节点返回的MOVED
错误的。而使用单机模式的redis-cli
客户端可以打印MOVED
错误。
其实,Redis 告知客户端重定向访问新实例分两种情况:MOVED
和 ASK
。下面我们分析下 ASK
重定向命令的使用方法。
在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:
重新分片可以在线进行,也就是说,重新分片的过程中,集群不需要下线。
举个例子,上面提到,我们组成了 7001
、7002
、7003
三个节点的集群,我们可以向这个集群添加一个新节点127.0.0.1:7004
。
$ redis-cli -c -p 7001
127.0.0.1:7001> CLUSTER MEET 127.0.0.1 7004
OK
然后通过重新分片,将原本指派给节点 7003
的槽 15001 ~ 槽 16383 改为指派给 7004
。
在重新分片的期间,源节点向目标节点迁移槽的过程中,可能会出现这样一种情况:如果某个槽的数据比较多,部分迁移到新实例,还有一部分没有迁移咋办?
在这种迁移部分完成的情况下,客户端就会收到一条 ASK
报错信息。
如果客户端向目标节点发送一个与数据库键有关的命令,并且这个命令要处理的键正好属于被迁移的槽时:
ASK
错误,指引客户端转向目标节点,并再次发送之前要执行的命令。看起来好像有点复杂,我们举个例子来解释一下。
如上图所示,节点 7003
正在向 7004
迁移 槽 16383
,这个槽包含 hello
和 world
,其中键 hello
还留在节点 7003
,而 world
已经迁移到 7004
。
我们向节点 7003
发送关于 hello
的命令 这个命令会直接执行:
127.0.0.1:7003> GET "hello"
"you get the key 'hello'"
如果我们向节点 7003
发送 world
那么客户端就会被重定向到 7004
:
127.0.0.1:7003> GET "world"
-> (error) ASK 16383 127.0.0.1:7004
客户端在接收到 ASK
错误之后,先发送一个 ASKING
命令,然后在发送 GET "world"
命令。
ASKING
命令用于打开节点的ASKING
标识,打开之后才可以执行命令。
ASK
错误和 MOVED
错误都会导致客户端重定向,它们的区别在于:
槽 i
的 MOVED
错误之后,客户端每次遇到关于 槽 i
的命令请求时,都可以直接将命令请求发送至 MOVED
错误指向的节点,因为该节点就是目前负责 槽 i
的节点。槽 i
的 ASK
错误之后,客户端只会在接下来的一次命令请求中将关于 槽 i
的命令请求发送到 ASK
错误指向的节点,但是 ,如果客户端再次请求 槽 i
中的数据,它还是会给原来负责 槽 i
的节点发送请求。这也就是说,ASK 命令的作用只是让客户端能给新实例发送一次请求,而且也不会更新客户端缓存的哈希槽分配信息。而不像 MOVED
命令那样,会更改本地缓存,让后续所有命令都发往新实例。
我们现在知道了 Redis 集群的实现原理。下面我们再来分析下,Redis 集群如何实现高可用的呢?
Redis 集群中的节点也是分为主节点和从节点。
举个例子,对于包含 7001
~ 7004
的四个主节点的集群,可以添加两个节点:7005
、7006
。并将这两个节点设置为 7001
的从节点。
设置从节点命令:
CLUSTER REPLICATE
如图:
如果此时,主节点 7001
下线,那么集群中剩余正常工作的主节点将在 7001
的两个从节点中选出一个作为新的主节点。
例如,节点 7005
被选中,那么原来由节点 7001
负责处理的槽会交给节点 7005
处理。而节点 7006
会改为复制新主节点 7005
。如果后续 7001
重新上线,那么它将成为 7005
的从节点。如下图所示:
集群中每个节点会定期向其他节点发送 PING
消息,来检测对方是否在线。如果接收消息的一方没有在规定时间内返回 PONG
消息,那么接收消息的一方就会被发送方标记为「疑似下线」。
集群中的各个节点会通过互相发消息的方式来交换各节点的状态信息。
节点的三种状态:
一个节点认为某个节点失联了并不代表所有的节点都认为它失联了。在一个集群中,半数以上负责处理槽的主节点都认定了某个主节点下线了,集群才认为该节点需要进行主从切换。
Redis 集群节点采用 Gossip 协议来广播自己的状态以及自己对整个集群认知的改变。比如一个节点发现某个节点失联了 (PFail),它会将这条信息向整个集群广播,其它节点也就可以收到这点失联信息。
我们都知道,哨兵机制可以通过监控、自动切换主库、通知客户端实现故障自动切换。那么 Redis Cluster
又是如何实现故障自动转移呢?
当一个从节点发现自己正在复制的主节点进入了「已下线」状态时,从节点将开始对下线主节点进行故障切换。
故障转移的执行步骤:
SLAVEOF no one
命令,成为主节点PONG
消息,让集群中其他节点知道,该节点已经由从节点变为主节点,且已经接管了原主节点负责的槽这个选主方法和哨兵的很相似,两者都是基于 Raft算法
的领头算法实现的。流程如下:
大于等于 N/2 + 1
时,该从节点就会当选为新的主节点;集群中的各个节点通过发送和接收消息来进行通信,我们把发送消息的节点称为发送者,接收消息的称为接收者。
节点发送的消息主要有五种:
集群中的各个节点通过 Gossip
协议交换不同节点的状态信息, Gossip
是由 MEET
、PING
、PONG
三种消息组成。
发送者每次发送 MEET
、PING
、PONG
消息时,都会从自己已知的节点列表中随机选出两个节点(可以是主节点或者从节点)一并发送给接收者。
接收者收到 MEET
、PING
、PONG
消息时,根据自身是否认识这两个节点来进行不同的处理:
好了,关于 Redis 集群相关的就介绍到这。我们下期见。