Redis知识整合(四):集群

上回主要介绍了redis的主从模式,主从模式最主要的作用是提高redis的可用性,假如主节点挂了,从节点可以在哨兵机制的协助下晋升为主节点继续对外服务,但主从终归是多个redis节点都保存同一份数据,在redis数据快速膨胀时,又该如何应对呢?redis对于这种情况提供了Cluster模式,这是redis的分布式解决方案,使得大批量数据可以拆分成小部分保存进多个redis节点中,本篇笔记将展开说一下Redis Cluster.

一、分布式DB常用的分区方案

分布式数据库在存储全量数据时,首先要解决的是如何把整个数据集按照分区规则映射到多个节点上:

图1

这里介绍几个常用分分区方案:

1.1:取模

这是最常用的方式,假如现在有N个存储节点,则计算方式为:p = hash(key) % N,也就是取唯一标识(在本例中就是redis key)进行哈希计算后跟当前节点数取模,最终就可以得出这条数据会被分配到哪台机器上,这是这种方式的优缺点:

  • 优点:简单易用
  • 缺点:扩缩容时可能导致全部节点失效(rehash),建议扩缩容时按照原节点数 × 2来进行,这样只要原来的哈希值不变,余数也就不会变

1.2:一致性哈希

这种方式的实现思路是为每个节点分配一个token值,取值范围在0~2^32间,当给节点分配完token,这些节点就会形成一个闭环:

图2

当有新的数据到来时,跟前面一样先计算出唯一标识(key)的哈希值,然后拿着这个哈希值顺时针找到第一个大于等于该值的token(找不到比自身大的token就放进顺时针第一个节点中,所以才说这是个闭合的环嘛),将其存入该token对应的节点中:

图3

这样一来,增减节点时只会影响哈希环中相邻的节点,对其他节点无影响:

图4

至于加减节点导致的一小部分数据不可用,需要手动处理,如果利用一致性哈希存缓存数据,那么也可以忽略这部分数据,等回源时再次写入正确的节点即可。

如果节点过少,则节点变化将大范围影响哈希环中的数据映射,而且数据会严重倾斜,所以一致性哈希不适合节点数较少时使用。

在使用一致性哈希时,节点扩缩容容易导致负载不均衡(如图4),尤其是删除一个节点时,被删节点的负载将全部打到下一个节点,如果下一个节点不堪重负挂掉,此时更大的压力将会顺延至下下一个节点,以此类推,最终可能导致服务全员故障,为了解决一致性哈希的这些问题,诞生了虚拟节点的概念,这是对一致性哈希算法的优化,算法的本质没有变化,但与之前不同的是现在数据不与实际的节点交互而是跟抽象出来的虚拟节点交互:

图5

图中简化了,实际上虚拟节点是逻辑层面的节点,数量会非常多。

加这一层虚拟节点做一致性哈希的代理层,然后再将虚拟节点映射到具体的机器上,只要这个映射逻辑足够散列,那么之前的问题将不复存在,比如现在我们去掉一个节点:

图6

可以看到,受被删节点影响的数据最终会被节点2和节点3的虚拟节点接收,这就是虚拟节点比原生一致性哈希优秀的地方,通过一层抽象,解决了一致性哈希的痛点。

1.3:哈希槽

它结合了上述两种哈希算法的特点实现,首先它会抽象出来一大堆的哈希槽,这些哈希槽都有自己编号,每个物理节点负责存储一定范围内的哈希槽,存储数据时跟前两种哈希算法一样,先计算出唯一标识(key)的哈希值,然后看该值在哪个槽区间里,若命中区间,则存进负责该槽区间的节点:

图7

增减节点时需要重新分配每个节点负责的槽范围,将一些错位数据做迁移,这样可以保证每个节点仍然是均匀的负责一个哈希槽区间,也就自然不存在数据倾斜等问题,Redis Cluster正是采用哈希槽的方式实现数据分区的,它一共有16383个槽位,流程跟图7中描绘的一样,分区算法为:CRC16(key) % 16383

二、Redis集群搭建

2.1:集群配置&启动

redis集群一般需要至少6个节点(3主3从)才能保证组成完整的高可用集群,单节点的redis-6379.conf主要配置如下:

1
2
3
4
5
6
port 6379 #端口号
cluster-enabled yes #开启集群模式
cluster-node-timeout 15000 #节点超时时间(单位:ms)
#↓集群配置文件,若没有则自动创建,当集群内节点信息发生变化(如添加/下线节点、故障转移等),节点
# 会自动保存集群状态到该配置文件中(redis自动维护该文件,不需要手动修改)
cluster-config-file "node-6379.conf"

配置完成后将所有节点都启动起来,假设有这样6个节点:

1
2
3
4
5
6
redis-server conf/redis-6379.conf
redis-server conf/redis-6380.conf
redis-server conf/redis-6381.conf
redis-server conf/redis-6382.conf
redis-server conf/redis-6383.conf
redis-server conf/redis-6384.conf

启动完成后,来随便看一个节点的集群配置文件,比如node-6379.conf:

1
5adabec665d2b4005adabec665d2b40adabec665 127.0.0.1:6379 myself,master - 0 0 0 connected vars currentEpoch 0 lastVoteEpoch 0

这里面记录的是集群的初始状态,其中第一个40位的十六进制字符串表示的是节点ID,它是节点在集群中的唯一标识,很多集群操作都需要借助这个ID来完成(ID在初始化集群时生成,节点重启会重用)

2.2:握手

到上面的步骤为止,节点只是打开了集群模式,完成了启动,它们彼此并不知道彼此的节点信息,接下来这一步操作就可以让它们感知到身处同一个集群里的彼此,这个操作由客户端发起:

1
cluster meet ${ip} ${port}

发送meet就可以让节点间进行握手通信,就像下面这样:

图8

当某个节点对着其他节点全部发一次meet消息后,集群中每个节点就会慢慢感知到其他的节点,最终在每一个节点内执行下面这个命令,都可以获取到所有的节点信息:

1
2
3
4
5
6
7
127.0.0.1:6379> cluster nodes
5adabec665d2b4005adabec665d2b40adabec665 127.0.0.1:6379 myself,master - 0 0 0 connected
8ebabevc789d2b6125becbec123d2f45adabecec 127.0.0.1:6380 master - 0 146345454 1 connected
fdadadacddd2b4666adabec125d2aaaadabecdec 127.0.0.1:6381 master - 0 146345454 2 connected
dddddddcddd5bbbbbadabec567d2bb0adabecfed 127.0.0.1:6382 master - 0 146345454 3 connected
fadabec567d6bddddadabec690d2b40adabecede 127.0.0.1:6383 master - 0 146345454 4 connected
dadacecef8d1b4a85adabece76d2b40adabecded 127.0.0.1:6384 master - 0 146345454 5 connected

至于meet的过程,我们放到后面详细说。

节点建立握手后,集群还无法工作,整体处于下线状态,所有数据的读写都会被禁止,这是因为还没给主节点分配槽位,没有槽位连插入的数据放到哪个节点都不知道。

2.3:分配哈希槽

redis是通过哈希槽进行数据分区的,这在之前已经提过了,分配槽位用到的指令是:

1
cluster addslots {x...y} #xy就是当前节点负责的槽范围

我们设6379/6380/6381三个节点为主节点,将16384个槽均匀分配给这三个节点:

1
2
3
redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0...5461}
redis-cli -h 127.0.0.1 -p 6380 cluster addslots {5462...10922}
redis-cli -h 127.0.0.1 -p 6381 cluster addslots {19023...16383}

在每个分配好槽位的主节点执行cluster info可以查看具体的集群状态信息,使用cluster nodes则可以查看整个集群的情况:

1
2
3
4
5
6
7
127.0.0.1:6379> cluster nodes
5adabec665d2b4005adabec665d2b40adabec665 127.0.0.1:6379 myself,master - 0 0 0 connected 0-5461
8ebabevc789d2b6125becbec123d2f45adabecec 127.0.0.1:6380 master - 0 146345454 1 connected 5462-10922
fdadadacddd2b4666adabec125d2aaaadabecdec 127.0.0.1:6381 master - 0 146345454 2 connected 10923-16383
dddddddcddd5bbbbbadabec567d2bb0adabecfed 127.0.0.1:6382 master - 0 146345454 3 connected
fadabec567d6bddddadabec690d2b40adabecede 127.0.0.1:6383 master - 0 146345454 4 connected
dadacecef8d1b4a85adabece76d2b40adabecded 127.0.0.1:6384 master - 0 146345454 5 connected

相比之前,三个主节点已经输出了自己负责的槽位信息。

2.4:配置主从

上述集群中还有3个节点没有利用到,这3个节点就是我们要设置的从节点,在集群模式下建立主从关系有些不太一样,用的是这个指令:

1
cluster replicate ${master-node-id}

在集群中,从节点负责复制主节点的槽信息和数据。虽然指令不同,但复制的流程则和原来讲的普通主从模式一致,最终我们这个集群的完整拓补图将会是下面这样:

图9

此时如果再执行cluster nodes命令,主从信息也会被打印出来(这里不再演示)。

2.5:redis-trib.rb

如果你觉得上面的流程太过复杂,没关系,redis提供了官方的快速搭建集群的工具:redis-trib.rb,它采用ruby实现,可以帮我们简化集群创建、检查、迁移槽、均衡等常见运维操作。

使用前需要先装ruby:

1
2
3
4
5
6
7
8
wget https://cache.ruby-lang.org/pub/ruby/${big_ver}/ruby-${ver}.tar.gz #下载压缩包,big_ver为大版本号,如2.3,ver为具体版本号,如2.3.1
tar xvf ruby-yy.tar.gz #解压
./configure -prefix=/usr/local/ruby
make #编译
make install
cd /usr/local/ruby
sudo cp bin/ruby /usr/local/bin
sudo cp bin/gem /usr/local/bin

安装rubygem redis依赖:

1
2
3
wget http://rubygems.org/downloads/redis-${ver}.gem #下载redis.gem,ver是具体的版本号,如3.3.0
gem install -l redis-${ver}.gem
gem list --check redis gem

安装redis-trib.rb:

1
sudo cp /${redis_home}/src/redis-trib.rb /usr/local/bin

安装完ruby环境后,执行redis-trib.rb命令确认环境是否正确即可,若准确则打印相关信息。

然后开始创建集群:

①首先准备好6个开启集群模式节点,过程跟之前一样,不再赘述。

②执行redis-trib.rb创建集群:

1
redis-trib.rb create --replicas 1 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384

--replicas可以指定每个主节点配置几个从节点,这里设置的是1,如果是多台机器部署,redis-trib.rb会尽可能保证主从不在同一个机器里(不然会弱化高可用)

上面的指令执行后,会将当前的主从分配信息打印出来,然后征求你的同意,你只要输入了yes,redis-trib.rb就会紧接着进行节点握手和槽分配;相比原生搭建集群,redis-trib.rb的确可以帮我们省去很多麻烦。

三、节点通信

3.1:Gossip

图8所示,为什么某个节点对着其他节点全部发一次meet消息后,每个节点就会慢慢感知到其他的节点呢?这是因为redis利用Gossip协议(一种分布式一致性协议,为最终一致)做信息同步,Gossip的工作原理就是节点间彼此不断的通信进行信息交换,这样持续一段时间后,所有的节点就都知道了集群的完整信息(这种方式很像流言的传播,所以Gossip协议也叫流言协议),过程如下:

图10

注意图中的流程是按照6379的meet消息同时发到各节点,各节点又同时发pong给6379的,但实际操作中这肯定不是同时发生的,但这并不影响节点的传播。

上面所涉及到的meetpingpong消息均为Redis Gossip消息,除了这些还有fail消息,当任意节点发现某一个节点挂掉后,会向集群内广播一个fail消息,其他节点接收到这条fail消息后会把对应的坏节点更新为下线状态(后面会详细介绍故障转移)。

3.2:定时消息

上面的流程图告诉我们集群内的每个节点都会定时对其他节点通信进行信息交换,频率是10次/秒,高频是为了让信息扩散的更快,可是这样就会导致另一个问题:每次请求都携带很多信息(比如自身和其他节点的状态信息等),这样势必会造成网络带宽的浪费;为了解决这一问题,节点间每次只选取部分节点进行通信,选取逻辑主要源自两部分,这里以6379这个节点(定时发ping消息)为例:

图11

根据这两个选取规则可以估算出每个节点每秒需要发送的消息数为:1 + 10 * num(node.pong_last_time > cluster_node_timeout/2),其中cluster_node_timeout可配,默认15s,当我们的带宽资源紧张时可以适当调大该值来降低贷款占用率,当然扩大该值意味着故障转移、槽信息更新、新节点发现等信息的同步速度降低,所以调整该值大小需要仔细斟酌,有得必有失。

除了削减每次发送的消息数量,还缩小了每次发送的数据包,每条Gossip消息都由消息头和消息体组成,其中消息头中的myslots字段(自己负责的槽信息,约占2kb),而主要的削减对象是消息体,消息体中主要携带其他节点的信息,用于信息交换,但每次都全量携带所有节点的信息显然太浪费,所以Redis进行了削减,每次只携带cluster.nodes.size/10个其他节点的信息。

3.3:扩容

前面我们就讲过集群中的每条数据都会通过哈希计算被映射进一个槽位,而每个槽位又按照编号均匀分布在集群的每个节点中,所以扩缩容说白了就是槽和数据在各个节点间移动。现在我们假设给集群加入了两个节点,它们融入进cluster的流程如下:

图10

截至目前,新节点依旧没有对外服务,因为还没分配槽,只要两个节点有一个不是从节点(假设6385非从),那么slots一定会重新分配,就像这样(其他主节点匀给新节点一部分slots):

图11

迁移过程较复杂,我们以一个槽位的迁移为切入点进行详细介绍,假设现在要将6381的某个槽对应的数据迁移到6385,过程如下:

图12

这个过程很复杂,如果全部通过手动来做那肯定不合适,所以redis-trib.rb为我们提供了槽重新分片的功能:

1
redis-trib.rb reshard ${host:port} --from ${source-node-id} --to ${target-node-id} --slots ${need-migrate-slots-total} --yes --timeout ${pre-migrate-timeout} --pipline ${per-migrate-key-total}

入参释义:

  1. host:port:必传,是集群中任意节点的地址,用来获取整个集群的信息
  2. source-node-id:源节点id,可用逗号传多个
  3. target-node-id:目标节点id,只允许填写一个,例如图11中就可以认为source-node-id是6379/6380/6381三个节点,而目标节点就是6385
  4. need-migrate-slots-total:需要迁移的槽的总数量,新增主节点可以根据16384/nodes.size估算出来
  5. pre-migrate-timeout:单次migrate超时时间,缺省值:60000ms
  6. per-migrate-key-total:单次migrate迁移的键数量,缺省值:10

ps:因为reshard命令每次只允许指定一个目标节点,所以当扩容多个节点时需要一个个的进行迁移(此时被加进来的节点为目标节点),而缩容时如果需要缩多个节点,可以将这些下线节点同时指定为源节点,但迁移至其集群内其他节点时也需要一个个执行(此时其他节点为目标节点)。

3.4:缩容

理解了扩容,缩容就好理解了,如果要从集群下掉一个节点,流程如下:

图13

1.迁移槽

假如我们要将上面例子中的某个主节点下掉,这里槽迁移过程和扩容时是一样的,唯一的不同点是现在的目标节点有三个,而源节点只有一个,所以需要将源节点里的槽位列出来,算出来其负责的槽位数,然后用槽位数/3计算出其余三个节点均摊多少槽位,然后分三次reshard即可。

2.忘记节点

通过以下命令来完成:

1
2
3
4
5
6
7
8
9
10
#向集群内每个节点发送下面的命令,发送后对应节点就会把下线节点加入到禁用列表里,
#处于禁用列表中的node不再参与Gossip通信,但该列表只有60s有效期,所以得在过
#期前让集群内所有节点都收到cluster forget指令
cluster forget ${target-node-id}

#如果觉得上面的流程麻烦,还可以使用redis-trib对着任意集群节点发送以下指令
redis-trib.rb del-node ${host:port} ${target-node-id}
#↑上面这个指令内部逻辑就是循环集群内其他节点挨个发cluster forget指令,除此
#之外,它还会将下线节点的从节点(如果有的话)指向别的主节点(原则上是指定给从
#节点最少的那个主节点)

3.5:故障转移

通过前面几个小节,我们知道了redis集群如何做心跳检查和扩缩容,这一小节,我们就来聊聊故障的转移。

一个正常集群内的各个节点会定期发送ping/pong消息,用来同步集群信息和做心跳检查(参考图11),那么自然也会通过这种方式发现故障节点,通过图11我们知道,一个节点与另一个节点间的ping消息正常情况下不会超出cluster_node_timeout,若超过,说明ping消息失败且重连失败,导致cluster_node_timeout一直得不到刷新,这时便可以判定对方为主观下线(pfail),现在还不能给这个节点判死刑,因为单台机器认定的故障不具备权威性,这条主观下线信息最终会被传播出去,当超过半数的主节点均认为该节点下线时,此时这个节点才是真正意义上的下线,即客观下线,详细流程如下:

图14

故障节点变为客观下线后,如果被下线的节点持有槽,那么接下来就需要故障恢复,当它的从节点也收到客观下线的消息时,就开始选举一个能替换它对外服务的节点作为新的主节点,选举和恢复流程如下:

图15

故障转移时间跟所配的cluster-node-timeout息息相关(默认15s),配置时可以根据业务容忍度做出适当的调整。

3.6:路由

3.6.1:重定向

槽分配都是在服务端完成的,且服务端每个节点都保存有一份当前的槽信息,那客户端要如何准确的访问整个集群呢?redis官方的做法是让客户端无脑发起直连,被连到的服务端做路由分析与转发,流程如下:

前两种方案都是建立在服务端节点无作为的情况下进行的,这样做有一个坏处,Gossip协议是最终一致的,那么槽信息的同步必定会存在延迟,这样在做槽迁移时会存在大量路由错误的key,而且第二种方案还加大了系统的复杂度,得不偿失。

第三种方案是服务端自身做路由转发,这也就意味着服务端节点本身具备判断槽分配的能力,即便是正在槽迁移,路由至错误节点,错误节点也能很好的更正路由(多次路由转发必定会找到正确的那个节点),而且这个方案并没有额外增加模块,没有增加维护成本,所以redis官方采用了这种方案,流程如下:

图16

客户端指令如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
redis-cli -p 6379
127.0.0.1:6379> set ${key} ${value}
(error) MOVED ${slot-num} 127.0.0.1:6381 #表示当前key所属槽分布在6381上

127.0.0.1:6381> set ${key} ${value}
OK #重新到6381上则顺利执行


#你或许觉得上面的操作过于麻烦,没关系,还可以给redis-cli加-c自动路由(内部逻辑跟上面是一样的,只是redis-cli帮我们屏蔽掉了)
redis-cli -p 6379 -c
127.0.0.1:6379> set ${key} ${value}
-> Redirected to slot [${slot-num}] located at 127.0.0.1:6381 #即便在6379上执行,也可以自动路由
OK

集群环境下,不能很好的支持mget等批量命令,因为多个key无法保证都落到同一个节点上,可以通过{}做路由控制,因为cluster做哈希计算时如果发现key里包含一对{},则只让{}内的部分参与哈希计算,可以通过这个特点来让一些需要批量操作的key落入同一个节点内。

集群中每个节点都保存一份完整的slot-node映射关系,其保存在clusterState结构中:

1
2
3
4
5
typedef struct clusterState {
clusterNode *myself; //自身节点
clusterNode *slots[CLUSTER_SLOTS]; //16384个槽-节点映射数组,下标为槽
...略
}

上面客户端请求重定向的逻辑伪代码如下:

1
2
3
4
5
6
7
def execute_or_redirect(key):
int slot = key_hash_slot(key);
ClusterNode node = slots[slot];
if(node == clusterState.myself):
return executeCommand(key);
else:
return '(error) MOVED [slot-num] [ip]:[port]';

通过上面的了解,我们知道了客户端与集群交互的基本流程,但这个流程存在一个问题,倘若大部分时候都请求不到准确的节点,那岂不是意味着客户端大部分访问都需要通信两次才能完成?这对IO来说是笔不菲的开销,有没有更聪明的办法来做路由呢?

3.6.2:Smart Client

事实上现在大部分语言的客户端都解决了这个问题,比如Jedis,解决办法就是在本地直接缓存下来slot-node映射信息(初始化时访问任意节点发送cluster slots指令即可获得),等key来了可以直接在客户端做好路由发给正确的节点,当偶尔发生MOVE响应时就更新本地的映射信息。

JedisCluster来说,它的处理流程如下:

图17

上面在出问题的情况下(比如连接出错、重定向)会重试一定的次数,当超出重试个数就会抛出以下异常:

1
JedisClusterMaxRedirectionsException("Too many Cluster redirections?");

所以不管是太多次连接出错,还是大量重定向发生,都会抛出这个异常。

3.6.3:ASK重定向

这绝对是我最感兴趣的部分,参考图12,如果客户端在发送命令时redis集群正在进行槽迁移,那么势必会出现下面这种情况:

图18

出现了上面的问题,如果要保证客户端的可用性,此时源节点肯定会通知客户端重定向至目标节点,那么redis具体是怎么做的呢?答案就是ASK重定向,与MOVED重定向有一些不同,它只会发生在槽迁移阶段,它的具体流程如下:

图19

通过这个流程可以保证在槽迁移过程中客户端也能很好的得到响应,再来看看服务端具体的处理流程:

图20

四、问题&建议

4.1:配置上的建议

3.5中的故障转移过程中整个集群是不可用状态,可以通过将cluster-require-full-coverage设为no来保证出现故障时其他未故障节点可用。

4.2:带宽开销

Redis对带宽的消耗主要体现在两方面,一是Gossip本身会消耗带宽,二是指令处理,官方建议redis集群最大规模在1000以内,也是出于对消息通信成本的考虑,可以通过增加物理机器来增加整体带宽;

提高cluster-node-timeout可以降低Gossip通信速率,但同样会影响故障转移的速度,请酌情选择合适的值。

如果条件允许,集群尽量部署在更多的机器上,如果大量的节点集中部署在少量物理机器上,这时机器的带宽消耗将非常严重。

4.3:集群倾斜

导致集群倾斜的原因和解决之道:

  • 节点和槽分布严重不均:可以通过redis-trib.rb rebalance均衡槽的分配
  • 不同槽对应的键数量差异过大:我们知道键通过CRC16哈希函数映射到槽上,正常情况下槽内键数会相对均匀,但当大量使用hash_tag时(例如user:user1:ids,user1就是hash_tag),会有不同的键映射到同一个槽的情况,通过命令cluster countkeysinslot ${slot}可以获得槽对应的键数,识别出哪些槽映射了过多的键,再通过命令cluster getkeysinslot ${slot} ${count}迭代出槽下所有的键,从而发现过度使用hash_tag的键。
  • 集合对象包含大量元素:大集合对象可在客户端通过--bigkeys指令输出,找出大集合后可根据业务场景进行拆分(大集合的键迁移时还容易超时导致失败)
  • 内存相关配置不一致:集群内各节点的内存配置要保持一致(比如xxxx-max-ziplist-value等)。

4.4:手动故障转移

在从节点执行cluster failover命令将会发起故障转移流程。