一、Redis Cluster
1、Redis集群方案的演变
- 大规模数据存储系统都会面临的一个问题就是如何横向拓展。
- 当你的数据集越来越大,一主多从的模式已经无法支撑这么大量的数据存储,于是你首先考虑将多个主从模式结合在一起对外提供服务,但是这里有两个问题就是如何实现数据分片的逻辑和在哪里实现这部分逻辑?
- 业界常见的解决方案有两种
- 一是引入
_Proxy_层来向应用端屏蔽身后的集群分布,客户端可以借助_Proxy_层来进行请求转发和_Key_值的散列从而进行进行数据分片,这种方案会损失部分性能但是迁移升级等运维操作都很方便,业界_Proxy_方案的代表有Twitter的_ Twemproxy 和豌豆荚的 _Codis; - 二是
_smart client__ _方案,即将_Proxy_的逻辑放在客户端做,客户端根据维护的映射规则和路由表直接访问特定的 Redis 实例,但是增减Redis实例都需要重新调整分片逻辑,如何使得客户端感知到集群的变化从而调整内存中维护的路由表呢,要么定时去感知,要么引入一个第三方协调服务,常见的就是_Zookeeper_
- 一是引入
2、Redis Cluster 简介
-
Redis 3.0版本开始官方正式支持集群模式 -
Redis集群模式提供了一种能将数据在多个节点上进行分区存储的方法,采取了和上述两者不同的实现方案——去中心化的集群模式- 集群通过分片进行数据共享,分片内采用一主多从的形式进行副本复制,并提供复制和故障恢复功能。
- 在官方文档 Redis Cluster Specification 中,作者详细介绍了官方集群模式的设计考量,主要有如下几点:
| 性能 |Redis集群模式采用去中心化的设计,即P2P而非之前业界衍生出的Proxy方式 |
| --- | --- |
| 一致性 |_**master**_与_**slave**_之间采用异步复制,存在数据不一致的时间窗口,保证高性能的同时牺牲了部分一致性 |
| 水平扩展 | 文中称可以线性扩展至1000个节点 |
| 可用性 | 在集群模式推出之前,主从模式的可用性要靠_**Sentinel**_保证,集群模式引入了新的故障检测机制,而在故障转移这块复用了_**Sentinel**_的代码逻辑,不需要单独启动一个Sentinel集群,Redis Cluster本身就能自动进行_**master**_ 选举和_**failover**_|
-
下图是一个三主三从的 Redis Cluster
-
三机房部署(其中一主一从构成一个分片,之间通过异步复制同步数据,一旦某个机房掉线,则分片上位于另一个机房的
_**slave**__** **_会被提升为_**master**_从而可以继续提供服务) -
每个
_**master**_负责一部分_**slot**_,数目尽量均摊;客户端对于某个_**Key**_操作先通过公式计算(计算方法见下文)出所映射到的_**slot**_,然后直连某个分片,写请求一律走_**master**_,读请求根据路由规则选择连接的分片节点,对于Squirrel的客户端路由规则可见 通用-Squirrel_路由策略简介。
3、三种集群方案的优缺点
| 集群模式 | 优点 | 缺点 |
|---|---|---|
| 客户端分片 | - 不使用第三方中间件,实现方法和代码可以自己掌控并且可随时调整。 - 这种分片性能比代理式更好(因为少了分发环节),分发压力在客户端,无服务端压力增加 |
- 不能平滑地水平扩容,扩容/缩容时,必须手动调整分片程序 - 出现故障不能自动转移,难以运维 |
| 代理层分片 | - 运维成本低。 - 业务方不用关心后端 Redis 实例,跟操作单点 Redis 实例一样。 - Proxy 的逻辑和存储的逻辑是隔离的 |
- 代理层多了一次转发,性能有所损耗; - 进行扩容/缩容时候,部分数据可能会失效,需要手动进行迁移,对运维要求较高,而且难以做到平滑的扩缩容; - 出现故障,不能自动转移,运维性很差。Codis 做了诸多改进,相比于 Twemproxy可用性和性能都好得多 |
| Redis Cluster | - 无中心节点,数据按照 _**slot**_ 存储分布在多个 Redis 实例上- 平滑的进行扩容/缩容节点,自动故障转移(节点之间通过 Gossip 协议交换状态信息,进行投票机制完成 _**slave**_ 到 _**master**_ 角色的提升)降低运维成本,提高了系统的可扩展性和高可用性 |
- 开源版本缺乏监控管理 - 原生客户端太过简陋, _**failover**_ 节点的检测过慢,维护 _**Membership**_ 的 _**Gossip**_ 消息协议开销大,无法根据统计区分冷热数据 |
二、哈希槽
1、什么是哈希槽
Redis Cluster中,数据分片借助哈希槽 (下文均称** **_**slot**_) 来实现- 集群预先划分
16384个_**slot**_,对于每个请求集群的键值对,根据_**Key**_进行散列生成的值唯一匹配一个_**slot**_。 Redis Cluster中每个分片的_**_**master**_负责16384个_**slot**_**_中的一部分- 当且仅当每个
_**slot**__** **_都有对应负责的节点时,集群才进入可用状态。 - 当动态添加或减少节点时,需要将
16384个_**slot**_做个再分配,_**slot**_中的键值也要迁移
2、哈希槽计算方法
HASH_SLOT = CRC16(key) mod 16384
- 但是上述计算方法实际采用时,做了一些改变,改变的目的是为了支持
哈希标签_**(Hash Tag)**_。 - 哈希标签是确保两个键都在同一个
_**slot**_里的一种方式。 - 为了实现哈希标签,
_**slot**_是用另一种不同的方式计算的。简单来说,如果一个键包含一个“{…}” 这样的模式,只有{ 和 } 之间的字符串会被用来做哈希以获取 slot。但是由于可能出现多个 { 或 },计算的算法如下
def HASH_SLOT(key)
s = key.index "{"
if s
e = key.index "}",s+1
if e && e != s+1
key = key[s+1..e-1]
end
end
crc16(key) % 16384
end
3、哈希槽的内部实现
Redis集群中每个节点都会维护集群中所有节点的_**clusterNode**_结构体- 其中的
_**slots**_属性是个二进制位数组,长度为2048 bytes,共包含16384个_**bit**_位,节点可以根据某个_**bit**_的 0/1 值判断对应的_**slot**_是否由当前节点处理。 - 每个节点通过
_**clusterStats**_结构体来保存从自身视角看去的集群状态,其中_**nodes**_属性是一个保存节点名称和_**clusterNode**_指针的字典,而_**slots**_数组是一个记录哪个_**slot**_属于哪个_**clusterNode**_结构体的数组
typedef struct clusterState {
... ...
// 保存集群节点的字典,键是节点名字,值是clusterNode结构的指针
dict *nodes; /* Hash table of name -> clusterNode structures */
// 槽和负责槽节点的映射
clusterNode *slots[CLUSTER_SLOTS];
... ...
} clusterState;
typedef struct clusterNode {
... ...
unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
int numslots; /* Number of slots handled by this node */
... ...
} clusterNode;
4、哈希槽的迁移
- 线上集群因为扩容和缩容操作,经常需要迁移
_slot__ _对数据进行重新分片 - 原生的
Redis Cluster可以借助 redis-trib 工具进行迁移。Squirrel 使用自研的 Squirrel migrate 进行数据迁移和分片_rebalance_。 - slot 在迁移过程有两个状态,在迁出节点会对该
_slot_标记为_MIGRATING_,在迁入节点会对该_slot_标记为_IMPORTING_。 - 当该
_slot_内的_Key_都迁移完毕之后,新的_slot_归属信息都进过消息协议进行传播,最终集群中所有节点都会知道该_slot_已经迁移到了目标节点,并更新自身保存的slot和节点间的映射关系。
三、MOVED & ASK
redis-cli 是官方提供的客户端脚本,我们可以通过 redis-cli -c -p port 命令连接任意一个 master,开始使用集群
1、详解MOVED
- 我们通过
_redis-cli__ _可以发起对集群的读写请求,节点会计算我们请求的Key所属的_slot_ - 一旦发现该
_slot_并非由自己负责的话,会向客户端返回一个_MOVED_错误(需要注意的是集群模式下_redis-cli_不会打印_MOVED_错误而是会直接显示_Redirected_,使用单机版_redis-cli_连接则可以看到_MOVED_错误) - 指引客户端重定向到正确的节点,并再次发送先前的命令,得到正确的结果
# cluster 模式
10.72.227.3:6380> set gfdsdf sdf
-> Redirected to slot [6901] located at 10.72.227.2:6381
OK
# stand alone 模式
192.168.0.16:6379> set myKey myValue
(error) MOVED 16281 192.168.0.14:6379
192.168.0.16:6379> get myKey
(error) MOVED 16281 192.168.0.14:6379
2、详解ASK
_MOVED_意为这个_slot_的负责已经永久转交给另一个节点,因此可以直接把请求准发给现在负责该_slot_的节点。- 但是考虑在
_slot_迁移过程中,会出现属于该_slot_的一部分Key已经迁移到目的地节点,而另一部分Key还在源节点 - 那如果这时收到了关于这个
_slot_的请求,那么源节点会现在自己的数据库里查找是否有这个Key,查到的话说明还未迁移那么直接返回结果,查询失败的话就说明Key已经迁移到目的地节点,那么就向客户端返回一个_ASK_错误,指引客户端转向目的地节点查询该Key。 - 同样该错误仅在单机版
redis-cli连接时打印。
3、客户端处理
- 这两个错误在实际线上环境中出现频率很高,那么定制化的客户端如何处理这二者呢?
- 如果客户端每次都随机连接一个节点然后利用
_MOVED_或者_ASK_来重定向其实是很低效的 - 所以一般客户端会在启动时通过解析_
_CLUSTER NODES__或者_CLUSTER SLOTS_命令返回的结果得到_slot_和节点的映射关系缓存在本地 - 一旦遇到
_MOVED_或者_ASK_错误时会再次调用命令刷新本地路由(因为线上集群一旦出现_MOVED_或者是_ASK_往往是因为扩容分片导致数据迁移,涉及到许多_slot_的重新分配而非单个,因此需要整体刷新一次) - 这样集群稳定时可以直接通过本地路由表迅速找到需要连接的节点。
四、故障检测
-
跟大多数分布式系统一样,
Redis Cluster的节点间通过持续的_heart beat_来保持信息同步 -
不过
Redis Cluster节点信息同步是内部实现的,并不依赖第三方组件如_Zookeeper_。 -
集群中的节点持续交换
_PING_、_PONG_数据,消息协议使用_ _Gossip,这两种数据包的数据结构一样,之间通过_type_字段进行区分。 -
Redis集群中的每个节点都会定期向集群中的其他节点发送_PING_消息,以此来检测对方是否存活 -
如果接收
_PING_消息的节点在规定时间内(_node_timeout_)没有回复_PONG_消息,那么之前向其发送_PING_消息的节点就会将其标记为疑似下线状态(_PFAIL_)。 -
每次当节点对其他节点发送
_PING_命令的时候,它都会随机地广播三个它所知道的节点的信息,这些信息里面的其中一项就是说明节点是否已经被标记为_PFAIL_或者_FAIL_。 -
当节点接收到其他节点发来的信息时,它会记下那些被集群中其他节点标记为
_PFAIL_的节点,这称为失效报告(_failure report_)。 -
如果节点已经将某个节点标记为
_PFAIL_,并且根据自身记录的失效报告显示,集群中的大部分_master_也认为该节点进入了_PFAIL_状态,那么它会进一步将那个失效的_master_的状态标记为_FAIL_。 -
随后它会向集群广播 “该节点进一步被标记为 FAIL ” 的这条消息,所有收到这条消息的节点都会更新自身保存的关于该
_master__ _节点的状态信息为_FAIL_
五、故障转移(Failover)
1、纪元(epoch)
Redis Cluster 使用了类似于 _Raft_ 算法 _term_(任期)的概念称为 _epoch_(纪元),用来给事件增加版本号。Redis 集群中的纪元主要是两种:_currentEpoch_ 和 _configEpoch_。
1.1、currentEpoch
-
这是一个集群状态相关的概念,可以当做记录集群状态变更的递增版本号。
-
每个集群节点,都会通过
server.cluster->currentEpoch记录当前的_currentEpoch_。 -
集群节点创建时,不管是
_master_还是_slave_,都置_currentEpoch_为 0。 -
当前节点接收到来自其他节点的包时,如果发送者的
_currentEpoch_(消息头部会包含发送者的_currentEpoch_)大于当前节点的_currentEpoch_,那么当前节点会更新_currentEpoch_为发送者的_currentEpoch_。
1.2、currentEpoch 作用
_currentEpoch_作用在于,当集群的状态发生改变,某个节点为了执行一些动作需要寻求其他节点的同意时,就会增加_currentEpoch_的值。- 目前
_currentEpoch_只用于_slave_的故障转移流程,这就跟哨兵中的sentinel.current_epoch作用是一模一样的。 - 当
_slave A_发现其所属的_master_下线时,就会试图发起故障转移流程。
1.3、configEpoch
-
这是一个集群节点配置相关的概念,每个集群节点都有自己独一无二的
configepoch。 -
所谓的节点配置,实际上是指节点所负责的槽位信息。
-
每一个
_master_在向其他节点发送包时,都会附带其_configEpoch_信息,以及一份表示它所负责的_slots_信息。 -
而
_slave_向其他节点发送包时,其包中的_configEpoch_和负责槽位信息,是其_master_的_configEpoch_和负责的_slot_信息。
1.4、configEpoch 作用
_configEpoch_主要用于解决不同的节点的配置发生冲突的情况。- 举个例子就明白了:
- 节点
A宣称负责_slot 1_,其向外发送的包中,包含了自己的_configEpoch_和负责的_slots_信息。 - 节点
C收到A发来的包后,发现自己当前没有记录_slot 1_的负责节点(也就是 server.cluster->slots[1] 为 NULL),就会将A置为_slot 1_的负责节点(server.cluster->slots[1] = A),并记录节点 A 的_configEpoch_。 - 后来,节点
C又收到了B发来的包,它也宣称负责_slot 1_,此时,如何判断_slot 1__ _到底由谁负责呢? - 这就是
_configEpoch_起作用的时候了,C在B发来的包中,发现它的_configEpoch_,要比A的大,说明B是更新的配置。 - 因此,就将
_slot 1_的负责节点设置为B(server.cluster->slots[1] = B)。 - 在
_slave_发起选举,获得足够多的选票之后,成功当选时,也就是_slave_试图替代其已经下线的旧_master_,成为新的_master_时,会增加它自己的_configEpoch_,使其成为当前所有集群节点的_configEpoch_中的最大值。 - 这样,该
_slave_成为_master_后,就会向所有节点发送广播包,强制其他节点更新相关_slots_的负责节点为自己。
- 节点
2、Failover
2.1、自动 Failover
- 当一个
_slave_发现自己正在复制的_master_进入了已下线(_FAIL_)状态时,_slave_将开始对已下线状态的_master_进行故障转移,以下是故障转移执行的步骤 - 该下线的
_master_下所有_slave_中,会有一个_slave_被选中。- 具体的选举流程为:
slave自增它的_currentEpoch_值,然后向其他_masters_请求投票,每个_slave_都向集群其他节点广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息用于拉票- 集群中具有投票权的
_master_收到消息后,如果在当前选举纪元中没有投过票,就会向第一个发送来消息的_slave_返回CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示投票给该_slave_。 - 某个
_slave_如果在一段时间内收到了大部分_master_的投票,则表示选举成功。
- 被选中的
_slave_会执行SLAVEOF no one命令,成为新的_master_ - 新的
_master_会撤销所有对已下线_master_的_slot_指派,并将这些_slot_全部指派给自己 - 新的
_master_向集群广播一条_PONG_消息,这条_PONG_消息可以让集群中的其他节点立即知道自己已经由_slave_变成了_master_,并且这个_master_已经接管了原本由已下线节点负责处理的_slot_ - 新的
_master_开始接收和自己负责处理的_slot_有关的命令请求,故障转移完成
2.2、手动Failover
Redis集群支持手动故障转移,也就是向_slave_发送CLUSTER FAILOVER命令,使其在master未下线的情况下,发起故障转移流程,升级为新的_master_,而原来的_master_降级为_slave_。- 为了不丢失数据,向
_slave_发送CLUSTER FAILOVER命令后,流程如下:_slave_收到命令后,向_master_发送CLUSTERMSG_TYPE_MFSTART命令_master_收到该命令后,会将其所有客户端置于阻塞状态,也就是在 10s 的时间内,不再处理客户端发来的命令,并且在其发送的心跳包中,会带有CLUSTERMSG_FLAG0_PAUSED标记_slave_收到_master_发来的,带CLUSTERMSG_FLAG0_PAUSED标记的心跳包后,从中获取_master_当前的复制偏移量,_slave_等到自己的复制偏移量达到该值后,才会开始执行故障转移流程:发起选举、统计选票、赢得选举、升级为_master_并更新配置
CLUSTER FAILOVER命令支持两个选项:_FORCE_和_TAKEOVER_。使用这两个选项,可以改变上述的流程。- 如果有
_FORCE_选项,则_slave_不会与_master_进行交互,_master_也不会阻塞其客户端,而是_slave_立即开始故障转移流程:发起选举、统计选票、赢得选举、升级为 master 并更新配置。 - 如果有
_TAKEOVER_选项,则更加简单直接,_slave_不再发起选举,而是直接将自己升级为_master_,接手原_master_的_slot_,增加自己的_configEpoch_后更新配置。
- 如果有
- 因此,使用
_FORCE_和_TAKEOVER_选项,master 可以已经下线;而不使用任何选项,只发送 CLUSTER FAILOVER命令的话,_master_必须在线
六、集群
1、集群消息
- 搭建
Redis Cluster时,首先通过CLUSTER MEET命令将所有的节点加入到一个集群中,但是并没有在所有节点两两之间都执行CLUSTER MEET命令,因为节点之间使用_Gossip_协议进行工作。 _Gossip_翻译过来就是流言,类似与病毒传播一样,只要一个人感染,如果时间足够,那么和被感染的人在一起的所有人都会被感染,因此随着时间推移,集群内的所有节点都会互相知道对方的存在。- 在 Redis 集群中,节点信息是如何传播的呢?
- 答案是通过发送
PING或PONG消息时,会包含节点信息,然后进行传播的。 - 先介绍一下
Redis Cluster中,消息是如何抽象的。一个消息对象可以是_PING_、_PONG_、_MEET_,也可以是_PUBLISH_、_FAIL_等。他们都是 clusterMsg 类型的结构,该类型主要由消息包头部和消息数据组成。- 消息包头部包含签名、消息总大小、版本和发送消息节点的信息。
- 消息数据则是一个联合体
union clusterMsgData,联合体中又有不同的结构体来构建不同的消息。
_PING_、_PONG_、_MEET_ 属于一类,是 clusterMsgDataGossip 类型的数组,可以存放多个节点的信息,该结构如下:
/* Initially we don't know our "name", but we'll find it once we connect
* to the first node, using the getsockname() function. Then we'll use this
* address for all the next messages. */
typedef struct {
// 节点名字
char nodename[CLUSTER_NAMELEN];
// 最近一次发送PING的时间戳
uint32_t ping_sent;
// 最近一次接收PONG的时间戳
uint32_t pong_received;
// 节点的IP地址
char ip[NET_IP_STR_LEN]; /* IP address last time it was seen */
// 节点的端口号
uint16_t port; /* port last time it was seen */
// 节点的标识
uint16_t flags; /* node->flags copy */
// 未使用
uint16_t notused1; /* Some room for future improvements. */
uint32_t notused2;
} clusterMsgDataGossip;
- 每次发送 MEET、PING、PONG 消息时,发送者都从自己的已知节点列表中随机选出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个结构中。
- 当接收者收到消息时,接收者会访问消息正文中的两个结构,并根据自己是否认识
clusterMsgDataGossip结构中记录的被选中节点进行操作:- 如果被选中节点不存在于接收者的已知节点列表,那么说明接收者是第一次接触到被选中节点,接收者将根据结构中记录的IP地址和端口号等信息,与被选择节点进行握手。
- 如果被选中节点已经存在于接收者的已知节点列表,那么说明接收者之前已经与被选中节点进行过接触,接收者将根据 clusterMsgDataGossip 结构记录的信息,对被选中节点对应的 clusterNode 结构进行更新。
- 有了消息之后,如何选择发送消息的目标节点呢?
- 虽然
_PING___PONG__发送的频率越高就可以越实时得到其它节点的状态数据,但_Gossip_消息体积较大,高频发送接收会加重网络带宽和消耗CPU的计算能力,因此每次 Redis 集群都会有目的性地选择一些节点;但节点选择过少又会影响故障判断的速度,Redis 集群的 Gossip 协议选择这样的解决方案:
2、集群数据一致性
Redis 集群尽可能保证数据的一致性,但在特定条件下会丢失数据,原因有两点:异步复制机制以及可能出现的网络分区造成脑裂问题
2.1、异步复制
_master_ 以及对应的 _slaves_ 之间使用异步复制机制,考虑如下场景:
- 写命令提交到
_master_,_master_执行完毕后向客户端返回 OK - 但由于复制的延迟此时数据还没传播给
_slave_;如果此时 master 不可达的时间超过阀值,此时集群将触发_failover_,将对应的_slave_选举为新的_master_,此时由于该_slave_没有收到复制流,因此没有同步到_slave_的数据将丢失
2.2、脑裂(split-brain)
在发生网络分区时,有可能出现新旧 _master_ 同时存在的情况,考虑如下场景:
- 由于网络分区,此时
_master_不可达,且客户端与_master_处于一个分区,并且由于网络不可达,此时客户端仍会向_master_写入。 - 由于
_failover_机制,将其中一个_slave_提升为新的_master_ - 等待网络分区消除后,老的
_master__ 再次可达,但此时该节点会被降为_slave_清空自身数据然后复制新的_master__ - 而在这段网络分区期间,客户端仍然将写命令提交到老的
_master_,但由于被降为_slave_角色这些数据将永远丢失


