Redis高可用方案Cluster
Redis Cluster介绍
Cluster 优势
- 线性的可扩展性:扩容即迁移槽,已有很多迁移案例;
如果要保存更多的数据,可以直接增加Master来支持,比如每台Master存32G,要完美存下1T数据的话,可以设置32台的Master,当然实际情况下这样非常浪费,一般会少设置一些,只用几台Master来存储最热的数据。 - 没有合并操作:因为 Redis 中的 List 和 Set 中保存的 Value 通常是比较大的,可能会达数以百万计的元素,而它们可能被存储到了不同的 Redis 实例上,传输和合并这样的值将很容易称为一个主要的性能瓶颈;
- 写入安全(Write Safety):只有在非常少见的 Master 宕机的情况下,写入才会失败,并且这个失败的时间窗口不大(由一个 Slave 顶替上来);
- 可用性(Availability):就算有部分 Master 不可用了,它们的 Slave 仍然可以通过选举提升为 Master。
Cluster 缺点
- Redis 集群并不支持处理多个 keys 的命令,因为这需要在不同的节点间移动数据,从而达不到像 Redis 那样的性能,在高负载的情况下可能会导致不可预料的错误。
- Redis 集群不像单机版本的 Redis 那样支持多个数据库,集群只有数据库 0,而且也不支持 SELECT 命令。
Cluster的去中心化架构

redis cluster在设计的时候,就考虑到了去中心化,去中间件,也就是说,集群中的每个节点都是平等的关系,都是对等的,每个节点都保存各自的数据和整个集群的状态。所有的 redis 节点彼此互联(PING-PONG 机制),内部使用二进制协议优化传输速度和带宽,而且这些连接保持活跃,这样就保证了我们只需要连接集群中的任意一个节点,就可以获取到其他节点的数据。客户端与 redis 节点直连,不需要中间 proxy 层,客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
一致性
Redis 不能保证强一致性,因为:
- 异步复制:写操作会被异步复制到 slave 节点,但可能由于出现网络分区、脑裂而导致数据丢失。

如上图所示,客户端 Z1 向 Master-B 写入数据后,集群出现了网络分区,且分区持续的时间足够长导致此时 B1 被选举为新的 Master,则在此期间 Z1 向 B 写入的数据就都丢失了。网络分区出现期间,客户端 Z1 可以向主节点 B 发送写命令的最大时间是有限制的, 这一时间限制称为节点超时时间(node timeout), 是 Redis 集群的一个重要的配置选项。
Cluster VS Codis
Codis 集群中包含了 4 类关键组件。
- codis server:这是进行了二次开发的 Redis 实例,其中增加了额外的数据结构,支持数据迁移操作,主要负责处理具体的数据读写请求。
- codis proxy:接收客户端请求,并把请求转发给 codis server。
- Zookeeper 集群:保存集群元数据,例如数据位置信息和 codis proxy 信息。
- codis dashboard 和 codis fe:共同组成了集群管理工具。其中,codis dashboard 负责执行集群管理工作,包括增删 codis server、codis proxy 和进行数据迁移。而 codis fe 负责提供 dashboard 的 Web 操作界面,便于我们直接在 Web 界面上进行集群管理。
Codis如何处理一次请求:
- 客户端连接Codis Proxy,将请求发给Proxy;
- codis proxy 接收到请求,就会查询请求数据和 codis server 的映射关系,并把请求转发给相应的 codis server 进行处理
- 当 codis server 处理完请求后,会把结果返回给 codis proxy,proxy 再把数据返回给客户端。
以4个方面来讨论
- 数据分布
和Cluster类似,Codis将数据保存到slot,集群一共有1024个slot,需要手动分配给Codis Server,或者由dashboard自动分配。
当客户端要读写数据时,会使用CRC32算法计算key的哈希值,并对1024取模,就可以知道对应的是哪个slot了。
这个路由规则需要先配置到dashboard,dashboard会把路由表发送给codis proxy,并同时保存到ZooKeeper。
而Cluster的数据路由表是由每个实例管理的,如果发生变化,这些实例会通过Gossip协议来互相传播,如果实例比较多,就会占用比较多的网络资源。 - 集群扩容和数据迁移
如果要增加Codis Server来负载slot,需要配置要迁移的slot,Codis Server会将该slot中的数据一个一个地发送给目标Server。
增加Codis Proxy也是类似的流程。 - 客户端兼容性
Codis客户端直接和Codis Proxy连接,codis proxy 是和单实例客户端兼容的,而集群相关的管理工作又都是由Codis Proxy和Codis dashboard这些组件来完成的,不需要客户端参与。 - 可靠性保证
Codis Server保证可靠性:Codis Server本身是Redis实例,只是增加了集群相关的操作命令,可靠性是可以通过主从机制+哨兵来实现的。
Codis Proxy的可靠性:Proxy上的信息都来自ZooKeeper,例如路由表,只要ZooKeeper集群中实例半数以上可以正常工作,那么ZooKeeper集群就是正常的。
比较:
- 从稳定性和成熟度来看,Codis 应用得比较早,在业界已经有了成熟的生产部署。虽然 Codis 引入了 proxy 和 Zookeeper,增加了集群复杂度,但是,proxy 的无状态设计和 Zookeeper 自身的稳定性,也给 Codis 的稳定使用提供了保证。而 Redis Cluster 的推出时间晚于 Codis,相对来说,成熟度要弱于 Codis,如果你想选择一个成熟稳定的方案,Codis 更加合适些。
- 从业务应用客户端兼容性来看,连接单实例的客户端可以直接连接 codis proxy,而原本连接单实例的客户端要想连接 Redis Cluster 的话,就需要开发新功能。所以,如果你的业务应用中大量使用了单实例的客户端,而现在想应用切片集群的话,建议你选择 Codis,这样可以避免修改业务应用中的客户端。
- 从使用 Redis 新命令和新特性来看,Codis server 是基于开源的 Redis 3.2.8 开发的,所以,Codis 并不支持 Redis 后续的开源版本中的新增命令和数据类型。另外,Codis 并没有实现开源 Redis 版本的所有命令,比如 BITOP、BLPOP、BRPOP,以及和与事务相关的 MUTLI、EXEC 等命令。Codis 官网上列出了不被支持的命令列表,你在使用时记得去核查一下。所以,如果你想使用开源 Redis 版本的新特性,Redis Cluster 是一个合适的选择。
- 从数据迁移性能维度来看,Codis 能支持异步迁移,异步迁移对集群处理正常请求的性能影响要比使用同步迁移的小。所以,如果你在应用集群时,数据迁移比较频繁的话,Codis 是个更合适的选择。
数据分布(分区)和查询路由
分区策略
分区将原来比较大的数据集分离存储到多个存储媒介上,分区后Redis可以管理更大的内存空间和计算能力,但同时多主机又会面临很多分布式集群的可用性、一致性等问题。
分区策略:
- 范围分区
将不同范围的对象映射到不同的Redis实例,比如用户ID为0到10000的存储到R0,10001到20000的存储到R1,以此类推。
缺点是需要建立一张映射表,谨小甚微地维护ID和Redis实例之间的映射关系,而且由于需要维护表,导致效率不如其他方案。 - 散列分区
使用散列函数(如CRC32)将key转换为一个数字,取模得到一个0到3的数字(假设Redis服务器有4台),这个数字即对应服务器的序号。 - 一致性哈希
一致性哈希的一种示例实现可以参考Dubbo中的实现:com.alibaba.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance
关键代码如下:
- 哈希槽
Redis Cluster采用的是哈希槽的方式。
Redis 集群没有并使用传统的一致性哈希来分配数据,而是采用另外一种叫做哈希槽 (hash slot)的方式来分配的。redis cluster 默认分配了 16384 个 slot,当我们 set 一个 key 时,会用CRC16算法来取模得到所属的 slot,然后将这个 key 分到哈希槽区间的节点上,具体算法就是:CRC16(key) % 16384。所以我们在测试的时候看到 set 和 get 的时候,直接跳转到了 7000 端口的节点。
客户端在接收到重定向错误(redirections errors) -MOVED 和 -ASK 的时候, 将命令重定向到其他节点。客户端不需要存储集群信息(槽所在位置),但是如何客户端可以缓存键值和节点之间的映射关系,就可以明显提高命令执行的效率了(Redisson 中就是这么做的)。
在 Cluster 架构中,slave 节点不分配槽,只拥有读权限,但是在代码中 cluster 执行读写操作的都是 master 节点,并不是读就是从节点、写就是主节点。
源码中,Redis采用一个大小固定为CLUSTER_SLOTS的clusterNode数组slots来保存每个桶的负责节点,这是个字节数组,每个位表示当前节点是否负责这个槽:
1 | // 节点状态 |
分区的实现层次
分区可以在程序的不同层次实现。
- 客户端分区
就是在客户端就已经决定数据会被存储到哪个redis节点或者从哪个redis节点读取。大多数客户端已经实现了客户端分区。 - 代理分区
意味着客户端将请求发送给代理,然后代理决定去哪个节点写数据或者读数据。代理根据分区规则决定请求哪些Redis实例,然后根据Redis的响应结果返回给客户端。redis和memcached的一种代理实现就是Twemproxy - 查询路由(Query routing)
意思是客户端随机地请求任意一个redis实例,然后由Redis将请求转发给正确的Redis节点。Redis Cluster实现了一种混合形式的查询路由,但并不是直接将请求从一个redis节点转发到另一个redis节点,而是在客户端的帮助下直接redirected到正确的redis节点。
Redis Cluster采用的是查询路由的方式。
在Cluster模式下,Redis接收任何命令都会首先计算键对应的桶编号,再根据桶找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点,这个过程称为MOVED重定向。
在客户端初次连接Redis集群时,如果客户端是Smart Client,它会获取集群的节点信息及slot的分布信息,并在本地缓存一份 hash slot 与node关系的路由表,这样不必每次访问服务器时都因为重定向而经过多次网络调用。
redis-cli不是smart client,它没有缓存路由表的功能;Java客户端Redisson是smart client,它在初始化时会调用redis实例的CLUSTER NODES命令来获取集群中每个Master负责的slot范围,并启动一个定时任务来每秒刷新本地缓存的集群状态:
1 | /** |
下面是手动调用cluster nodes可以得到的响应,从中可以看到每个master所负责的slot范围:
1 | hgc@hgc-X555LD:~$ redis-cli -h 10.32.64.12 -p 16371 -c |
如果Cluster发生了扩容缩容或failover导致客户端缓存的信息过期,客户端只需要MOVED时重新更新本地缓存即可。
但是这里有一个问题,如果扩容缩容时正在发生槽迁移,这时正在迁移中的槽在哪个节点是不确定的,可能会导致客户端本地缓存的频繁更新。因此,Redis迁移过程中,会对正在迁移的槽打标记(server.cluster->migrating_slots_to),如果客户端访问的key命中了正在迁移中的槽,则服务器会返回ASK而不是MOVED,客户端接收到ASK后不会重新更新本地的槽缓存。
代码:redis.c/processCommand
1 | /* If cluster is enabled perform the cluster redirection here. |
分区的缺点
有些特性在分区的情况下会受到限制:
- 涉及多个key的操作通常不会被支持。例如你不能对两个集合求交集,因为他们可能被存储到不同的Redis实例(实际上这种情况也有办法,但是不能直接使用交集指令)。
同时操作多个key,则不能使用Redis事务. - 分区使用的粒度是key,不能使用一个非常长的排序key存储一个数据集(The partitioning granularity is the key, so it is not possible to shard a dataset with a single huge key like a very big sorted set).
- 当使用分区的时候,数据处理会非常复杂,例如为了备份你必须从不同的Redis实例和主机同时收集RDB / AOF文件。
- 分区时动态扩容或缩容可能非常复杂。Redis集群在运行时增加或者删除Redis节点,能做到最大程度对用户透明地数据再平衡,但其他一些客户端分区或者代理分区方法则不支持这种特性。然而,有一种预分片的技术也可以较好的解决这个问题。
当要把Redis当作持久化存储时,需要注意分区的性质
- 如果Redis被当做缓存使用,使用一致性哈希实现动态扩容缩容。
- 如果Redis被当做一个持久化存储使用,必须使用固定的keys-to-nodes映射关系,节点的数量一旦确定不能变化。否则的话(即Redis节点需要动态变化的情况),必须使用可以在运行时进行数据再平衡的一套系统,现在Redis Cluster已经支持这种再平衡。
节点通信
Redis Cluster采用Gossip协议完成集群状态数据及路由数据等元数据的管理。
一种简单的集群内状态同步思路是:每次节点都将自己本地的集群状态数据广播到集群内所有N个节点,其他节点判断接收到的数据比本地的新则更新本地数据。但是这种方式的缺点是通信量剧增,网络带宽变得紧张。
因此Redis采用Gossip协议来进行集群内元数据的同步,而且:
1、每次只随机选择K(K << N)个其他节点来同步状态;
集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用于保证Gossip信息交换的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最近一次接受pong消息的时间大于cluster_node_timeout/2,则立刻发送ping消息,防止该节点信息太长时间未更新。根据以上规则得出每个节点每秒需要发送ping消息的数量=1+10*num(node.pong_received>cluster_node_timeout/2),因此cluster_node_timeout参数对消息发送的节点数量影响非常大。当我们的带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。过度调大cluster_node_timeout会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。
1 | /* This is executed 10 times every second */ |
2、状态信息并不是全量同步,而是随机选M(M << N)个节点的状态同步到其他节点。
M值最小为3,最大为N - 2,一般情况下M = N / 10。
1 | /* Send a PING or PONG packet to the specified node, making sure to add enough |
扩容 / 缩容
当新的节点加入时,我们该如何重新分配数据,让新的节点也对外提供服务。当有节点退出时,我们该如何把存在该节点上的数据分配到其他机器上,让其他机器来提供这部分数据的服务。即集群的扩缩容问题。
新节点加入流程
新节点加入时,需要把一部分数据迁移到新节点来达到集群的负载均衡。
在Redis集群中,数据的存储是以slot为单位的,因此:
- 集群的伸缩本质上就是slot在不同机器节点之间的迁移;
- 迁移过程中,有的slot在老节点上,有的slot在新节点上,这时,客户端请求应该被重定向到正确的节点上。
比如slot1从A迁移到B上时,请求A或B会怎么样?请求别的节点又会怎么样?
节点的迁移过程主要分为3个步骤:
- 准备新节点
- 加入集群
- 迁移slot到新节点
以下迁移过程的伪代码来自:Redis集群详解(中)
1 | def move_slot(source,target,slot): |
节点迁移过程
以下命令告知目标节点准备导入slot:
1 | cluster setslot <slot> IMPORTING <nodeId> |
以下命令告知目标节点准备导出slot:
1 | cluster setslot <slot> MIGRATING <nodeId> |
每个节点保存的集群状态中记录了迁移中的slot,其中,迁出的slot放到migrating_slots_to中,迁入的slot放到importing_slots_from:
1 | typedef struct clusterState { |
接下来,将待迁移slot中的key批量转移到目标节点:
1 | # 返回count个slot中的键 |
migrate命令就是向节点发送了N个RESTORE-ASKING命令,实现代码如下:
1 | /* Create RESTORE payload and generate the protocol to call the command. */ |
迁入节点接收到restore-asking命令后,执行节点的恢复操作,即获取key,解析出value,然后写入数据库:
1 | /* RESTORE key ttl serialized-value [REPLACE] */ |
迁移过程中,在外部客户端的视角看来,在任意时间点上,key只会存在于某个节点上,而不会同时存在于两个节点上。
现在,待迁移槽中的key都已经被迁移了,但是对其他节点来说,该slot仍是由迁出节点负责的,它们接收到相关请求后仍然会路由到迁出节点,所以迁移的最后一步需要向集群中的所有主节点通知槽已经被分配给目标节点。
1 | cluster setslot <slot> node <nodeId> |
迁移过程中对新请求的响应
迁移过程中:
- 如果迁出节点接收请求,迁出节点判断slot或key是否已迁出,若是则ASK重定向到迁入节点上,否则迁出节点自己负责处理请求;
- 如果迁入节点接收请求,会把请求重定向到迁出节点上,除非请求中包含ASKING命令;
- 其他节点接收到的相关请求会被重定向到迁出节点上;注意上边的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69int processCommand(redisClient *c) {
...
/* If cluster is enabled perform the cluster redirection here.
*
* 如果开启了集群模式,那么在这里进行转向操作。
*
* However we don't perform the redirection if:
*
* 不过,如果有以下情况出现,那么节点不进行转向:
*
* 1) The sender of this command is our master.
* 命令的发送者是本节点的主节点
*
* 2) The command has no key arguments.
* 命令没有 key 参数
*/
if (server.cluster_enabled &&
!(c->flags & REDIS_MASTER) &&
!(c->cmd->getkeys_proc == NULL && c->cmd->firstkey == 0))
{
int hashslot;
// 集群已下线
if (server.cluster->state != REDIS_CLUSTER_OK) {
flagTransaction(c);
addReplySds(c,sdsnew("-CLUSTERDOWN The cluster is down. Use CLUSTER INFO for more information\r\n"));
return REDIS_OK;
// 集群运作正常
} else {
int error_code;
clusterNode *n = getNodeByQuery(c,c->cmd,c->argv,c->argc,&hashslot,&error_code);
// 不能执行多键处理命令
if (n == NULL) {
flagTransaction(c);
if (error_code == REDIS_CLUSTER_REDIR_CROSS_SLOT) {
addReplySds(c,sdsnew("-CROSSSLOT Keys in request don't hash to the same slot\r\n"));
} else if (error_code == REDIS_CLUSTER_REDIR_UNSTABLE) {
/* The request spawns mutliple keys in the same slot,
* but the slot is not "stable" currently as there is
* a migration or import in progress. */
addReplySds(c,sdsnew("-TRYAGAIN Multiple keys request during rehashing of slot\r\n"));
} else {
redisPanic("getNodeByQuery() unknown error.");
}
return REDIS_OK;
// 命令针对的槽和键不是本节点处理的,进行转向
} else if (n != server.cluster->myself) {
flagTransaction(c);
// -<ASK or MOVED> <slot> <ip>:<port>
// 例如 -ASK 10086 127.0.0.1:12345
addReplySds(c,sdscatprintf(sdsempty(),
"-%s %d %s:%d\r\n",
(error_code == REDIS_CLUSTER_REDIR_ASK) ? "ASK" : "MOVED",
hashslot,n->ip,n->port));
return REDIS_OK;
}
// 如果执行到这里,说明键 key 所在的槽由本节点处理
// 或者客户端执行的是无参数命令
}
}
...
}getNodeByQuery根据key的散列结果查询命令应该被打到的节点,可以看到这个函数里有对ASKING标识的特殊处理:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21clusterNode *getNodeByQuery(redisClient *c, struct redisCommand *cmd, robj **argv, int argc, int *hashslot, int *error_code) {
...
/* If we are receiving the slot, and the client correctly flagged the
* request as "ASKING", we can serve the request. However if the request
* involves multiple keys and we don't have them all, the only option is
* to send a TRYAGAIN error. */
if (importing_slot &&
(c->flags & REDIS_ASKING || cmd->flags & REDIS_CMD_ASKING))
{
if (multiple_keys && missing_keys) {
if (error_code) *error_code = REDIS_CLUSTER_REDIR_UNSTABLE;
return NULL;
} else {
return myself;
}
}
...
}
旧节点退出流程
与新节点的加入相反的是,旧节点退出时需要把其上的数据迁移到其他节点上,确保该节点上的数据能够被正常访问。
槽的迁移过程和上边扩容中描述的没有区别,主要区别是在迁移完毕后需要轮询每个节点发送cluster forget命令,让它们能忘记下线的节点。
节点在接收cluster forget命令后,会将目标节点的状态从自己保存的集群状态中移除,并将其加入黑名单中60s,这期间其他节点不会再去更新自己维护的该节点的信息,也就是说这60秒内该节点无法重新加入集群内。
1 | def delnode_cluster_cmd(downNode): |
集群规模估算
集群规模并不是没有限制的,理论上每个节点一个slot集群可以扩容到16384个节点,但是Redis官方给出的规模上限是一个集群1000个节点,因为实例间的通信开销会随着实例规模增加而增大。
下面来讨论下集群内部有哪些交互,并分析它们会对性能有什么样的影响。
实例间数据的同步
集群每个节点都会记录slot和实例间的映射关系,用于请求的重定向。
每个实例都需要通过Gossip协议将数据同步到其他节点,大致流程为:
- 每个实例之间会按照一定的频率,从集群中随机挑选一些实例,把 PING 消息发送给挑选出来的实例,用来检测这些实例是否在线,并交换彼此的状态信息。PING 消息中封装了发送消息的实例自身的状态信息、部分其它实例的状态信息,以及 Slot 映射表。
发送的节点状态信息在源码中由clusterMsgDataGossip这个结构来表示,大小为104字节。每个实例在发送Gossip消息时,除了传递自身的状态信息,默认还会传递集群十分之一实例的状态信息,比如,对于一个包含了 1000 个实例的集群来说,每个实例发送一个 PING 消息时,会包含 100 个实例的状态信息,总的数据量是 10400 字节,再加上发送实例自身的信息,一个 Gossip 消息大约是 10KB。
另外,Slot映射表是一个16384位的bitmap,算上上面的10KB就是12KB的内容。 - 一个实例在接收到 PING 消息后,会给发送 PING 消息的实例,发送一个 PONG 消息。PONG 消息包含的内容和 PING 消息一样,也是12KB。
- 另外,上面是随机选节点发PING请求的,如果部分节点一直没有被选到,就会导致这些节点和其他节点不同步。
为了避免这种情况,Redis Cluster 的实例会按照每 100ms 一次的频率,扫描本地的实例列表,如果发现有实例最近一次接收 PONG 消息的时间,已经大于配置项 cluster-node-timeout 的一半了(cluster-node-timeout/2),就会立刻给该实例发送 PING 消息,更新这个实例上的集群状态信息。
当集群规模扩大之后,因为网络拥塞或是不同服务器间的流量竞争,会导致实例间的网络通信延迟增加。如果有部分实例无法收到其它实例发送的 PONG 消息,就会引起实例之间频繁地发送 PING 消息,这又会对集群网络通信带来额外的开销了。
从上可知,实例间的数据同步受到通信消息大小和通信频率这两方面的影响。
当集群规模扩大后,PING/PONG会占用大量的集群内网络带宽,降低集群服务正常请求的吞吐量。
单实例每秒会发送的PING消息数量大致可以算出是(注意cron是100ms执行一次):
1 | PING 消息发送数量 = 1 + 10 * 实例数(最近一次接收 PONG 消息的时间超出 cluster-node-timeout/2) |
其中,1 是指单实例常规按照每 1 秒发送一个 PING 消息,10 是指每 1 秒内实例会执行 10 次检查,每次检查后会给 PONG 消息超时的实例发送消息。
假设单个实例检测发现,每 100 毫秒有 10 个实例的 PONG 消息接收超时,那么,这个实例每秒就会发送 101 个 PING 消息,约占 1.2MB/s 带宽。如果集群中有 30 个实例按照这种频率发送消息,就会占用 36MB/s 带宽,这就会挤占集群中用于服务正常请求的带宽。
因此实例间的通信开销优化主要是:
- 减少实例传输的消息大小(PING/PONG 消息、Slot 分配信息)
但是,因为集群实例依赖 PING、PONG 消息和 Slot 分配信息,来维持集群状态的统一,一旦减小了传递的消息大小,就会导致实例间的通信信息减少,不利于集群维护,所以,我们不能采用这种方式。 - 降低实例间发送消息的频率
从上面PING消息发送数量公式可以看出,每秒发送一条PING消息的频率不算高,如果要降低可能导致集群内数据同步延迟;每100ms做一次检测并给延迟超过cluster-node-timeout/2的节点发送PING消息,这个配置是可以适当调大的。- 如果配置得比较小,则在大规模集群中会频繁出现PONG超时的情况;
- 如果配置得过大,则如果真得发生了故障,我们反而需要等比较长的时间才能检测出来。
可以在调整前后使用tcpdump抓取实例发送心跳网络包的情况。tcpdump host 192.168.10.3 port 16379 -i 网卡名 -w /tmp/r1.cap
故障恢复(容错)
Redis故障恢复主要分为以下3个步骤:
- 故障发现
采用多数派协议完成故障检测判断(即至少有半数以上节点认为某主节点故障后才真正判断节点故障)。 - 子节点选举
Redis Cluster中每个Master都会有1至多个Slave,通过复制实现高可用(故障转移),当Master有多个Slave,会采用Raft实现选举出一个主节点以实现故障恢复。 - 配置更新
故障转移后,那么之前的Master和其他Slave怎么处理?Redis会将这些节点成为新Master节点的子节点。
故障发现
一些 CP 特性且中心化的集群来说,当出现节点宕机时经常需要选举新的 Leader 节点,但是 Redis-Cluster 是去中心化的,某个 Master 的宕机并不会影响其他节点的工作。但是,当节点失联时,需要考虑网络的抖动情况,毕竟不能因为某几个请求意外超时就推断集群失败了,部分节点判断一个节点失联只会标记这个节点状态为PFAIL(主观下线),之后如果多数节点投票通过才会真正标记这个节点FAIL(下线)。
投票过程是集群中所有 master 参与的,每个节点都存有整个集群所有主节点及从节点的信息,它们之间通过互相 ping-pong 来判断节点是否可以连上,如果半数以上 master 节点与当前 master 节点通信超时(cluster-node-timeout),则认为当前 master 节点挂掉,标记这个节点状态为FAIL。
当 master 挂掉时,并不意味着集群已无法再提供服务了,集群要进入fail(不可用)状态需要满足以下条件之一:
- 集群的任意 master 挂掉,且该 master 没有 slave 或 slave 全挂掉了,则集群进入 fail 状态。
这是因为,Cluster中所有slot是平均分配到每个Master的,如果有一个Master的slot不能用了、而且这个Master还没有Slave,那么集群就不能提供服务了,如果Master还有Slave,Slave可以代替Master继续向外提供服务,这个步骤称为slave promotion。
单独的一对Master-Slave挂掉,Redis还提供一个叫 Replica Migration 的解决方案:当集群中的某个Master节点没有Slave节点时(称之为 Orphaned Master),其他有富余Slave节点的主节点会向该节点迁移一个Slave节点以防该节点下线之后没有子节点来替换从而导致整个集群下线。 - 集群超过半数以上 master 挂掉,无论有无 slave 都进入 fail 状态。
当集群不可用时,任何操作都将返回((error) CLUSTERDOWN The cluster is down)错误。需要注意的是,必须要 3 个或以上的主节点,否则在创建集群时会失败。
PFAIL
1、Redis每个节点会不断向其他节点发送PING消息来检测其他节点是否可达,如果超时会先断开连接:
代码:cluster.c/clusterCron
1 | /* This is executed 10 times every second */ |
2、此时节点A PING目标节点B失败,A会尝试重连,并将重连时间记录到ping_sent变量中:
1 | /* This is executed 10 times every second */ |
3、节点A发现PING B的延时时间超过了node_timeout之后,就会标记该节点为PFAIL(Possible FAILure),即主观下线:
1 | void clusterCron(void) { |
FAIL

1、A将B标记为PFAIL后,A会通过Gossip通知到其他节点。
2、所有节点会维护一个下线报告列表(Fail Report),主要维护一个节点被哪些节点报告处于下线状态,此时,C会记录“B被A报告下线了”。
1 | int clusterProcessPacket(clusterLink *link) { |
3、C添加下线报告之后,会进行B节点的客观下线状态(FAIL)判定。
当集群中有超过半数的节点都认为节点B处于PFAIL后才会判断B为FAIL,且需要注意的是,A将PFAIL通知给C后,C自己本身也得认为B处于PFAIL状态才会开始客观下线判定。
当C认为B正式FAIL后,它就会立刻向集群所有节点广播这个消息。
1 | /* This function checks if a given node should be marked as FAIL. |
4、当C标记了B为FAIL状态,则它会广播到整个集群中的所有节点(包括子节点),其他节点都会更新自己维护的节点B的状态信息为FAIL
1 | /* Send a FAIL message to all the nodes we are able to contact. |
子节点选举(故障迁移)
1、当B的两个子节点接收到B的FAIL状态消息时,它们会更新自己本地内存中的集群状态
1 | int clusterProcessPacket(clusterLink *link) { |
2、随后,在clusterCron定时任务中就会开始发起故障迁移,竞选成为新的Master
1 | void clusterCron(void) { |
3、资格检查
Slave节点会不停的与Master节点通信来复制Master节点的数据,如果一个Slave节点长时间不与Master节点通信,那么很可能意味着该Slave节点上的数据已经落后Master节点过多(因为Master节点再不停的更新数据但是Slave节点并没有随之更新)。Redis认为,当一个Slave节点过长时间不与Master节点通信,那么该节点就不具备参与竞选的资格。
1 | void clusterHandleSlaveFailover(void) { |
4、休眠时间计算
B的所有子节点(B1、B2)在判断自己具备选举资格时,就开始执行竞选,竞选协议是Raft,选举过程中,所有参与选举的节点首先随机休眠一段时间。
整个休眠时间由两个部分组成:
1 | DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds + SLAVE_RANK * 1000 milliseconds. |
- 一部分为固定的500ms时间,这500ms主要是为了等待集群状态同步。上面提到节点C会向集群所有节点广播消息,那么这500ms就是等待确保集群的所有节点都收到了消息并更新了状态。
- 另一部分主要是一个随机的时间加上由该Slave节点的排名决定的附加时间。每个slave都会记录自己从主节点同步数据的复制偏移量。复制偏移量越大,说明该节点与主节点数据保持的越一致。那么显然我们选举的时候肯定是想选状态更新最近的子节点,所以我们按照更新状态的排序来确定休眠时间的附加部分。状态更新最近的节点SLAVE_RANK排名为1,那么其休眠的时间相应的也最短,也就意味着该节点最有可能获得大部分选票。5、发起拉票 & 选举投票
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45void clusterHandleSlaveFailover(void) {
...
/* If the previous failover attempt timedout and the retry time has
* elapsed, we can setup a new one. */
if (auth_age > auth_retry_time) {
server.cluster->failover_auth_time = mstime() +
500 + /* Fixed delay of 500 milliseconds, let FAIL msg propagate. */
random() % 500; /* Random delay between 0 and 500 milliseconds. */
server.cluster->failover_auth_count = 0;
server.cluster->failover_auth_sent = 0;
server.cluster->failover_auth_rank = clusterGetSlaveRank();
/* We add another delay that is proportional to the slave rank.
* Specifically 1 second * rank. This way slaves that have a probably
* less updated replication offset, are penalized. */
server.cluster->failover_auth_time +=
server.cluster->failover_auth_rank * 1000;
/* However if this is a manual failover, no delay is needed. */
if (server.cluster->mf_end) {
server.cluster->failover_auth_time = mstime();
server.cluster->failover_auth_rank = 0;
}
redisLog(REDIS_WARNING,
"Start of election delayed for %lld milliseconds "
"(rank #%d, offset %lld).",
server.cluster->failover_auth_time - mstime(),
server.cluster->failover_auth_rank,
replicationGetSlaveOffset());
/* Now that we have a scheduled election, broadcast our offset
* to all the other slaves so that they'll updated their offsets
* if our offset is better. */
clusterBroadcastPong(CLUSTER_BROADCAST_LOCAL_SLAVES);
return;
}
...
/* Return ASAP if we can't still start the election. */
// 如果执行故障转移的时间未到,先返回
if (mstime() < server.cluster->failover_auth_time) return;
...
}
B1唤醒后,会向其他所有节点发送拉票请求,即CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST类型的消息。
其他主节点接收到拉票请求,且此时它还没有投出自己的票,则会将自己票投给发请求的B1,即回复FAILOVER_AUTH_ACK消息。
其他子节点没有投票的资格,因此即使接收到CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST类型消息也会直接忽略。6、替换节点(failover)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37void clusterHandleSlaveFailover(void) {
...
/* Ask for votes if needed. */
// 向其他节点发送故障转移请求
if (server.cluster->failover_auth_sent == 0) {
// 增加配置纪元
server.cluster->currentEpoch++;
// 记录发起故障转移的配置纪元
server.cluster->failover_auth_epoch = server.cluster->currentEpoch;
redisLog(REDIS_WARNING, "Starting a failover election for epoch %llu.",
(unsigned long long) server.cluster->currentEpoch);
// 向其他所有节点发送信息,看它们是否支持由本节点来对下线主节点进行故障转移
clusterRequestFailoverAuth();
// 打开标识,表示已发送信息
server.cluster->failover_auth_sent = 1;
// TODO:
// 在进入下个事件循环之前,执行:
// 1)保存配置文件
// 2)更新节点状态
// 3)同步配置
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG |
CLUSTER_TODO_UPDATE_STATE |
CLUSTER_TODO_FSYNC_CONFIG);
return; /* Wait for replies. */
}
...
}
当子节点接收到来自其他节点的ACK消息时会统计自己获得的票数,当达到集群Master总数的一半以上时,就会开始执行failover,即替换自己的主节点。
首先标记自己为主节点,然后将原来由节点B负责的slots标记为由自己负责,最后向整个集群广播现在自己是Master同时负责旧Master所有slots的信息。其他节点接收到该信息后会更新自己维护的B1的状态并标记B1为主节点,将节点B负责的slots的负责节点设置为B1节点。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55void clusterHandleSlaveFailover(void) {
...
/* Check if we reached the quorum. */
// 如果当前节点获得了足够多的投票,那么对下线主节点进行故障转移
if (server.cluster->failover_auth_count >= needed_quorum) {
// 旧主节点
clusterNode *oldmaster = myself->slaveof;
redisLog(REDIS_WARNING,
"Failover election won: I'm the new master.");
/* We have the quorum, perform all the steps to correctly promote
* this slave to a master.
*
* 1) Turn this node into a master.
* 将当前节点的身份由从节点改为主节点
*/
clusterSetNodeAsMaster(myself);
// 让从节点取消复制,成为新的主节点
replicationUnsetMaster();
/* 2) Claim all the slots assigned to our master. */
// 接收所有主节点负责处理的槽
for (j = 0; j < REDIS_CLUSTER_SLOTS; j++) {
if (clusterNodeGetSlotBit(oldmaster, j)) {
// 将槽设置为未分配的
clusterDelSlot(j);
// 将槽的负责人设置为当前节点
clusterAddSlot(myself, j);
}
}
/* 3) Update my configEpoch to the epoch of the election. */
// 更新集群配置纪元
myself->configEpoch = server.cluster->failover_auth_epoch;
/* 4) Update state and save config. */
// 更新节点状态
clusterUpdateState();
// 并保存配置文件
clusterSaveConfigOrDie(1);
/* 5) Pong all the other nodes so that they can update the state
* accordingly and detect that we switched to master role. */
// 向所有节点发送 PONG 信息
// 让它们可以知道当前节点已经升级为主节点了
clusterBroadcastPong(CLUSTER_BROADCAST_ALL);
/* 6) If there was a manual failover in progress, clear the state. */
// 如果有手动故障转移正在执行,那么清理和它有关的状态
resetManualFailover();
}
}
配置更新
上边我们已经通过故障发现和子节点选举机制用B1这个子节点替换掉了它的Master节点B,那么留下来的节点B和B2应该怎么处理呢?实际上Redis会让它们变成B1的Slave节点。
1、对B2来说,B1升级成Master后会给B2发送消息,让它知道自己已经升级成Master了。
1 | void clusterHandleSlaveFailover(void) { |
2、对B来说,B1已经成为了Master,B从故障恢复后再次加入集群时,会成为B1的Slave。
Cluster数据丢失隐患及处理方案
一种数据丢失的场景是主从复制时Master挂掉了,这点我在《Redis 复制》讨论过。
另一种数据丢失的场景存在于Cluster集群中,并且并不是特别容易出现,也就是Cluster发生了脑裂,分区恢复时脑裂期间的数据被覆盖:
- 主节点挂掉了,从节点选举出了一个新的主节点,但是此时客户端还在与老主节点通信,将数据写入到老的主节点上;
这种情况是可能发生的,因为客户端会记忆槽所在的节点,而不是每次请求都通过重定向定位到槽实际所在的节点上。
- 之后主从切换成功后,老的主节点会成功新主节点的Slave,并从新的主节点上获取数据,这时该节点上的数据会被清空,从而导致数据丢失。
为什么会发生脑裂?
在《Redis核心技术与实战》中提到了一种会导致脑裂的情况:
- Slave会定时地PING Master,发生的错误达到一定时间会被标记为主观下线,当标记主观下线的次数达到规定数量后,标记为客观下线;
- 但是Master实际上是“假故障”,即虽然响应Slave的心跳失败了,但是客户端还是可以和Master正常通信的。
比如宿主机上有一些其他进程将CPU打满了,在打满期间,Slave就会有可能将Master判断为下线,开始选举及主从切换。
为什么脑裂会导致数据丢失?
主从切换后,从库会升级为新主库,这时如果老主库重新上线了,会成为新主库的Slave,执行全量同步,而全量同步执行的最后阶段,需要清空本地的数据,加载新主库发送过来的RDB文件,这期间写入的数据就会丢失了。
如何解决这种脑裂问题?
可以通过两个配置来解决这个脑裂问题:
min-slaves-to-write
这个配置项设置了主库能进行数据同步的最少从库数量min-slaves-to-write
min-slaves-max-lag:这个配置项设置了主从库间进行数据复制时,从库给主库发送 ACK 消息的最大延迟(以秒为单位)
我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的请求了。即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端请求,客户端也就不能在原主库中写入新数据了。等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。
举个例子:假设我们将min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。
数据倾斜
Cluster集群通过CRC16算法将key hash到节点槽上,这个过程还是存在很多不确定性,可能很多数据会被hash到固定的某几个槽上,造成数据分布的不均匀,或者某些key是热点数据,被访问得尤其频繁。
数据倾斜的危害
数据倾斜的危害主要是保存热点数据的节点处理压力会增大,速度变慢,甚至内存资源耗尽而崩溃。
数据倾斜的成因
数据倾斜的成因主要有3个:
- bigkey
bigkey一般是value值很大的string或保存了大量对象的集合类型。
bigkey可能会造成实例IO线程阻塞,影响其他请求的执行效率。
为了处理bigkey,设计的时候最好避免把过多的数据保存在同一个键值对中,如果是集合类型,还可以把bigkey拆分成多个小的集合类型数据,分散保存在不同的实例上。 - slot分配不均衡
如果没有均衡地分配slot,就会有大量的数据被分配到同一个slot中,而同一个slot只会在一个实例上分布,并导致大量数据被集中到同一个实例上。 - Hash Tag
hash tag指针对key的某个部分进行hash,比如user:123,可以加上hash tag后变成user:{123},只针对123进行hash。
hash tag的意义主要在于可以将同类的数据hash到同一个槽上,便于范围查询。
hash tag的缺点也在于分布到同一槽内后,对该槽所在节点的压力会变大。
数据倾斜的解决办法
数据倾斜可以通过重分配slot来解决。
但是热点数据往往是少部分数据被频繁访问,这种情况下重分配slot是无法解决的,为此可以通过热点数据多副本的方法来解决,比如同一key添加一个前缀然后hash到其他slot上。
但是多副本只能用于只读热点key,对于有读有写的热点数据,就只能给实例本身增加资源了,比如改成配置更高的机器。
QA
- Redis Cluster哈希槽通过CRC16算法将key哈希到实例上的槽,这样做有什么好处?为什么不直接用一张表来存储key和哈希槽之间的对应关系?
如果用一张关系表来做映射,问题太多了,比如:key太多了怎么存关系?集群扩容、缩容、故障转移时怎么修改key和实例间的对应关系?
而引入哈希槽,实际上是将数据和节点解耦,客户端只需关注key被hash到哪个哈希槽,就算打到错误的节点上,也可以通过





















