Redis 集群高可用和数据持久化
原文链接(请科学上网):https://medium.com/@bb8s/how-redis-cluster-achieves-high-availability-and-data-persistence-8cdc899764e8
对于 Redis
集群等有状态分布式系统而言,系统的高可靠性需要有两个内在要求:
- 持久性:即使集群中的某个实例发生故障也不会有数据丢失。
Redis
集群提供两种持久化机制:RDB(Redis Database)
时间点快照和AOF(Append Only File)
日志来保证数据的持久性; - 高可用性:即使出现网络故障、机器故障等,
Redis
依然可以对外提供服务。
首先让我们研究一下 Redis
集群如何实现数据持久化,这是在由每个 Redis
实例来保障的。
Redis 持久化
Redis
通常被广泛用于基于内存的 KV
缓存。如果某个 Redis
实例在生产环境中出现不可用,通常意味着该节点上的所有内存数据都会丢失。一种直接的解决方案是允许事故发生,并依靠底层数据库(如 MySQL
)继续提供数据,保证服务可用;虽然这种方法在理论上是可行的,但是在实际生产环境中却很少被使用。缓存的意外故障会导致数据库的流量突然变大,很容易使数据库过载。此外即使数据库可以处理突然的过高负载,但是数据库的数据请求延迟通常比缓存要高很多。对于大多数业务场景来说,这种延迟地增加几乎是不可接受的。在 Redis
缓存层的持久化特性,可以大大减少直接访问数据库出现的概率。
Redis
提供了两种持久化机制:
AOF
日志RDB
快照
什么是 AOF 日志
我们知道 MySQL
等关系型数据库通常使用预写日志(WAL
)来避免发生故障时的数据丢失。写操作数据首先追加到一个 WAL
,然后再写入内存缓冲区和磁盘。Redis
使用了不同的解决方案,在 Redis
中,写入操作首先将数据写入到内存中,只有写入到内存中成功时,该操作才会继续持久化到日志中,也称为后写日志 (WBL
);如果数据写入内存失败,Redis
将不会写入日志,直接向客户端返回错误。
使用 WBL
方案的优势是:
- 不需要校验写入数据从而产生额外的请求开销:来自客户端的写请求可能是无效的并且永远不会成功,因此需要进行请求校验或健全性检查。在
WBL
的情况下,写入内存操作已经完成数据校验,因此在写入日志时不需要额外的健全性检查。 - 不会阻塞当前的写入操作:可以配置
Redis
,在将数据写入到内存之后,就可以向客户端返回操作成功。
三种常用的 AOF
配置是:
AOF 配置 | 保存日志时机 | 优势 | 劣势 |
---|---|---|---|
always | 同步写入,每次请求同步写入日志 | 最大限度减少数据丢失,如果实例出现故障,最后一次写入操作可能会丢失 | 较高写入延迟,每次请求需要占用更多 CPU 周期,所以每个实例的吞吐量最低 |
everysecond | 异步写入,每秒写入一次 | 写入延迟、吞吐量等性能居中 | 可能会丢失最后一秒的数据变更 |
no | 异步写入,由本机操作系统决定将日志刷新到磁盘时间 | 三种配置当中性能最好 | 实例出现故障可能会导致更多数据丢失,依赖于操作系统配置 |
到目前为止感觉一切都很美好,但是假设将成百上千的写请求发送到 Redis
实例,会发生什么情况呢?如果 Redis
为每一次写操作保存一条日志记录,AOF
文件最终会变得非常大,从而导致新的问题:
- 文件系统通常对可以存储的单个文件大小有上限限制;
- 如果日志文件非常大,继续追加写入操作会随着文件大小的进一步增加而变得非常慢;
- 如果日志文件非常大,当
Redis
实例发生故障时,通过重放AOF
日志中的每一个操作(即每条记录)来恢复数据,这个恢复过程将会非常慢。
AOF 压缩(日志重写)
AOF
压缩将同一个 Key
(即未压缩日志中的多条记录)上的多个变更(也称为写入)合并为一个变更,并作为一条记录重写到新日志。每当我们发出 BGREWRITEAOF
命令时,Redis
会将内存中重建当前数据集所需的最短命令序列的日志内容写入文件。由于实际业务场景中 Key
的数量通常是有限的,并且增长速度小于写请求的数量,AOF
压缩或 AOF
重写有助于缓解日志文件的快速增长。
AOF
重写如何在后台工作?重新操作对 Redis
处理正常读/写请求性能指标会产生哪些影响?
原始未压缩的 AOF
日志通常由主进程写入,压缩操作由 BGREWRITEAOF
子进程完成,这样主进程永远不会被 AOF
重写操作所阻塞;也就是说,无论 AOF
重写是否正在执行,主进程都可以继续处理正常的请求流量——读/写 KV
记录——并不会受影响。
AOF
重写操作由两个步骤组成:
- 复制原日志文件,生成两个独立的日志文件;
- 保持两个日志文件同步。
文件拷贝
每次触发重写时,主进程都会通过系统调用 fork()
函数来派生一个 BGREWRITEAOF
子进程。本质上这一步是创建了一个父进程的复制进程——也包括内存中所有状态的副本,此内存副中本包含所有最近的数据变更内容。fork
之后子进程会进行日志压缩,本质上是对键值数据的每个 Key
进行合并操作,并将合并结果持久化到磁盘上的一个新文件(重写日志)中。
在 Linux fork() 函数实现中,子进程的内存“副本”不是直接复制父进程所有内存页。相反它将内存页标记为“写时复制”,也就是说子进程和父进程最初共享所有内存页,只有当父进程有新的数据写入后,才会将内存页复制到子进程地址空间的单独内存页中。查看更多详细信息。
同步两份日志
此时主进程没有被阻塞,而且还在正常处理读/写请求,所以未压缩的原始 AOF
日志在 fork
之后会不断变化,将新增的变更数据同步到重写日志的功能在不同的 Redis
版本有不同的实现。
对于 Redis < 7.0
的版本首先将变更操作(写入操作)保存在内存缓冲区中,并按照上述 AOF
配置的时间间隔保存到原始 AOF
日志,这时如果 Redis
集群出现故障需要恢复数据,则使用原始 AOF
日志通过重放所有数据变更来恢复数据。
fork
之后的变更数据也将写入 AOF
重写日志的缓冲区,也称为写时复制。当子进程重写完文件后,父进程会收到一个信号,将内存缓冲区中的记录持久化到 AOF
重写日志文件中。日志压缩完成后,将执行原子操作进行日志切换,原始 AOF
日志将被压缩后的 AOF
日志替换,然后被删除掉。
对于 Redis ≥ 7.0
,内存缓冲区被一个新的临时 AOF
文件取代,以记录从 fork
开始后的增量数据,这个增量文件和旧的基础文件组合到一起代表发生变更后的全部数据。
RDB 快照
当我们使用 AOF
恢复数据时,所有的变更都需要重放。如果有大量的写入操作,即使经过日志压缩,重放日志操作也会非常慢。Redis
提供了另外一种快速的数据恢复方式:RDB 快照。
RDB
快照就像拍照一样,它捕获在特定时间戳“冻结”的所有 KV
数据的状态,并将快照结果保存到磁盘。在这种情况下,当 Redis
集群发生故障时,可以通过将最近的快照文件加载回内存来恢复数据。
有几个细节需要注意:
- 进行快照操作时,数据还可以变更吗?换句话说,正常的
Redis
读/写操作是否被快照操作所阻塞? - 我们应该多久做一次快照?
Redis
为 RDB
快照提供了两个命令:save
和 bgsave
。
- save:由主进程进行快照操作,会阻塞数据读写;
- bgsave:创建一个专门用于生成
RDB
快照的子进程,避免阻塞主进程,这是Redis
集群中使用的默认配置。
一般来说,在生成快照时阻塞主进程并不是一个好主意。Redis
使用操作系统提供的 COW(copy-on-write)
机制来解决这个问题。简而言之,bgsave
子进程是从主进程派生出来的,当生成 bgsave
进程时,它开始将数据写入临时 RDB
快照文件,如果主进程需要读取共享内存中的数据,例如读取图中 Key
为 A
的键值数据,则不会发生冲突——因为快照只需要读取共享内存数据。但是当主进程需要修改数据时,例如插入/更新/删除 Key
为 B
的键值对,则修改后的内存页将被复制到子进程地址空间中的新内存页。本质上现在相同的数据有两个副本,bgsave
进程会访问新的副本生成 RDB
文件,而主进程仍然可以对旧副本中的键值对 B
进行变更。如果没有 COW
,两个进程将需要依靠锁来协调对共享内存空间的访问以确保数据一致性,这在高并发场景下带来显着的性能开销——而 Redis
通常会在高并发场景下使用。
保存 RDB
快照的触发条件可以在 redis.conf
文件中配置。例如:
- save 900 1:表示如果每
900
秒有至少1
个Key
发生变更,则触发生成快照; - save 300 10:表示如果每
300
秒有至少10
个Key
发生变更,则触发生成快照; - save 60 10000:表示如果每
60
秒有至少10000
个Key
发生变更,则触发生成快照。
Redis
是如何理解这些配置并根据配置执行 save
或 bgsave
命令呢?在底层,Redis
维护着这两个关于快照的状态:
- 脏计数器:脏计数器保存自上次成功执行
save
或bgsave
以来发生的变更(插入、更新、删除)次数; - last_save 时间戳:
last_save
记录最后一次成功执行save
或bgsave
的时间戳。
当 Redis
成功执行键值对变更后,脏计数器会加 1
,而 last_save
属性保持不变,存储的是最后一次创建快照的时间戳。Redis
内部还有一个 serverCron
函数,默认每 100
毫秒周期性执地行一次,该函数会迭代 saveparams
中的所有快照触发器配置,saveparams
是上述 redis.conf
文件配置规则在内存中的表示,只要满足一个条件就执行 bgsave
命令。
当 bgsave
命令执行成功时,脏计数器重置为 0
,并更新 last_save
时间戳。
从 RDB
快照中恢复数据通常比从 AOF
日志中恢复要更快,因为前者直接按原样存储键值对,而后者是所有变更操作的时间线,需要从日志起始重放。然而快照的执行频率同样需要权衡:如果执行频率过高,将会产生较大的资源开销;但是如果执行频率太低,那么当 Redis
发生故障时,尚未保存到快照的变更数据将会丢失。那有没有办法结合两个方案的优点呢?理想情况下,我们希望 RDB
快照提供快速数据恢复能力,同时最大限度地减少数据丢失和资源占用。
Redis 4.0
版本提出了 AOF
日志和 RDB
快照结合的方式。简单来说,RDB
快照以一定的频率执行,在两个快照之间,AOF
日志用于持久化所有增量数据变更。在这种场景下,执行快照的频率可以较低,避免主进程频繁 fork
操作带来的资源开销。另外,AOF
日志只是用来记录两个快照之间的变化,而不是记录从始到终的整个历史数据,日志文件不会过大,压缩/重写所占用的资源也相应减少。
一般来说,如果业务场景可以容忍一些数据丢失,那么我们可以只使用 RDB
快照来持久化,否则需要 AOF
日志占用更多的资源为代价来减少数据丢失。
Redis 集群高可用
主从复制
Redis
支持创建多个副本,每个分区可以有一个写副本(主节点)和多个读副本(从节点)。当一个新的从节点实例加入集群时,它会首先从主节点那里加载最新的 RDB
快照文件,然后开始消费复制缓冲区增量数据保持与主节点同步。
哨兵节点
Redis
集群也使用哨兵节点来保证高可用性。哨兵节点是特殊的 Redis
进程(是相对于存储键值数据的主节点和从节点,又名数据节点而言),哨兵节点有三个作用:
- 监控:监听数据节点的心跳,判断哪些节点健康,哪些不可用;
- 领导选举:当主节点挂掉或者没有响应时,负责选举出一个新的主节点;
- 通知:通知从节点与主节点保持同步。
监控
哨兵节点连接到所有 Redis
数据节点,并使用 PING
命令检查其与数据节点的连接。如果对某个数据节点 PING
失败,根据它是主节点还是从节点,要分两种情况处理。
如果它是一个从节点,不管从节点是否真的出现故障——也许这个节点只是暂时不可用,很快就能恢复——它仍然会被哨兵节点标记为死亡,并停止接收请求流量。
如果是主节点,我们在设计哨兵节点逻辑的时候就要考虑到可能出现的误报。主节点可能完全健康并正常提供服务,但只是它与哨兵的连接遇到问题——也许数据中心的网络出现了一些故障。如果触发了领导者故障转移,随后的领导者选举和通知将产生大量的计算和网络资源占用,进一步给网络带来压力,并可能触发雪崩故障。
那如何在主节点出现故障需要下线时避免出现误报呢?Redis
中使用共识协议。只有当所有哨兵节点中的大多数判断一个主节点不可用时,这个主节点才会被标记为死亡,并触发故障转移进行领导者选举过程。
领导选举
当旧的主节点出现故障时,将从其他从节点中选举出新的主节点。Redis
使用三条规则来确定应将哪个从节点提升为新的主节点:
- 首先,优先级最高的从节点将获得高分;
- 其次,与旧主节点数据最同步的从节点将获得高分;
- 最后,具有最小
instance_id
的从节点将获得高分。
配置属性 slave-priority
可用于为每个从节点设置用户定义的优先级。例如,假设我们有两个具有不同内存空间的从节点实例,我们可以为具有更多内存的实例设置高优先级,在领导者选举中,哨兵节点将首先给具有最高用户定义优先级的节点打分——因此拥有更大内存的节点将成为新的主节点。
在两个从节点的 slave-priority
配置相同的情况下,哨兵节点将使用第二条规则来确定新的主节点,原因是在这种情况下,新领导者将拥有最新的数据。众所周知,主从复制可能由于网络传输延迟而存在复制滞后性,在复制过程中,主节点使用 master_repl_offset
记录 repl_backlog_buffer
中最近一次写入的日志位置,从节点使用 slave_repl_offset
记录其最近写入的日志位置。
此外,每个 Redis
实例都有一个唯一的数字 instance_id
。当第三条规则适用时——即当所有从节点的 slave-priority
和同步日志位置都相同时,instance_id
最小的节点将被选为新的主节点。
分片
当数据量增加时,比如从 5G
增加到 25G
,我们就需要对 Redis
集群进行扩容,扩容有两种实现方式:
- 垂直扩容:我们可以使用配置更高CPU和内存的机器。但是高端机器很快就会变得过于昂贵;此外当数据量变大时,生成
RDB
快照和fork()
系统调用等操作将会很耗时,因为同一个实例处理了过多的数据; - 水平扩容:通过添加更多的一般规格的机器来扩充容量。
在分片集群中数据分布在所有实例中。Redis
使用哈希槽将键值记录映射到某个实例。在当前的 Redis
实现中,最多有 16384
个哈希槽。每个键值记录都散列到其中一个槽中。具体来说,
CRC16
哈希用于使用键值记录的Key
计算16
位哈希值;- 然后通过计算后的
16
位哈希值和16384
的模来计算[0, 16383]
范围内的哈希槽号。
简单来说,有两层映射:
- 一个键值记录首先映射到一个哈希槽,这种映射是确定性的,由计算节点完成。
- 然后将哈希槽映射到特定的
Redis
实例。此映射关系存储在所有Redis
实例上,并通过gossip
协议在所有实例之间保持同步。当我们向集群添加/删除实例时,或者当Redis
决定将哈希槽重新分配给实例以平衡每个实例上的负载时,这个映射关系是可以动态调整的,并且可能会发生变化。
Redis
提供了一种“重定向”机制来处理哈希槽重新分配时的客户端请求。简单来说,当客户端向一个 Redis
实例发送请求时,假设这个特定实例没有请求的数据记录,客户端将被重定向到另一个具有请求的数据记录的实例。
有两种类型的重定向,取决于包含请求的 Key
的哈希槽是否已完全迁移到另一个实例。
如果槽完全迁移到另一个实例,旧实例返回包含新实例IP:端口的 MOVED
重定向,客户端需要连接到新实例来获取键值数据。
1 | get key1 |
如果哈希槽仅完成部分迁移,则返回 ASK
重定向。在发出实际的读/写命令之前,客户端需要先向新实例发出 ASKING
命令。
1 | get key1 |
有关 Redis
分片的更多详细信息,请查看我的深入探索 Redis 集群:分片算法和架构这篇文章。