一、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_
角色这些数据将永远丢失