Redis:分布式 - 集群
集群
在主从复制
与哨兵
模式中,数据库的数据对于每一台主机来说,都是全量保存的。这就会导致,就算引入再多台服务器,数据的存储上限都不会变。为了提高数据的存储上限,那么就要让每个节点都保存一部分数据,而不是保存所有数据。在Redis
中,这种模式称为集群
。
此处要辨析一下集群
的概念:
广义集群
:只要是多台服务器共同提供服务,就称为集群Redis集群
:不同服务器只存储总数据的一部分
每一部分数据,不能由一个节点来存储,一旦这个节点宕机,那么这部分数据就会丢失,因此每一部分数据,都会用一个主从复制来维护。
如下图:
此处维护了三个主从复制,每个主从复制都只存储总量1 / 3
的数据,这样既可以提高存储的数据总量,又可以保证数据的安全。
此处每一部分数据,都称为一个分片
,接下来讲解如何对数据进行分片。
数据分片
分片的核心思路是用多组机器来存数据的每个部分,那么接下来的核心问题就是,给定一个数据(一个具体的 key),那么这个数据应该存储在哪个分片上?读取的时候又应该去哪个分片读取?围绕这个问题,业界有三种比较主流的实现方式:
- 哈希求余
- 一致性哈希算法
- 哈希槽分区算法
哈希求余
第一种是采用最基本的哈希算法,将key
通过哈希算法转化为一个哈希值,随后对哈希值进行取模运算。
h a s h ( k e y ) m o d N hash(key) \bmod N hash(key)modN
hash(key)
用于取哈希值,N
是分区个数,上式结果是多少,那么该数据就放入到第几个分区存储。
这种方式非常简单有效,但是也会遇到问题,那就是扩容问题。扩容后N
的值会变大,那么原先的求余就不再适用了,此时要把所有数重新计算。计算完毕后,还要面临复杂的交换数据的过程。
一致性哈希算法
一致性哈希算法解决了数据拷贝的问题,如下图:
假设最开始有四个分片,经过哈希函数的到达哈希值一定在 [ 0 , 2 32 ) [0, 2^{32}) [0,232)区间内,那么将 2 32 2^{32} 232个哈希值均匀分成四份。每个范围内的数据,都属于一个分区,每个分区存储的哈希值范围都是 2 30 2^{30} 230。
当要进行扩容,加入新的分片:
如图所示,直接从某一个分片中划分出一半来,紫色区域就是新的分片需要存储的数据范围。这样,就只需要从原先的绿色分片中,拷贝出大约一半的数据给新分区。一方面来说,拷贝是一对一的,没有错综复杂的拷贝关系,另一方面,拷贝的数据变少了,这样就可以节省很多拷贝的消耗。
但是这也会面临一个问题,那就是加入新分区后,五个分区的数据量不同了,这称为数据倾斜
。
哈希槽分区算法
前两个算法都存在一定的缺陷,而哈希槽分区算法解决了这些算法,最终Redis
采用了这一版算法。
Redis
官方文档给出了以下公式:
c r c 16 ( k e y ) m o d 16384 crc16(key) \bmod 16384 crc16(key)mod16384
此处的crc
是一种哈希算法,而16384 = 1024 * 16
,这样就生成了16384
个哈希槽,随后会把这些哈希槽分配给不同的分片。
假设当前有三个分片,它们分配到的哈希槽可能为:
0号分片
:[0, 5461]
,共5462
个槽位1号分片
:[5462, 10923]
,共5462
个槽位2号分片
:[10924, 16383]
,共5460
个槽位
也就是说,每个分片最终会拿到尽可能接近的槽位,当crc(key)
的值落在对应的槽位,那么这个数据就会进入指定的分片存储。
在Redis
内部,每个分片使用一张位图来表示自己持有的槽位,16384
个槽位,只需要2048
个字节来表示即可。
当然这些槽位的编号未必是连续的,也有可能是其它分配方法,但是最终还是要保证每个分片的槽位的个数接近。
当对数据库扩容,新增一个分片,此时就会对槽位再分配:
0号分片
:[0, 4095]
,共4096
个槽位1号分片
:[5462, 9557]
,共4096
个槽位2号分片
:[10924, 15019]
,共4096
个槽位3号分片
:[4096, 5461] + [9558, 10923] + [15020, 16383]
,共4096
个槽位
此时,每个分片分出一部分槽位给新来的分片,并且保证最终所有分片持有的槽位个数接近,此处因为16384 / 4
可以除尽,所以最终四个分片的槽位数目完全相同。
最后进行数据拷贝时,只需要0 1 2
三个分片,分别单向拷贝一部分数据给3
号分片即可。
- 为什么是
16384
个槽位?
- 节点之间通过心跳包通信,心跳包中包含了该节点持有哪些槽位,这个是使用位图这样的数据结构表示的。表示 16384 个槽位需要的位图大小是 2KB,如果给定的槽位数更多了,此时就需要消耗更多的空间。这对于内存来说不算什么,但是在频繁的网络心跳包中,还是一个不小的开销,因为网络带宽是比内存更稀缺的资源
- 另一方面,Redis 集群一般不建议超过 1000 个分片。
16384
个槽位,对于最大 1000 个分片来说是足够用的,同时也会使对应的槽位配置位图体积不至于很大
Docker搭建集群
接下来使用docker
搭建一个如下集群:
将整个数据库分为三个分片,每个分片都有一个主从复制。除了这九个节点,还需要额外的两个节点来进行模拟扩容操作,但是最开始不加入集群,是两个单独的节点。
另外的,为了方便操作,每一个节点都指定一个内网IP,后续直接通过操作IP来操作redis
。
首先挑选一个合适的目录,用于进行测试,在目录中写如下脚本文件generate.sh
:
bash
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
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
这个脚本可以生成十一个目录,每个目录内部都有一个redis
配置文件。
执行bash generate.sh
:
此时就生成了是一个配置文件与目录了。
编写docker-compose.yml
:
bash
networks:
mynet:
ipam:
config:
- subnet: 172.30.0.0/24
services:
redis1:
image: 'redis:5.0.9'
container_name: redis1
restart: always
volumes:
- ./redis1/:/etc/redis/
ports:
- 6371:6379
- 16371:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.101
redis2:
image: 'redis:5.0.9'
container_name: redis2
restart: always
volumes:
- ./redis2/:/etc/redis/
ports:
- 6372:6379
- 16372:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.102
redis3:
image: 'redis:5.0.9'
container_name: redis3
restart: always
volumes:
- ./redis3/:/etc/redis/
ports:
- 6373:6379
- 16373:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.103
redis4:
image: 'redis:5.0.9'
container_name: redis4
restart: always
volumes:
- ./redis4/:/etc/redis/
ports:
- 6374:6379
- 16374:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.104
redis5:
image: 'redis:5.0.9'
container_name: redis5
restart: always
volumes:
- ./redis5/:/etc/redis/
ports:
- 6375:6379
- 16375:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.105
redis6:
image: 'redis:5.0.9'
container_name: redis6
restart: always
volumes:
- ./redis6/:/etc/redis/
ports:
- 6376:6379
- 16376:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.106
redis7:
image: 'redis:5.0.9'
container_name: redis7
restart: always
volumes:
- ./redis7/:/etc/redis/
ports:
- 6377:6379
- 16377:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.107
redis8:
image: 'redis:5.0.9'
container_name: redis8
restart: always
volumes:
- ./redis8/:/etc/redis/
ports:
- 6378:6379
- 16378:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.108
redis9:
image: 'redis:5.0.9'
container_name: redis9
restart: always
volumes:
- ./redis9/:/etc/redis/
ports:
- 6379:6379
- 16379:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.109
redis10:
image: 'redis:5.0.9'
container_name: redis10
restart: always
volumes:
- ./redis10/:/etc/redis/
ports:
- 6380:6379
- 16380:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.110
redis11:
image: 'redis:5.0.9'
container_name: redis11
restart: always
volumes:
- ./redis11/:/etc/redis/
ports:
- 6381:6379
- 16381:16379
command:
redis-server /etc/redis/redis.conf
networks:
mynet:
ipv4_address: 172.30.0.111
由于要启动是一个容器,所以这个文件会比较长。
启动容器:
bash
docker compose up -d
此时十一个容器就启动了。
但是这些容器目前还是单独的节点,尚未构成集群。
执行以下命令完成集群构建:
bash
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
--cluster create
:表示建立集群,后面填写每个节点的IP和端口--cluster-replicas 2
:表示每个主节点需要两个从节点
这样redis
就会依据规则自动构建集群,每三个节点构成一个分片(因为指定了一个主有两个从),并且会自动构建主从关系。
输入命令后,出现以下界面:
bash
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
这一部分,就是信号槽的分配部分,可以看到redis
内部采用了连续分配的方式,将信号槽均匀的分配给三个分片。
bash
Adding replica 172.30.0.105:6379 to 172.30.0.101:6379
Adding replica 172.30.0.106:6379 to 172.30.0.101:6379
Adding replica 172.30.0.107:6379 to 172.30.0.102:6379
Adding replica 172.30.0.108:6379 to 172.30.0.102:6379
Adding replica 172.30.0.109:6379 to 172.30.0.103:6379
Adding replica 172.30.0.104:6379 to 172.30.0.103:6379
这是主从关系的建立部分,第一行表示:172.30.0.105:6379
成为172.30.0.101:6379
的从节点。那么这六行日志,就表示xxx.101
、xxx.102
、xxx.103
成为了三个主节点,其余的节点成为这三个的从节点。
bash
M: b2623df997bc620972a7a682852b16e1403e2f76 172.30.0.101:6379
slots:[0-5460] (5461 slots) master
M: 490c33d0d8b9d3be62f56d069419af35e96525a7 172.30.0.102:6379
slots:[5461-10922] (5462 slots) master
M: f84179e62de60218aa585fcc1c414ac25e0b85a0 172.30.0.103:6379
slots:[10923-16383] (5461 slots) master
这一段日志,就是具体把那一部分槽位具体分配给哪一个分片,比如xxx.101
拿到了[0-5460]
的槽位号。
此时输入yes
,表示用户同意这个构建集群的方式,redis
就会开始构建集群。
集群操作
此时可以连接任意一个端口的redis
,比如6371
:
bash
redis-cli -p 6371
此时不论连接上任意一个节点,都可以视作连接上了整个集群。
bash
cluster nodes
执行以上命令,可以看到当前节点集群内的所有节点。
重定向
尝试插入数据:
报错了,因为当前集群发生了分片,每个分片都只能存储一部分数据,key1
经过哈希运算,发现并不是这个节点可以存储的值,于是就报错了。
想要解决这个问题,可以在启动时加上-c
选项,此时插入数据,会自动重定向到对应的节点。
bash
redis-cli -c -p 6371
如图,每次操作数据是,如果该数据不属于当前分片,就会触发一次重定向,自动跳转到对应的客户端。命令行前面的端口号一直在改变,这就说明我们的客户端一直在切换。
但是redis
中,有一些命令同时操作多个key
,比如最后一个命令mget
,此时又报错了。因为这几个key
属于不同分片,那么就无法同时处理,因此在集群的情况下,最好不要一次性操作多个key
。
故障转移
如果在集群中,某一个分片的主节点宕机了,会发生什么?在部署集群时,并没有引入哨兵节点,但是集群也会完成哨兵的工作,如果主节点宕机了,集群会自动完成重新选主的过程。
如图:
首先通过docker stop redis1
,关掉了redis1
节点,也就是xxx.101
下线了,而这是一个主节点。登录6372
端口的客户端,查看当前集群,可以发现xxx.106
成为了新的主节点,而xxx.106
原先是xxx.101
的从节点。
重启redis1
,其变为了reids6
的从节点。
此处集群的故障转移,和哨兵的故障转移是有一些差别的,接下来就讲解集群中是如何完成故障转移的。
- 故障判定
集群中的所有节点,都会周期性的使用心跳包进行通信
- 节点A 给 节点B 发送
ping
包,B 就会给 A 返回一个pong
包,ping
和pong
除了消息类型属性之外,其他部分都是一样的,这里包含了集群的配置信息:- 节点的id
- 该节点从属于哪个分片
- 是主节点还是从节点
- 从属于谁
- 持有哪些哈希槽的位图
- 每个节点,每秒钟都会给一些随机的节点发起
ping
包,而不是全发一遍,这样设定是为了避免在节点很多的时候,心跳包也非常多 - 当 节点A 给 节点B 发起
ping
包,B不能如期回应的时候,此时 A 就会尝试重置和 B 的 tcp 连接,看能否连接成功,如果仍然连接失败,A 就会把 B 设为PFAIL
状态,相当于主观下线 - A 判定 B为
PFAIL
之后,会通过redis
内置的Gossip
协议,和其他节点进行沟通,向其他节点确认 B的状态,每个节点都会维护一个自己的"下线列表",由于视角不同,每个节点的下线列表也不一定相同 - 此时A发现其他很多节点也认为B为
PFAIL
,并且数目超过总集群个数的一半,那么A就会把B标记成FAIL
(相当于客观下线),并且把这个消息同步给其他节点,其他节点收到之后,也会把B标记成FAIL
至此,B 就彻底被判定为故障节点了。
- 故障迁移
上述例子中,B 故障并且 A 把 B FAIL
的消息告知集群中的其他节点:
- 如果 B 是从节点,那么不需要进行故障迁移
- 如果 B是主节点,那么就会由 B 的从节点触发故障迁移
所谓故障迁移,就是指把从节点提拔成主节点,继续给整个redis
集群提供支持.具体流程如下:
- 从节点判定自己是否具有参选资格,如果从节点和主节点已经太久没通信,此时认为从节点的数据和主节点差异太大了,时间超过阈值,就失去竞选资格
- 具有资格的节点,就会先休眠一定时间,
休眠时间=500ms基础时间+[0,500ms]随机时间+排名*1000ms
,offset
的值越大,则排名越靠前(越小) - 如果某个节点的休眠时间到了,该节点就会给其他所有集群中的节点,进行拉票操作,但是只有主节点才有投票资格
- 每个主节点只有1票,当该节点收到的票数超过主节点数目的一半,就会晋升成主节点
- 新的主节点自己负责执行
slaveofno one
,并且让同一分片中的其它节点执行slaveof
- 最后,新的主节点会把自己成为主节点的消息,同步给其他集群的节点,大家也都会更新自己保存的集群结构信息
以上算法成为raft
算法,其实和哨兵选主的目的是一样的,就是选出那个目前网络状态比较好的节点成为主节点,而网络状态的反映,就是休眠时间。
有些情况下,如果节点宕机,会导致整个集群宕机,这称为fail
状态:
- 某个分片内部,所有的主节点和从节点都挂了
- 某个分片内部,主节点挂了没有从节点可以成为新的主节点
- 超过半数的主节点都挂了
集群扩容
- 加入集群
想要给集群扩容,可以通过--cluster add-node
选项:
bash
redis-cli --cluster add-node 新增节点 集群任意节点
新增节点
:要增加到集群的节点集群任意节点
:用于标识要加入哪一个集群
执行:
bash
redis-cli --cluster add-node 172.30.0.110:6379 172.30.0.101:6379
这样就可以把xxx.110
节点加入到集群中,登入任意客户端查看:
可以看到,集群内部已经有xxx.110
了,而且是一个主节点。但是仔细观察,可以发现其他节点末尾都有哈希槽的范围,但是新增的节点没有,说明新节点还没有分配。
- 分配哈希槽
接下来就要给新节点分配哈希槽,同选项 --cluster reshard
,注意是shard
不是shared
。
bash
redis-cli --cluster reshard 172.30.0.101:6379
执行后进入如下选项:
上面的S
表示从节点,M
表示主节点,在主节点的信息中,已经告知了每个主节点拥有的槽位个数。此处它询问要移动多少个slots
,也就是哈希槽。
此处要移动4096
个哈希槽给新节点,所以输入4096
:
随后它询问将这些哈希槽移动给哪一个节点,此时往上找哪一个master
节点的哈希槽为0
,复制他的ID。
最后询问,要从哪些节点中空出这些节点。如果选择all
,那么就是从所有的现有节点平均提取。如果你希望自己指定,那么就复制那些节点的ID,最后以done
结尾即可。
最后向用户确认,是否要这样执行。
最后进入任意客户端,查看集群现状:
可以看到,新节点获得了三个范围的哈希槽。
这里有一个小问题,这个搬运哈希槽的过程是比较久的,如果在搬运期间,用户访问数据是合法的吗?
这分情况,搬运过程中,大部分哈希槽是不用搬运的,如果用户访问这些哈希槽内的数据,那么可以正常访问。但是如果用户访问正在移动的哈希槽,那么就会失败了。
- 添加从节点
目前添加了主节点,最后还要把从节点安排上,这通过 add-node
命令配合--cluster-master-id
完成:
bash
redis-cli --cluster add-node 新节点 --cluster-slave --cluster-master-id 主节点的ID
--cluster-slave
:这个选项指定新添加的节点将作为从节点--cluster-master-id
:这个选项后面跟着的是主节点的ID,表示该节点从属于哪一个节点
此时从属节点就成功加入集群了。