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,表示该节点从属于哪一个节点

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


相关推荐
数据智能老司机18 分钟前
CockroachDB权威指南——SQL调优
数据库·分布式·架构
数据智能老司机19 分钟前
CockroachDB权威指南——应用设计与实现
数据库·分布式·架构
数据智能老司机32 分钟前
CockroachDB权威指南——CockroachDB 模式设计
数据库·分布式·架构
数据智能老司机19 小时前
CockroachDB权威指南——CockroachDB SQL
数据库·分布式·架构
数据智能老司机20 小时前
CockroachDB权威指南——开始使用
数据库·分布式·架构
松果猿20 小时前
空间数据库学习(二)—— PostgreSQL数据库的备份转储和导入恢复
数据库
Kagol20 小时前
macOS 和 Windows 操作系统下如何安装和启动 MySQL / Redis 数据库
redis·后端·mysql
无名之逆20 小时前
Rust 开发提效神器:lombok-macros 宏库
服务器·开发语言·前端·数据库·后端·python·rust
s91236010120 小时前
rust 同时处理多个异步任务
java·数据库·rust
数据智能老司机20 小时前
CockroachDB权威指南——CockroachDB 架构
数据库·分布式·架构