深入探讨redis:万字讲解集群
什么是集群
广义的集群:多个机器,构成了分布式系统,就可以称为“集群”。
狭义的集群:redis提供的集群模式,这个集群模式之下,主要解决的是存储空间不足的问题(拓展存储空间)
随着数据量的增多一台机器的内存不够,为了可以将这些数据完好的存储就需要引入更多的机器,Redis的集群就是在上述的思路之下,引入多组Master/Slave,每⼀组Master/Slave存储数据全集的⼀部分,从而构成⼀个更大的整体,称为Redis集群(Cluster)。
举个例子假如现在要存储1TB的数据,用一台机器的话就要将这1TB的数据全都存储在这一台机器上,用两台机器存储,每台机器就是512GB,用三台机器存储,每台机器就是三百多GB,用四台机器存储每台机器就是256GB,随着机器数量的增加,每台机器上所要存储的数据量也在减小,负载也相应减小。
虽然数据可以存储在硬盘中但是,硬盘的访问速度远低于内存,有很多场景是既需要快速的访问又要较大的存储空间,所以为了解决这种情况就要用到集群
数据分片算法
- Master1和Slave11和Slave12保存的是同样的数据.占总数据的1/3
- Master2和Slave21和Slave22保存的是同样的数据.占总数据的1/3
- Master3和Slave31和Slave32保存的是同样的数据.占总数据的1/3
这三组数据存储的东西都是相同的
每个Slave都是对应Master的备份.每个红框部分都可以称为是⼀个分片(Sharding).
如果全量数据进⼀步增加,只要再增加更多的分片,即可解决.
以下是三种主流的分片方法
哈希求余
就是借助hash函数,把一个key值映射为一个整数,再用这个整数对数组长度求余,就可以得到一个小于数组长度的数组下标
比如有3个分片(0,1,2)现在要存储一个数据key,计算hash值(比如md5),然后将hash值对3取余,得到一个下标,然后根据这个下标将数据存储到对应分片中,后续查询key时也是同样的方法。
为什么使用MD5当作hash函数
- md5计算结果是定长的,无论输入多长的原字符串,最终算出结果就是固定长度
- md5计算结果是分散的,两个原字符串哪怕大部分都相同,只有一个小的地方不同,算出来的md5值也会差很大
- md5计算的结果不可逆,一个字符串可以很容易的算出MD5的值,而一个MD5值,很难还原出原始的字符串(虽然有很多md5解析器,但是这些网站大多都是将一些常见的MD5值给保存起来,一些不常见的则解析不出来)
哈希求余的缺点
如果一开始只搞了两三个分片,但是后面随着业务的增长数据量不断变多,三个分片已经不够了需要再增加一个,但是此时求下标的值也会变比如
一个数据hash(key) = 102,求分片下标102 % 3 = 0,存在第0下标的分片,当增加分片后就要是hash(102) % 4 = 2,也就是说如果此时查找这个数据就要再2号分片找,但是之前存的地方是0号分片也就找不到这个数据了。所以再扩容之后就要重新进行数据分配,但是那么多数据都进行重新分配,只有那些增加分片前和增加分片后hash值恰好不变的数据不用重新分配,这个开销也就不言而喻了。
一致性哈希算法
为了降低上面哈希求余再扩充时需要大量搬运数据的问题,提出了一种新的方法。
首先将0-2^32-1这个数据空间,按顺时针方向映射到一个圆环上
然后将三个分片,分别等分的放在圆环上
之后如果需要保存key,先计算hash(key)得到哈希值,这个哈希值不在对分片书取余,而是在该哈希值对应的地方顺时针查找,找到的第一个分片就是这个数据key保存的分片。
相当于每个分片都有自己管辖的区域
如果要扩容
只需要将3号分片放在其中一个分片中间的位置,这样其实就只用搬运2号分片和3号分片中间区域的数据,相较于之间的要搬运大部分数据,这种方法大大减小了内存开销。相当于将0号分片的管辖区域分一半给3号分片。(增加分片就是为了降低其他分片的负载,所以搬运数据是必须的,我们只能让这个过程尽量在服务器的处理能力范围之内)
但是这种方法也有一个缺点,就是数据分配不均匀。
假如就按照刚刚的方法增加分片,那么2号分片和一号分片上的数据量必然比3号和0号的多,因为1号和2号的管辖区域比0号3号大,所以就会照成数据分配不均匀。
但是你可能会想到,只需要在加两个分片分别在1号2号之间和0号1号之间就可以了呀。确实这样做的话每个分片的管辖区域一样,数据分配也自然一样。但是有时候我们不需要这么多机器,加了多余的机器后成本不说,还会有很大内存浪费,所以这个方法显然不是很好。
接下来就开始介绍一下,第三种方案。
哈希槽分区算法
虽然刚刚两个在业内有在使用,因为扩容搬运开销大和数据分配不均匀的问题,redis并没有采用。redis使用的是哈希槽算法
hash_slot = crc16(key) % 16384 //crc16()也是一个哈希算法。
hash_slot就是一个哈希槽,总的哈希槽的个数是16384,redis会将数据映射到这些哈希槽上,并且会进一步把这些哈希槽,较为均匀的分给不同的分片。
假设当前有三个分片,⼀种可能的分配方式
- 0号分片:[0,5461],共5462个槽位
- 1号分片:[5462,10923],共5462个槽位
- 2号分片:[10924,16383],共5460个槽位
每个分片都会使用位图来表示当前自己有多少槽位号,16384个bit位,用每一位的0/1来区分当前槽位是否属于自己,16384个bit位也就是2kb左右的空间对于每个分片来说这点空间还是完全没问题的
这样看这种算法既像哈希求余算法要把数据映射到指定区段,又像一致性哈希算法需要给每个分片分片不同的区域,其实这种算法本质上就是那两种算法的结合。
但是注意刚刚给分片分配槽位时,说的是可能的分配方式,实际上槽位的分配是非常灵活的,每个分片分配到的槽位号可以是连续的也可以是不连续的,比如上述的0号槽位我可以在[0,5461]区间分配一些槽位,接着在[10924,16383]区间再分配一部分,只需要保证每个分配的槽位数尽量均匀就行,至于连不连续其实无关紧要的。
扩容
刚刚说了既然不用保证每个分片的槽位号连续,那么当需要扩容一个分片时,只需要其他每个分片都分一些槽位号给新的分片,就可以保证所有分片的槽位数依然较为均匀。
⼀种可能的分配方式:
- 0号分片:[0,4095],共4096个槽位
- 1号分片:[5462,9557],共4096个槽位
- 2号分片:[10924,15019],共4096个槽位
- 3号分片:[4096,5461]+[9558,10923]+[15019,16383],共4096个槽位
这样就既解决了扩容搬运开销大和数据分配不均匀的问题
那么此时你是否会有问题
- Redis集群是最多有16384个分片吗?
按照刚刚的方法,每个哈希值都对16384取余,那么槽位数最多就是16384个,相对应得分片数肯定不可以比槽位数大,极限状态下给每个分片分一个槽位也就是16384个分片。
真的可以分这么多分片吗?如果每个分片的槽位个数很少,那么就会很难保证数据在各个分片上的均匀性。假如就是这种极限状态,每个分片只有一个槽位,而key是要先映射到槽位,再找到对应分片的。此时我们要存储16384个数据因为每个分片只有一个槽位而且无法保证每个数据对应的槽位不同,那么就会只有部分分片有数据,而其他分片是没有数据的,此时数据的分配就不均匀了。
如果每个分片包含的槽位数非常少(分片数非常多),槽位个数就不一定能直观的反应到key的数目,有的槽位可能有多个key,有的槽位可能没有key。
所以可以看到当分片个数越多时,数据分配的均匀性是在下降的,换言之redis集群根本用不了很多的分分片数,而且redis的作者也有建议,集群分片数,不应该超过1000(1000个分片已经是一个很大的规模了).
- 为什么是16384个槽位?
对于这个问题redis官网有解释
- 节点之间通过心跳包通信.心跳包中包含了该节点持有哪些slots.这个是使用位图这样的数据结构表示的.表示16384(16k)个slots(槽位),需要的位图大小是2KB.如果给定的slots数更多了,比如65536个了,此时就需要消耗更多的空间,8KB位图表示了.8KB,对于内存来说不算什么,但是在频繁的网络心跳包中,还是⼀个不小的开销的.
- 另一方面,Redis集群⼀般不建议超过1000个分片.所以16k对于最⼤1000个分片来说是足够用的,同时也会使对应的槽位配置位图体积不至于很大.
docker搭建集群环境
我们要搭建的集群环境一共有三个分片,每个分片有一个主节点和两个从节点,并且再多两个节点备用,用来模拟之后的扩容,所以之后我们要创建11个redis节点。
博主这里是在之前演示docker搭建哨兵环境的基础上进行
首先先停掉之前的docker容器(如果没有可以不执行,这里是停掉博主自己的),不然可能会和之后开启的容器起端口冲突
cd redis-sentinel
docker-compose down
cd redis-data
docker-compose down
准备
查看是否还有docker容器启动
接着创建一个新的文件夹和yml文件以及generate.sh,这是一个shell脚本文件
mkdir redis-cluster
touch docker-compose.yml
touch generate.sh
在linux中.sh后缀的文件称之为shell脚本,因为linux是通过命令来操作,所以就可以把这些命令给放到一共文件中批量化执行,并且还可以加入循环,条件判断,函数等机制。我们这里需要创建11个redis节点,每个节点的配置内容差不多,所以就可以使用这样一个脚本来帮助我们批量生成。
创建每个redis节点的配置文件
generate.sh内容,直接粘贴进文件即可
#创建1-9号节点
for port in $(seq 1 9); \
do \
mkdir -p redis${port}/
touch redis${port}/redis.conf
cat << EOF > redis${port}/redis.conf
port 6379
bind 0.0.0.0
protected-mode no
appendonly yes
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
cluster-announce-ip 172.30.0.10${port}
cluster-announce-port 6379
cluster-announce-bus-port 16379
EOF
done
# 注意 cluster-announce-ip 的值有变化.
# 创建10-11号节点
for port in $(seq 10 11); \
do \
mkdir -p redis${port}/
touch redis${port}/redis.conf
cat << EOF > redis${port}/redis.conf
port 6379
bind 0.0.0.0
protected-mode no
appendonly yes
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
cluster-announce-ip 172.30.0.1${port}
cluster-announce-port 6379
cluster-announce-bus-port 16379
EOF
done
接着执行文件
bash generate.sh
如果没有报错则说明成功执行,执行完之后就会自动生成这些文件
然后我们打开文件redis1,可以看到里面也会自动生成配置文件,以下是配置文件的内容
然后我们就要开始创建redis容器了
使用docker创建出redis节点
我们以及提前创建好了yml文件但是还没有进行编写
以下是docker-compose.yml文件的内容
version: '3.7'
networks:mynet:ipam:config:- subnet: 172.30.0.0/24
services:redis1:image: 'redis:5.0.9'container_name: redis1restart: alwaysvolumes:- ./redis1/:/etc/redis/ports:- 6371:6379- 16371:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.101redis2:image: 'redis:5.0.9'container_name: redis2restart: alwaysvolumes:- ./redis2/:/etc/redis/ports:- 6372:6379- 16372:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.102redis3:image: 'redis:5.0.9'container_name: redis3restart: alwaysvolumes:- ./redis3/:/etc/redis/ports:- 6373:6379- 16373:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.103redis4:image: 'redis:5.0.9'container_name: redis4restart: alwaysvolumes:- ./redis4/:/etc/redis/ports:- 6374:6379- 16374:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.104redis5:image: 'redis:5.0.9'container_name: redis5restart: alwaysvolumes:- ./redis5/:/etc/redis/ports:- 6375:6379- 16375:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.105redis6:image: 'redis:5.0.9'container_name: redis6restart: alwaysvolumes:- ./redis6/:/etc/redis/ports:- 6376:6379- 16376:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.106redis7:image: 'redis:5.0.9'container_name: redis7restart: alwaysvolumes:- ./redis7/:/etc/redis/ports:- 6377:6379- 16377:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.107redis8:image: 'redis:5.0.9'container_name: redis8restart: alwaysvolumes:- ./redis8/:/etc/redis/ports:- 6378:6379- 16378:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.108redis9:image: 'redis:5.0.9'container_name: redis9restart: alwaysvolumes:- ./redis9/:/etc/redis/ports:- 6379:6379- 16379:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.109redis10:image: 'redis:5.0.9'container_name: redis10restart: alwaysvolumes:- ./redis10/:/etc/redis/ports:- 6380:6379- 16380:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.110redis11:image: 'redis:5.0.9'container_name: redis11restart: alwaysvolumes:- ./redis11/:/etc/redis/ports:- 6381:6379- 16381:16379command:redis-server /etc/redis/redis.confnetworks:mynet:ipv4_address: 172.30.0.111
注意这里用的是172.30.0网段,在使用之前要保证不要和主机上的其他网段冲突
# 查看主机网段
ifconfig
到这里我们的配置文件就写好了,接下来启动容器,在启动之前要确保将之前的redis进程停了才可以
ps aux | grep redis
启动docker
docker-compose up -d
如果报错可以在docker-compose.yml文件的输入栏里输入set nu就可以看到行数来查找报错的位置了
启动成功之后再查看以下docker容器和redis进程
如此可见我们的确启动成功了
但是此时还没有构建集群环境
构建集群
redis-cli --cluster create 172.30.0.101:6379 172.30.0.102:6379 172.30.0.103:6379 172.30.0.104:6379 172.30.0.105:6379 172.30.0.106:6379 172.30.0.107:6379 172.30.0.108:6379 172.30.0.109:6379 --cluster-replicas 2
//注意这是一行命令,而且我们只构建9个节点剩下两个用作之后扩容
- create:创建集群
- 172.30.0.101:6379 :容器ip和容器内redis端口,注意这里要和刚刚配置的环境里的一致
- cluster-replicas 2 :表示集群里每个分片有两个从节点,我们刚刚只是把节点创建出来还没有构建他们的关系,通过这里的设置redis就知道每个分片里是一个主节点两个从节点的结构,就可以构建出集群环境,每个分片里的节点分配哪个是不一定的,但是一定是一主两从
当输入命令后并没有立刻构建集群,而是打印了一些日志
表示一共有三个分片,每个分片对应的槽位分别是多少
表示每个分片的主从结构,看以看到101,102,103分别是每个分片的主节点,而他们的从节点也都写在前面了
表示每个节点的主从关系和每个主节点对应的槽位数
确认无误后输入yes确定,之后就可以成功搭建我们的集群环境了。
到这里集群环境的搭建就完成了
小总结
- 首先做完准备工作后,生成每个redis节点的配置文件
- 接着使用docker创建出11个redis节点,并启动容器
- 最后使用redis-cli 执行构建集群命令
使用集群
打开客户端
启动redis客户端,这里有两种方法
redis-cli -h 172.30.101 -p 6379
这种方法就相当于启动172.30.101ip容器的6379端口客户端
redis-cli -p 6371
这种相当于就是开启本机的6371端口的客户端,这两种方法开启的客户端都是一样的,因为本机的6371端口,在172.30.101ip容器的映射端口就是6379
//从101-109节点现在是一个整体本质上连上哪个节点都是一样的
#查看当前集群信息
cluster nodes
存储数据
在集群环境下,原来用来存储数据的redis依然可以使用。
先来存储一个key1
看以看到这里报了一个错,其实因为这个key1在经过hash函数之后得到的槽位是9189,分配的分片是102,也就是这其实是一个属于102的数据,而我们这里打开的是101的分片。那么如果我们想要存储这个数据就要重新启动102的分片的客户端吗?
并不是,我们可以在启动redis客户端时加上 -c 选项,此时客户端发现当前key的操作不在当前分片上,就会根据实际的槽位自动重定向到对应的分片主机。
看以看到这时就可以成功存储,而且自动帮我们重定向到102分片。不过如果使用的是从节点的客户端因为从节点是不能进行写操作的,所以在从节点进行写操作会自动重定向到对应主节点。
故障处理
如果集群中有节点挂了会怎么样’如果挂的是从节点那就无关紧要,因为从节点不能进行写操作,如果挂的是主节点,此时集群就会和之前的哨兵一样,自动的把该主节点下面的从节点,挑选一个出来选为新的主节点。
此时我们手动停掉redis1(也就是101号节点),来模拟主节点挂掉的情景
docker stop redis1
看以看到此时101节点已经挂了(fail),取而代之的是106节点(101节点有两个从节点分别是105,106),而且105节点也和106节点建立了新的主从关系。
此时我们再回复101节点
docker start redis1
此时重启的101节点变成了106节点的从节点,而106节点依然是主节点。
从刚刚的表现来看集群的故障处理好像和哨兵一样,但是其实虽然看着一样,但是内部原理却不一样
故障处理流程
1.故障的判定
- 每个节点每秒钟都会给一些随机的节点发送ping包,不全发是因为,避免再节点很多时引起巨大的开销
- 当节点A给节点B发起ping包后,如果B没有及时回应,那么A就会尝试重置和B的tcp连接,如果任然连接失败,A就会把B设为PFAIL(也就是认为B挂了,和哨兵里的主观下线一样)
- A认为B挂了之后,会通过redis内置的Gossip协议,和其他节点沟通,像其他节点确认B的状态(每个节点都会维护自己的下线表)
- 如果A发现有很多节点都认为B挂了,并且数目超过集群节点个数的一半,那么就会认为B真的挂了就把B标记位FAIL(相当于客观下线),并且把这个消息同步给其他节点,其他节点收到后也会把B标记位FAIL
2.故障迁移
故障转移就是选出一个新的主节点,让该分片的其他节点都连上这个节点
如果B是从节点就不用转移,如果B是主节点,就要从B的从节点(C和D)来选择新的主节点
- 从节点会先判定自己是否有资格参选,如果从节点和主节点太久没通信,也就是数据相差较大,那么就会失去竞争资格
- 假如C和D都具有资格,他们并不会立刻开始竞选,而是先休眠一段时间。休眠时间 = 500ms基础时间+[0,500ms]随机时间+排名*1000ms.offset的值越大,则排名越靠前(越小)。这里对休眠时间影响较大的主要是排名*1000ms,所以这个休眠时间其实就是为了选出和主节点数据最同步的那个从节点
- 如果C的休眠时间先到(先醒),C就会给集群中其他每个节点都发信息(拉票),但是只有主节点才有投票资格
- 主节点就会把自己的票投给C(每个主节点只有1票).当C收到的票数超过主节点数目的⼀半,C就会晋升成主节点.(C自己负责执行slaveof no one,并且让D执行slaveofC)。
- 同时,C还会把自己成为主节点的消息,同步给其他集群的节点大家也都会更新自己保存的集群结构信息
这里的选举策略和哨兵还是有所不同的,哨兵是先选取一共leader再由leader选出谁是主节点,而集群这里则是直接选出主节点。但是选主节点的期望都是一样的,都是选出和原来主节点数据最同步的从节点作为新的主节点
还有几种情况会导致集群宕机
- 某个分片的所有节点和从节点都挂了
- 某个分片主节点挂了,而且没有从节点
- 超过半数的master节点都挂了
集群扩容
将新的节点加入集群
redis-cli --cluster add-node 172.30.0.110:6379 172.30.0.101:6379
将110和111也加入到集群,集群从原来的3分片变为4分片
//集群扩容是一件风险较高的事,而且成本也较大
看以看到虽然我们将110节点插入到集群中并且成为新的分片,但是并没有分配槽位,所以接下来就要为新分片分配槽位
重新分配slots
redis-cli --cluster reshard 172.30.0.101:6379
执行后他会问你,要分多少槽位给新的分片
这里我们分四分之一就可以,16384的四分之一大概就是4096
之后还有输入新分片的id
也就是这个槽位为0的分片
之后询问你要从那些分片中分出来,选all就是所有,不过不选就要将指定的分片一一列举出来,这里我直接选all
然后就会跳出好的信息
这个是要你确认将这些槽位分出去可不可以,选yes就行
之后就正式开始搬运了,这里并不是只搬运槽位,而是连槽位上对应的数据也一起搬运
此时就已经搬运成功了
如果再搬运的时候,客户端能否访问redis集群呢?
我们在搬运的时候,大部分key是不需要搬运的,所以对于这些key是可以正常访问的,而对于那些正在搬运的key可能就会访问出错。可能客户端正在访问key,但是因为搬运这个key刚好搬走了也就访问不到了。
所以如果要在生产环境进行扩容操作,最好找个没啥访问量的时候进行,降低损失。当然也有好一些的方法,就是重新搭建一组集群,然后将之前的数据都导过来使用新的集群代替旧的,这样做虽然速度快,安全性高但是响应的成本也非常高。
把从节点加入集群
光有主节点了,此时扩容的目标已经初步达成.但是为了保证集群可用性,还需要给这个新的主节点添加从节点,保证该主节点宕机之后,有从节点能够顶上。
redis-cli --cluster add-node 172.30.0.111:6379 172.30.0.101:6379 --cluster-slave
--cluster-master-id [172.30.1.110 节点的 nodeId]
注意这里后面要跟对应的id
可以看到此时从节点111就已经成功连上主节点110了
到这里我们就完成了集群的扩容!!!