
深入Redis系列(十)Redis高可用进阶——分片技术(Redis Cluster)详解
从前面两篇文章:
深入Redis系列(九)Redis高可用之哨兵Sentinel大揭秘
我们知道主从复制和哨兵机制保障了高可用,就读写分离而言虽然slave节点扩展了主从的读并发能力,但是写能力和存储能力是无法进行扩展,就只能是master节点能够承载的上限。
如果面对海量数据那么必然需要构建master(主节点分片)之间的集群,同时必然需要实现高可用(主从复制和哨兵机制)能力,即每个master分片节点还需要有slave节点,这是分布式系统中典型的纵向扩展(集群的分片技术)的体现;
其实数据分片在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 响应变慢了。
因此这个方案显然是不可行的了,这个时候我们想到了 Redis 的切片集群。虽然组建切片集群比较麻烦,但是它可以保存大量数据,而且对 Redis
主线程的阻塞影响较小。
切片集群,也叫分片集群,就是指启动多个 Redis
实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。回到我们刚刚的场景中,如果把 25GB 的数据平均分成 5
份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。
如下图所示:

在切片集群中,实例在为 5GB 数据生成 RDB 时,数据量就小了很多,fork 子进程一般不会给主线程带来较长时间的阻塞。
采用多个实例保存数据切片后,我们既能保存 25GB 数据,又避免了 fork 子进程阻塞主线程而导致的响应突然变慢。
在实际应用 Redis 时,随着用户或业务规模的扩展,保存大量数据的情况通常是无法避免的。而切片集群,就是一个非常好的解决方案。
这篇文章,我们就来了解一下Redis分片集群的实现,以及Redis分片需要做到哪些功能才能满足我们海量业务存储的需求。
Redis Cluster 分片集群如何实现
哈希槽(Hash Slot)
Redis-cluster没有使用一致性hash,而是引入了哈希槽的概念。 Redis-
cluster中有16384个哈希槽,每个key通过CRC16算法计算出一个16bit的值后对16383取模来决定这个key要放置在哪个槽。Cluster中的每个节点负责一部分hash槽存储。
比如集群中存在三个节点,则可能存在的一种分配如下:
节点A包含0到5500号哈希槽;
节点B包含5501到11000号哈希槽;
节点C包含11001到16384号哈希槽。
在手动为节点分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。
如下所示:

图中的切片集群一共有 3 个实例,同时假设有 5 个哈希槽,我们首先可以通过下面的命令手动分配哈希槽:实例 1 保存哈希槽 0 和 1,实例 2 保存哈希槽
2 和 3,实例 3 保存哈希槽 4。
在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 5 取模,再根据各自的模数结果,就可以被映射到对应的实例 1 和实例
3 上了。
Keys hash tags
Hash tags提供了一种途径,用来将多个(相关的)key分配到相同的hash slot中。这是Redis Cluster中实现multi-
key操作的基础。
hash tag规则如下:一个key需要包含 "{}"符号,并且”{“ 和 ”}“ 之间的字符会作为一个hash tag
用来计算HASH_SLOT,以保证这样的key保存在同一个slot中。
例如:
a. {user1000}.following和{user1000}.followers这两个key会被hash到相同的hash
slot中,因为只有user1000会被用来计算hash slot值。
b.foo{}{bar}这个key不会启用hash tag因为第一个{和}之间没有字符。
c.foo{bar}{zap}这个key中的bar会被用来计算计算hash slot,而zap不会。
Cluster总线
每个Redis Cluster节点有一个额外的TCP端口用来接受其他节点的连接。该端口等于普通命令端口加上10000。
例如,一个Redis在端口6379监听客户端连接,那么它的集群总线端口16379也会被打开。
节点到节点的通讯只使用集群总线,这个协议是由不同的类型和大小的帧组成的二进制协议。
集群拓扑
Redis
Cluster是一张全网拓扑,节点与其他每个节点之间都保持着TCP连接。在一个拥有N个节点的集群中,每个节点有N-1个TCP传出连接,和N-1个TCP传入连接。
这些TCP连接总是保持活跃的,这些TCP连接就形成了一个网络图拓扑。
一个节点在集群总线上发送了ping请求并期待对方回复pong,如果该节点没有得到回复,它会花足够长时间等待对方响应,以便将对方标记为不可达之前,先尝试重新刷新与对方的连接。
在全网拓扑中的Redis
Cluster节点,节点使用gossip协议和配置更新机制来避免在正常情况下节点之间交换过多的消息,以保证集群内交换的消息数目不是指数级的。
Gossip协议
Redis Cluster通讯底层是Gossip协议,所以需要对Gossip协议有一定的了解。
gossip协议是基于流行病传播方式的节点或者进程之间信息交换的协议,在分布式系统中被广泛使用。
Gossip协议大概是这样运作的:
1. 状态交换:
每个Redis节点定期(默认每秒一次)随机选择几个其他节点进行状态信息的交换。 状态信息包括了每个节点的负载情况、存活状态等元数据。
通过这种方式,所有节点可以逐渐了解到整个集群的状态,并能够检测出失败的节点或新加入的节点。
2. 反熵同步
Redis集群中的节点还利用Gossip协议来进行数据修复,这是一种形式上的反熵过程。
具体是这样的,如果某个节点在一段时间内没有收到特定数据的更新,它会发起一个读取请求到其他副本持有者那里,以确保自己的数据是最新的。
这有助于解决由于网络分区或其他原因导致的数据不一致问题。
3. 服务发现
当一个新的Redis节点加入集群时,它首先与一个已知的节点通信。
之后,这个新节点通过Gossip机制广播它的存在,使得集群中的其他节点都能意识到它的加入,并开始与其交互。
4. 故障检测
如果某个节点长时间没有响应Gossip消息,那么其他节点就会认为该节点已经失效。
失效的信息也会通过Gossip在网络中传播,从而触发相应的容错机制,例如重新平衡数据分布。
5. 拓扑变更通知
当发生节点添加或删除时,Gossip用来快速传播这些变更信息给集群中的所有节点。 这样做可以让所有的节点及时调整它们的数据布局和负载均衡策略。
Gossip协议已经是P2P网络中比较成熟的协议了。Gossip协议的最大的好处是,即使集群节点的数量增加,每个节点的负载也不会增加很多,几乎是恒定的。
节点握手
节点总是接受集群总线端口的连接,并且总是会回复ping请求,即使ping来自一个不可信节点。然而,如果发送节点被认为不是当前集群的一部分,所有其他包将被抛弃。
节点认定其他节点是当前集群的一部分有两种方式:
1. 新节点发送meet消息给已有节点。
一条meet消息非常像一个PING消息,但是它会强制接收者接受一个节点作为集群的一部分。节点只有在接收到系统管理员的如下命令后,才会向其他节点发送MEET消息。
CLUSTER MEET ip port
2.
如果一个被信任的节点gossip了某个节点,那么接收到gossip消息的节点也会将那个节点标记为集群的一部分。也就是说,如果在集群中,A知道B,而B知道C,最终B会发送gossip消息到A,告诉A节点C是集群的一部分。这时,A会把C注册到网络的一部分,并尝试与C建立连接。
这意味着集群加入一个新节点之后,该节点可以自动发现其他节点。
请求重定向
Redis cluster采用去中心化的架构,集群的主节点各自负责一部分槽,客户端如何确定key到底会映射到哪个节点上呢?这就是我们要讲的请求重定向。
在cluster模式下,节点对请求的处理过程如下:
1.检查当前key是否存在当前NODE?
a.通过crc16(key)/16384计算出slot
b.查询负责该slot负责的节点,得到节点指针;
c. 将该指针与自身节点指针比较,如果该指针就是自身节点指针说明这个key对应的slot位于本节点。
2.若slot不是由自身负责,则返回MOVED重定向给客户端;
3.若slot由自身负责,且key在slot中,则返回该key对应的value;
4.若key不存在此slot中,检查该slot是否正在迁出(MIGRATING)?
5. 若key正在迁出,返回ASK错误重定向客户端到迁移的目的服务器上。

这个过程中有两点需要具体理解下:MOVED重定向和ASK重定向。
Moved重定向
在Redis集群中, MOVED 重定向是一种客户端请求路由机制,它用于处理当客户端尝试访问一个不在当前节点负责的槽(slot)上的键时的情况。
当客户端向某个节点发送命令,而该命令所涉及的键并不属于这个节点负责的槽时,节点会返回一个 MOVED
响应,这个响应包含客户端这个key所在正确的节点IP和port。
MOVED 响应后,客户端通常会缓存槽号和节点IP与port的信息,以避免将来对相同槽的请求再次发生重定向。
然后,客户端使用新的IP地址和端口号重新发送原始命令到正确的节点。
对于相同的槽或已经缓存了槽映射关系的其他键,客户端可以直接将请求发送到正确的节点,无需再次经历 MOVED 重定向过程。
注意事项:
a. 缓存管理:客户端需要妥善管理槽到节点的映射关系,以便能够高效地执行命令。如果集群配置发生变化(例如添加或移除节点),客户端可能需要刷新其缓存的信息。
b. 批量操作:对于涉及到多个键的命令,如 执行 MGET命令获取多个key,而 这些键分布在不同的槽上,可能会导致多次
MOVED 重定向。
为了优化这种情况,客户端应该尽量确保批处理命令中的所有键都位于同一个槽内,此时可以使用上文提到过的 key hash tags
的规范来给key命名,从而保证相同tag的key位于同一个槽内。
c. 容错能力:如果目标节点不可达,客户端可能需要通过询问其他节点或者使用集群提供的故障转移机制来获取最新的集群状态。
ASK重定向
Ask重定向发生于集群伸缩时(伸缩是指添加或移除集群中的Redis节点),集群伸缩会导致槽迁移,当我们去源节点访问时,此时数据已经可能已经迁移到了目标节点,Redis会使用Ask重定向来解决此种情况。

smart客户端
smart客户端(也称为智能客户端)是指那些能够理解并直接与Redis集群进行交互的客户端库。
上述两种重定向的机制使得业务侧在Redis集群中的命令请求的实现更加复杂,Redis提供了smart客户端(如JedisCluster)来减低复杂性,追求更好的性能。
每种语言如Java、Python等都有属于自己的smart客户端实现。下面是Java的smart客户端 JedisCluster 在命令请求时的工作流程。

状态检测及维护
Redis Cluster中节点状态如何维护呢?这里便涉及有哪些状态,底层协议Gossip,及心跳检测机制。
Cluster中的每个节点都维护一份在自己看来当前整个集群的状态,主要包括:
- 当前集群状态
- 集群中各节点所负责的slots信息,及其migrate状态
- 集群中各节点的master-slave状态
- 集群中各节点的存活状态及不可达投票
集群状态变化时,如新节点加入、slot迁移、节点宕机、slave提升为新Master,我们希望这些变化尽快的被发现,传播到整个集群的所有节点并达成一致。节点之间相互的心跳(PING,PONG,MEET)及其携带的状态数据是集群状态传播最主要的途径。
Gossip协议的使用
Redis集群是去中心化的,彼此之间状态同步靠gossip协议通信,集群的消息有以下几种类型:
1. Meet
通过「cluster meet ip port」命令,已有集群的节点会向新的节点发送邀请,加入现有集群。
2.Ping
节点每秒会向集群中其他节点发送ping消息,消息中带有自己已知的两个节点的地址、槽、状态信息、最后一次通信时间等。
3. Pong
节点收到ping消息后会回复pong消息,消息中同样带有自己已知的两个节点信息。
4. Fail
节点ping不通某节点后,会向集群所有节点广播该节点挂掉的消息。其他节点收到消息后标记已下线。
基于Gossip协议的故障检测
节点状态有3种:在线状态、疑似下线状态PFAIL、已下线状态FAIL。
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此交换各个节点状态信息,检测各个节点的状态。
下面是故障检测的原则。
a.
自己保存信息:当主节点A通过消息得知主节点B认为主节点D进入了疑似下线(PFAIL)状态时,主节点A会在自己的clusterState.nodes字典中找到主节点D所对应的clusterNode结构,并将节点D的下线报告添加到clusterNode结构的fail_reports链表中,后续关于节点D疑似下线的状态通过Gossip协议通知其他节点。
b.一起裁定:如果集群里面,半数以上的主节点都将主节点D报告为疑似下线,那么主节点D将被标记为已下线(FAIL)状态,将主节点D标记为已下线的节点会向集群广播主节点D的FAIL消息,所有收到FAIL消息的节点都会立即更新nodes里面主节点D状态标记为已下线。
c.最终裁定: 满足以下两个条件就可以最终 将node标记为FAIL。条件一是 有半数以上的主节点将node标记为PFAIL状态。条件二是
当前节点也将node标记为PFAIL状态。
通讯状态和维护
我们理解了Gossip协议基础后,就可以进一步理解Redis节点之间相互的通讯心跳(PING,PONG,MEET)实现和维护了。我们通过几个问题来具体理解。
1. 什么时候进行心跳?
Redis节点会记录其向每一个节点上一次发出ping和收到pong的时间,心跳发送时机与这两个值有关。
通过下面的方式既能保证及时更新集群状态,又不至于使心跳数过多:
- 每次定时向所有未建立链接的节点发送ping或meet;
- 每1秒从所有已知节点中随机选取5个,向其中上次收到pong最久远的一个发送ping;
- 每次定时向收到pong超过timeout/2的节点发送ping;
- 收到ping或meet,立即回复pong;
2. 每个节点发送哪些心跳数据?
a. 发送者自己的信息
- 本节点所负责slots的信息
- 主从信息
- ip port信息
- 状态信息
b.发送者所了解的部分其他节点的信息
- ping_sent,pong_received
- ip,port信息
- 状态信息,比如发送者认为该节点已经不可达,会在状态信息中标记其为PFAIL或FAIL
故 障恢复(Failover)
master节点挂了之后,如何进行故障恢复呢?
当slave发现自己的master变为FAIL状态时,便尝试进行Failover故障恢复,以期成为新的master。
由于挂掉的master可能会有多个slave。Failover的过程需要经过类Raft协议的过程在整个集群内达到一致。
在此之前还需要介绍一下epoch这个概念。
在Redis集群中,epoch是一个非常重要的概念,它用于确保节点间数据的一致性和协调故障恢复。
每个Redis集群节点都有一个唯一的、递增的整数epoch,这个数字也被称为配置纪元(configuration epoch)。
简单来说,epoch可以被视为一种版本号,它保证了Redis集群内部配置更新的顺序性和唯一性。
回到故障恢复,它的过程如下:
-
slave发现自己的master变为FAIL
-
将自己记录的集群currentEpoch加1,并广播Failover Request信息;
-
其他节点收到该信息,只有其中的master节点响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK;
-
尝试执行故障恢复的slave节点收集FAILOVER_AUTH_ACK响应;
-
如果该slave节点收到超过半数主节点的ACK后,该从节点就会变成新Master;
-
这个新的Master广播Pong通知其他集群节点;

扩容 &缩容
Redis Cluster是如何进行扩容和缩容的呢?
对于扩容而言, 当集群出现容量限制或者其他一些原因需要扩容时,redis cluster提供了比较优雅的集群扩容方案。
首先将新节点加入到集群中,可以通过在集群中任何一个客户端执行cluster meet新节点ip:端口,或者通过redis-trib add
node添加,新添加的节点默认在集群中都是主节点。
迁移数据迁移数据的大致流程是,首先需要确定哪些槽需要被迁移到目标节点,然后获取槽中key,将槽中的key全部迁移到目标节点,然后向集群所有主节点广播槽(数据)全部迁移到了目标节点。
直接通过redis-trib工具做数据迁移很方便。现在假设将节点A的槽10迁移到B节点,过程如下:
B:cluster setslot 10 importing A.nodeIdA:cluster setslot 10 migrating B.nodeId
循环获取槽中key,将key迁移到B节点
A:cluster getkeysinslot 10 100A:migrate B.ip B.port""0 5000 keys key1[key2....]
向集群广播槽已经迁移到B节点
cluster setslot 10 node B.nodeId
缩容的大致过程与扩容一致,需要判断下线的节点是否是主节点,以及主节点上是否有槽,若主节点上有槽,需要将槽迁移到集群中其他主节点,槽迁移完成之后,需要向其他节点广播该节点准备下线(cluster
forget nodeId)。
最后需要将该下线主节点的从节点指向其他主节点,当然最好是先将从节点下线。
更深入理解
通过几个例子,再深入理解Redis Cluster
1. 为什么Redis Cluster的Hash Slot是16384?
我们知道一致性hash算法是2的16次方,为什么hash slot是2的14次方呢?
在redis节点发送心跳包时需要把所有的槽放到这个心跳包里,以便让节点知道当前集群信息,16384=16k,在发送心跳包时使用char进行bitmap压缩后是2k(2*8(8
bit)*1024(1k)=16K),也就是说使用2k的空间创建了16k的槽数。
虽然使用CRC16算法最多可以分配65535(2^16-1)个槽位,65535=65k,压缩后就是8k(8*8(8
bit)*1024(1k)=65K),也就是说需要需要8k的心跳包,作者认为这样做不太值得;并且一般情况下一个redis集群不会有超过1000个master节点,所以16k的槽位是个比较合适的选择。
2.为什么Redis Cluster中不建议使用发布订阅呢?
在集群模式下,所有的publish命令都会向所有节点(包括从节点)进行广播,造成每条publish数据都会在集群内所有节点传播一次,加重了带宽负担,对于在有大量节点的集群中频繁使用pub,会严重消耗带宽,不建议使用。
-- 往期精彩回顾 –
深入Redis系列(九)Redis高可用之哨兵Sentinel大揭秘
一个程序员老鸟技术提升的心路历程:为什么越努力学习越感到焦虑?
