前言
前面我们学习了 redis 哨兵机制,哨兵机制是为了解决当主节点挂了之后,能够自动进行故障转移的机制,该机制提高了系统的可用性,它并不能帮助 redis 节点存储更多的数据,要想使得我们的 redis 节点能够存储更多的数据,就需要用到集群。
什么是集群
这里集群有两种含义,一种是广义的集群,一种就是狭义的集群。只要你是多个机器构成的分布式系统,都可以称作是一个“集群”,这就是广义的集群,而狭义的集群则是 redis 提供的集群模式,在这个集群模式下主要解决的就是存储空间不足的情况。
Redis集群实现了对Redis的水平扩容,即启动多个Redis节点,将整个数据库分布存储在多个节点中,每个节点存储总数据的一部分。Redis集群将数据分成多个槽(slot),每个节点负责处理其中的一部分槽,通过哈希算法将键分配到不同的槽中,这样可以实现数据的分布式存储和负载均衡。
集群模式基本原理
既然集群模式是将数据分为多个部分,分别存储在多个 redis 节点中的,那么如何将数据分为多个部分呢?redis 是如何实现数据的分片的呢?
哈希求余
哈希求余的思想很简单,我们都知道任何一个数据经过哈希函数的计算都能得到一个整数,当我们插入数据的时候,因为 redis 中 key 是唯一的,所以就将 key 经过哈希函数的计算得到一个整数,然后再针对 redis 主节点的个数进行求余,然后根据这个余数讲数据插入到对应编号的 redis 节点中,这样就简单的实现了数据的分片。
当查询 key 的值的时候,还是首先将 key 经过哈希函数的计算得到一个整数,然后还是将这个整数与 redis 主节点的个数进行去余的操作,根据这个余数和 redis 节点的编号的对应关系就知道这个 key 存储在哪个 redis 节点上了。
通过哈希求余可以很简单的首先数据分片操作,但是这个做法有一个致命的缺点,就是当进行扩容操作,增加 redis 节点的时候,对应的取余的操作数就发生了变化了,对于扩容之前的数据进行查询操作,因为 redis 节点的数量变化了,所以取模之后得到的结果就不一样了,那么在该节点对应编号的 redis 节点上查询该数据就无法得到该 key 的值。
为了解决这个问题,就需要对不应该待在当前节点的数据进行重新分配:
可以发现,当进行扩容操作的时候,大部分的数据都需要进行数据迁移的操作,这是一个很大的工作量。所以为了解决这个问题,又出现了第二种分片的方式 —— 一致性哈希算法。
一致性哈希算法
相较于普通的哈希算法,一致性哈希算法在进行哈希之后当前 key 属于有哪个分片,这个交替出现的情况改为了连续的情况,比如一段数据经过哈希之后的结果是 0 1 2 3,那么这四个数据就分别属于 0、1、2、3 号编号所对应的 redis 节点,而一致性哈希算法将这种交替出现的情况改为了连续出现,一致性哈希算法,会将一个圆分成例如 2^ 32 -1 个哈希槽,当 key 经过哈希函数计算之后,拿这个计算之后的结果去余上 2^ 32 -1,然后按照顺时针或者逆时针去找分片。
当进行扩容操作新增加 redis 节点之后,通过这个一致性哈希算法进行数据重新分配的时候就只用迁移小部分数据了:
一致性哈希算法虽然解决了普通哈希算法扩容的时候需要重新分配大量数据的问题,但是这样又出现了新的问题,就是出现了可能各个 redis 节点上存储的数据量存在较大差异的情况,这样就导致了数据倾斜的问题,这就导致了某个服务器承受的压力很大而导致挂掉,而有节点中存储的数据太少,干的工作很少的情况。这样有解决方法吗?有的,可以在扩容的时候,一次添加多个 redis 节点,使得新数据在迁移到新的 redis 节点之后各个 redis 节点中存储的数据量基本相同的情况,但是这样做的话又存在不确定性,因为实际工作中可能一次扩容老板不会给你这么多机器。所以为了解决这个问题,又出现了另外一种算法 —— 哈希槽分区算法。
哈希槽分区算法
哈希槽分区算法才是 redis 真正采用的分片算法,这个算法本质上结合了普通哈希求余和一致性哈希算法。该算法会将一致性哈希算法中的哈希槽分配到不同的分片上。
该算法会将哈希槽的个数默认为 16384 个,这个数字是如何来的呢?8 * 2 * 1024,也就是 2kb 的大小,为什么会设置为 2kb 呢?我们都知道判断 redis 节点是否存活的方式是定时向这个 redis 节点发送心跳包,而这个心跳包则是通过网络来传输的,既然是通过网络传输的,就不可避免的需要网络带宽,而网络带宽是网络中最重要也是最贵的资源,所以心跳包的大小越大就需要占用越多的网络带宽,虽然 2 kb 不算大,但是顶不住多了 redis 节点每一段时间都需要这个心跳包,这个 2 kb 大小的槽位是符合现在的业务需求的,所以为了避免占用过多的网络带宽又保证能够满足需求,所以哈希槽分区算法的槽位就设置成了 16384 个。
该算法是如何将槽位分配到不同的分片中的呢?给大家举个例子:
假设有三个分片,可能的分配方式:
- 0号分片:[0, 5461],共 5462 个槽位
- 1号分片:[5462, 10923],共 5462 个槽位
- 2号分片:[10924, 16383],共 5460 个槽位
虽然不能做到绝对平均,但是每个节点所分得的槽位数量是大致相同的,并且每个分片为了区分哪个槽位是属于自己的,使用了位图这样的数据结构来记录。每个分片持有的槽位号可以是不连续的。
如果此时进行了扩容,有四个分片,那么每个分片所持有的槽位就会发生变化:
- 0号分片:[0, 4095],共 4096个槽位
- 1号分片:[5462, 9557],共 4096个槽位
- 2号分片:[10924, 15019],共 4096个槽位
- 3号分片:[4096, 5461] + [9558, 10923] + [15020, 16383]共 4096 个槽位
当进行扩容的时候,只是将每个分片上的槽位分一点给新的分片,这样就减少了大量的重新分配工作。
docker模拟出一个集群
同样,因为我条件有限,只有一个云服务器,而使用虚拟机的话,也需要创建出很多个虚拟机,就很吃主机的内存,所以这里就选择使用 docker 来模拟出来集群模式。
准备模拟出三个分片,也就是三个 redis 主节点,每个主节点有两个从节点,也就是说一共需要创建出 9 个 redis 节点,一个 redis 节点需要一个配置文件,这里一个一个手动创建的话就很麻烦,所以我们选择使用 shell 脚本的方式来创建多个 redis 配置文件。
先创建出下面目录结构的文件:
redis-cluster
├── docker-compose.yml
└── generate.sh
编写 generate.sh 文件中的内容:
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-anounce-port 6379
cluster-announce-bus-poet 16379
EOF
done
- seq 1 9:生成[1,9]之间的数字
- ${}:表示得到执行 {} 中的命令的返回值
- for port in ${seq 1 9};:循环 port 的取值为1-9
- \:续航符,将下面一行的内容将当前一行的内容合并为一行
- do:循环开始的标志,Linux shell 中不是用 {} 来表示循环体
- EOF:文件内容结束的标志
- cat >:将下面的内容重定向到文件中
- done:循环结束的标志
写完 shell 脚本之后,使用 bash .sh
来运行 shell 脚本,运行之后当前目录结构就变成了:
redis-cluster
├── docker-compose.yml
├── generate.sh
├── redis1
│ └── redis.conf
├── redis2
│ └── redis.conf
├── redis3
│ └── redis.conf
├── redis4
│ └── redis.conf
├── redis5
│ └── redis.conf
├── redis6
│ └── redis.conf
├── redis7
│ └── redis.conf
├── redis8
│ └── redis.conf
└── redis9
└── redis.conf
为了展示后面的扩容的情况,我们再创建出两个额外的节点:
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.10${
port}
cluster-announce-port 6379
cluster-announce-bus-port 16379
EOF
done
redis-cluster
├── docker-compose.yml
├── generate.sh
├── redis1
│ └── redis.conf
├── redis10
│ └── redis.conf
├── redis11
│ └── redis.conf
├── redis2
│ └── redis