(八)Redis 主从复制、切片集群

一、主从复制

1、主从关系

都说的 Redis 具有高可靠性,这里有两层含义:一是数据尽量少丢失,二是服务尽量少中断。AOF 和 RDB 保证了前者,而对于后者,Redis 的做法就是将一份数据同时保存在多个实例上。为了保证数据一致性,Redis 提供了主从库模式,并采用读写分离的方式,如图

2、主从复制-全量

当启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系。例如,让实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5)成为主从关系的命令:replicaof 172.16.19.3 6379,当关系建立后,第一次同步分数据为三个阶段:

(1)从库给主库发送 psync 命令,表示要进行数据同步,包含主库的 runID(redis 实例启动生成的随机 ID) 和复制进度 offset 两个参数,初次复制runID 为 ?offset 为 -1,主库会用 FULLRESYNC(初次为全量复制)响应命令带上两个参数返回给从库,从库收到响应后会记录 runID、offset 两个参数。
(2)主库执行 bgsave 命令,生成 RDB 文件并发给从库,从库会先清空当前数据库,然后加载 RDB 文件。这个过程中主库不会被阻塞,为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。
(3)主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。

3、主从复制-级联

全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件,如果从库数量很多,主线程忙于 fork 子进程生成 RDB 文件会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。

我们可以通过"主-从-从"模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上,从而降低主库的压力。简单来说,我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库,相当于选择一个从库当做其他从库的主库,执行replicaof 所选从库IP 6379,建立关系。

主从复制完成后,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

4、网络问题

网络中断后,主从库会采用增量复制的方式把主从库网络断连期间主库收到的命令同步给从库,期间命令会写入 replication buffer 以及 repl_backlog_buffer 缓冲区。这是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

起初,两个位置是相同的,但随着主库不断接收新的写操作,缓冲区中的写位置会逐步偏离起始位置,通常用偏移量来衡量这个偏移距离的大小,偏移越多,master_repl_offset 越大。

当主从库的连接恢复,从库首先会给主库发送 psync 命令把自己当前的 slave_repl_offset 发给主库,主库根据 master_repl_offset 和 slave_repl_offset 之间的差距,形成命令发送给从库进行数据同步。

需要强调的是,因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。

我们可以调整 repl_backlog_size 这个参数来设置缓冲空间大小。计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,我们可以扩大一定倍数应对突发的请求压力。

如果并发请求量非常大,除了适当增加 repl_backlog_size 值,就需要考虑使用切片集群来分担单个主库的请求压力了。

二、切片集群

在使用 RDB 持久化时,Redis 会 fork 子进程来完成,fork 操作的用时和 Redis 的数据量是正相关的,而 fork 在执行时会阻塞主线程。数据量越大,fork 操作造成的主线程阻塞的时间越长,会导致 Redis 响应变慢。我们使用 INFO 命令查看 Redis 的 latest_fork_usec 指标值(表示最近一次 fork 的耗时)。所以当数据量持续增长时,通过扩大内存的方式不太适用,应该采用切片集群。

切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。例如把 25GB 的数据平均分成 5 份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。实例在为 5GB 数据生成 RDB 时,数据量就小了很多,fork 子进程一般不会给主线程带来较长时间的阻塞。
加大内存、切片集群,两种方法对应的就是 纵向扩展(scale up)和横向扩展(scale out):
(1)纵向扩展:升级单个 Redis 实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的 CPU。
(2)横向扩展:横向增加当前 Redis 实例的个数。
纵向扩展的好处是,实施起来简单、直接,但是受限于数据量增加带来的阻塞问题和硬件成本问题。与纵向扩展相比,横向扩展是一个扩展性更好的方案,要想保存更多的数据,只用增加 Redis 的实例个数就行了,相对的管理起来会复杂一点。

我们就需要解决两大问题:
(1)数据切片后,在多个实例之间如何分布?
(2)客户端怎么确定想要访问的数据在哪个实例上?

Redis 切片集群通常是通过 Redis Cluster 来实现的,用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。先根据键值对的 key 按照 CRC16 算法 计算一个 16 bit 的值,然后再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

使用 cluster create 命令创建集群时,Redis 会自动把这些槽平均分布在集群实例上,每个实例上的槽个数为 16384/N 个。也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。举个栗子,假设3个实例,5个哈希槽,根据实例内存情况,按下图配置:

redis 复制代码
redis-cli -h 172.16.19.3 --p 6379 cluster addslots 0,1
redis-cli -h 172.16.19.4 --p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5 --p 6379 cluster addslots 4

key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 5 取模,再根据各自的模数结果,就可以被映射到对应的实例 1 和实例 3 上了,这个过程就完成了数据分布的问题。(注意:手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作)

集群创建后,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端,客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:
(1)在集群中,实例有新增或删除,Redis 需要重新分配哈希槽
(2)为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍

实例之间可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致。Redis Cluster 方案提供了一种重定向机制,所谓的"重定向",就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据的话,会返回 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址,客户端重定向到新实例,同时更新本地对应关系的缓存。

redis 复制代码
GET hello:key
(error) MOVED 13320 172.16.19.5:6379

在实际应用时,如果正好赶上实例数据正在迁移,访问的 Slot 2 中的数据只有一部分迁移到了实例 3,还有部分数据没有迁移。这种情况下,客户端就会收到一条 ASK 报错信息:

redis 复制代码
GET hello:key
(error) ASK 13320 172.16.19.5:6379

这表明客户端请求的键值对所在的哈希槽 13320,在 172.16.19.5 这个实例上,但是这个哈希槽正在迁移,客户端需要先给 172.16.19.5 这个实例发送一个 ASKING 命令,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送 GET 命令,以读取数据。举个例子如图:

Slot 2 正在从实例 2 往实例 3 迁移,key1 和 key2 已经迁移过去,key3 和 key4 还在实例 2。客户端向实例 2 请求 key2 后,就会收到实例 2 返回的 ASK 命令。然后给实例 3 发送 ASKING 命令,才能读取 key2 的数据。注意,和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求,重复上述步骤。