Redis:分布式 - 集群

Redis:分布式 - 集群


集群

主从复制哨兵模式中,数据库的数据对于每一台主机来说,都是全量保存的。这就会导致,就算引入再多台服务器,数据的存储上限都不会变。为了提高数据的存储上限,那么就要让每个节点都保存一部分数据,而不是保存所有数据。在Redis中,这种模式称为集群

此处要辨析一下集群的概念:

  • 广义集群:只要是多台服务器共同提供服务,就称为集群
  • Redis集群:不同服务器只存储总数据的一部分

每一部分数据,不能由一个节点来存储,一旦这个节点宕机,那么这部分数据就会丢失,因此每一部分数据,都会用一个主从复制来维护。

如下图:

此处维护了三个主从复制,每个主从复制都只存储总量1 / 3的数据,这样既可以提高存储的数据总量,又可以保证数据的安全。

此处每一部分数据,都称为一个分片,接下来讲解如何对数据进行分片。


数据分片

分片的核心思路是用多组机器来存数据的每个部分,那么接下来的核心问题就是,给定一个数据(一个具体的 key),那么这个数据应该存储在哪个分片上?读取的时候又应该去哪个分片读取?围绕这个问题,业界有三种比较主流的实现方式:

  1. 哈希求余
  2. 一致性哈希算法
  3. 哈希槽分区算法

哈希求余

第一种是采用最基本的哈希算法,将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个槽位?
  1. 节点之间通过心跳包通信,心跳包中包含了该节点持有哪些槽位,这个是使用位图这样的数据结构表示的。表示 16384 个槽位需要的位图大小是 2KB,如果给定的槽位数更多了,此时就需要消耗更多的空间。这对于内存来说不算什么,但是在频繁的网络心跳包中,还是一个不小的开销,因为网络带宽是比内存更稀缺的资源
  2. 另一方面,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.101xxx.102xxx.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的从节点。

此处集群的故障转移,和哨兵的故障转移是有一些差别的,接下来就讲解集群中是如何完成故障转移的。

  • 故障判定

集群中的所有节点,都会周期性的使用心跳包进行通信

  1. 节点A 给 节点B 发送ping包,B 就会给 A 返回一个pong包,pingpong除了消息类型属性之外,其他部分都是一样的,这里包含了集群的配置信息:
    • 节点的id
    • 该节点从属于哪个分片
    • 是主节点还是从节点
    • 从属于谁
    • 持有哪些哈希槽的位图
  2. 每个节点,每秒钟都会给一些随机的节点发起 ping 包,而不是全发一遍,这样设定是为了避免在节点很多的时候,心跳包也非常多
  3. 当 节点A 给 节点B 发起 ping包,B不能如期回应的时候,此时 A 就会尝试重置和 B 的 tcp 连接,看能否连接成功,如果仍然连接失败,A 就会把 B 设为 PFAIL 状态,相当于主观下线
  4. A 判定 B为 PFAIL 之后,会通过 redis 内置的 Gossip 协议,和其他节点进行沟通,向其他节点确认 B的状态,每个节点都会维护一个自己的"下线列表",由于视角不同,每个节点的下线列表也不一定相同
  5. 此时A发现其他很多节点也认为B为 PFAIL,并且数目超过总集群个数的一半,那么A就会把B标记成 FAIL (相当于客观下线),并且把这个消息同步给其他节点,其他节点收到之后,也会把B标记成FAIL

至此,B 就彻底被判定为故障节点了。

  • 故障迁移

上述例子中,B 故障并且 A 把 B FAIL 的消息告知集群中的其他节点:

  • 如果 B 是从节点,那么不需要进行故障迁移
  • 如果 B是主节点,那么就会由 B 的从节点触发故障迁移

所谓故障迁移,就是指把从节点提拔成主节点,继续给整个redis 集群提供支持.具体流程如下:

  1. 从节点判定自己是否具有参选资格,如果从节点和主节点已经太久没通信,此时认为从节点的数据和主节点差异太大了,时间超过阈值,就失去竞选资格
  2. 具有资格的节点,就会先休眠一定时间,休眠时间=500ms基础时间+[0,500ms]随机时间+排名*1000msoffset 的值越大,则排名越靠前(越小)
  3. 如果某个节点的休眠时间到了,该节点就会给其他所有集群中的节点,进行拉票操作,但是只有主节点才有投票资格
  4. 每个主节点只有1票,当该节点收到的票数超过主节点数目的一半,就会晋升成主节点
  5. 新的主节点自己负责执行 slaveofno one,并且让同一分片中的其它节点执行 slaveof
  6. 最后,新的主节点会把自己成为主节点的消息,同步给其他集群的节点,大家也都会更新自己保存的集群结构信息

以上算法成为raft算法,其实和哨兵选主的目的是一样的,就是选出那个目前网络状态比较好的节点成为主节点,而网络状态的反映,就是休眠时间。

有些情况下,如果节点宕机,会导致整个集群宕机,这称为fail状态:

  1. 某个分片内部,所有的主节点和从节点都挂了
  2. 某个分片内部,主节点挂了没有从节点可以成为新的主节点
  3. 超过半数的主节点都挂了

集群扩容

  • 加入集群

想要给集群扩容,可以通过--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,表示该节点从属于哪一个节点

此时从属节点就成功加入集群了。


相关推荐
远歌已逝37 分钟前
维护在线重做日志(二)
数据库·oracle
只因在人海中多看了你一眼1 小时前
分布式缓存 + 数据存储 + 消息队列知识体系
分布式·缓存
qq_433099402 小时前
Ubuntu20.04从零安装IsaacSim/IsaacLab
数据库
Dlwyz2 小时前
redis-击穿、穿透、雪崩
数据库·redis·缓存
zhixingheyi_tian3 小时前
Spark 之 Aggregate
大数据·分布式·spark
工业甲酰苯胺4 小时前
Redis性能优化的18招
数据库·redis·性能优化
没书读了5 小时前
ssm框架-spring-spring声明式事务
java·数据库·spring
求积分不加C5 小时前
-bash: ./kafka-topics.sh: No such file or directory--解决方案
分布式·kafka
nathan05295 小时前
javaer快速上手kafka
分布式·kafka
i道i5 小时前
MySQL win安装 和 pymysql使用示例
数据库·mysql