Redis系列2:数据持久化提高可用性

2023-04-10 00:00:00 数据 执行 内存 日志 快照

1 介绍

从上一篇的 《深刻理解高性能Redis的本质》 中可以知道, 我们经常在数据库层上加一层缓存(如Redis),来保证数据的访问效率。
这样性能确实也有了大幅度的提升,但是本身Redis也是一层服务,也存在宕机、故障的可能性。
一旦服务挂起,可能生产的后果包括如下几方面:
1、Redis的数据是存在内存中的,所以一旦挂起,内存中的数据会全部丢失。
2、I/O从内存层级迁移到磁盘层级,性能极速下降。
3、原本访问缓存的请求会透过缓存层直接投向数据库,给数据库带来极大的压力,甚至导致雪崩。

所以,缓存层崩溃产生的后果是灾难的。为了避免宕机和宕机后的数据丢失, 为了保证数据的快速恢复,Redis提供了两个持久化数据的能力, AOF(Append Only FIle)日志 和 RDB 快照。

2 关于RDB 内存快照

大规模高并发的分布式场景,经常会遇到问题就是Redis挂起,导致访问失败,而所有的请求透过缓存层投向数据库,给数据库造成极大的压力。
而Redis的数据是存储在高速缓存中,即使我们重启并且恢复使用,缓存池依旧是空的,因为内存被释放了。
重新建立缓存的过程,对数据库也是一个暴击的过程,很可能会导致整个系统调用链的雪崩。参考我的这篇《架构与思维:一次缓存雪崩的灾难复盘》
我们知道,Redis 数据都是保存在内存中,能不能将内存中的数据进一步写到磁盘上,Redis 重启的时候就可以把磁盘上的数据快速恢复到内存中。这样,即使Redis宕机重启之后,依然能够正常的提供服务。
但是不能忽略一个问题,Redis和MySQL大的区别之一就是一个存储在内存,一个持久化在磁盘。但是如果每次数据的变化(新增、修改、删除缓存)都要写内存并同时写磁盘,这样成本太高,内存+磁盘,会让 Redis 性能大大降低。而且还要保证原子性操作,避免内存和磁盘的数据不一致。

2.1 使用内存快照

为了避免实时写入高频操作磁盘带来的负面效应。Redis提供了内存快照策略。
我们知道,Redis 在 执行写(增、删、改)指令过程中,内存中数据会持续的在变化。而内存快照,指的是 Redis 内存中的数据在某一刻的状态。就好比如是拍照一样,你把那一刻的数据都定格下来,持久化到磁盘上。打游戏的同学可以想象存盘。
快照文件我们称之为 RDB 文件,即 Redis DataBase 的缩写。
Redis 通过定时执行 RDB 内存快照,这样就不必每次执行写指令都存盘,只需要在执行内存快照的时候写磁盘。这样既保证Redis的高效读写,还实现了定时持久化,宕机后可快速恢复数据。


如上图,在做数据恢复时,直接将 RDB 文件读入内存完成恢复。

2.2 生成RDB策略

Redis 提供了两种模式来生成 RDB 文件:

  • save: 由主线程来执行,同步阻塞,只有等save完成后,才能进行新操作;
  • bgsave:执行后,会立刻返回OK,同时调用 glibc 的函数fork产生一个子进程用于写入 RDB 文件,快照持久化完全交给子进程来处理。主进程继续执行他自己的工作,非阻塞。

2.2.1 save模式

save模式是主进程执行,非常不建议使用主进程执行的方式,在 《深刻理解高性能Redis的本质》 中,
我们知道他的主操作都是在单线程模型上完成的。所以尽量避免 RDB 文件生成影响主线程的网络I/O和键值对读写。

2.2.2 bgsave模式

上面提到的另外一种方式,fork一个子进程来写RDB文件。
Redis 使用操作系统的多进程写时复制技术 COW(Copy On Write) 来实现快照持久化,这个很重要,具体可以了解下这篇《Copy On Write机制》,写的不错。
Redis 在持久化时会调用 glibc 的函数fork产生一个子进程,由这个子进程来处理快照持久化的动作,子进程可以共享主进程的所有内存数据,所以它读取到主进程的数据之后写入到 RDB 文件。而父进程继续处理客户端的写操作,不受影响。
在创建 RDB 文件时,程序会对数据库中的键进行检查,仅仅将未过期的键保存到新创建的 RDB 文件中。
当主进程执行写指令修改数据的时候,这个数据就会复制一份副本, bgsave 子进程读取这个副本数据写到 RDB 文件,所以主进程就可以直接修改原来的数据。


这既保证了快照的完整性,也允许主进程同时对数据进行修改,避免了对正常业务的影响。

2.2.3 避免过频全量照片

虽然说Redis 使用 bgsave 函数 fork 子进程在后台完成 内存中的数据做快照,没有影响父进程继续处理客户端的各种操作。
但是需注意一点,过于频繁的执行全量的数据快照,必然会导致严重的性能开销:

  • 频繁生成 RDB 文件写入磁盘,磁盘压力过大,效率降低。
  • fork 出来的 bgsave 子进程因为共享主线程的数据,一定程度上会阻塞主线程的运行,主线程的内存越大,阻塞时间越长。

2.3 总结

  • 快照的恢复速度快,但是生成 RDB 文件的频率需要把握一个度,频率过低快照间隔数据较大,丢失的数据就会比较多;频率太快,又会消耗额外开销,降低Redis性能。
  • RDB 建议采用二进制 + 数据压缩的方式写磁盘,文件体积小,数据恢复速度快。

3 AOF 日志

AOF 日志存储了 Redis 服务器的顺序指令序列,AOF 日志只记录对内存进行修改的指令记录。
假设 AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例顺序执行所有的指令。
也就是说,可以通过重放(replay),来建立 Redis 当前实例的内存数据结构。这种模式有没有很熟悉,有没有想到MySQL主从同步时候的relay log。

3.1 日志变更前后对比

AOF记录日志有两种模式,一种是预写式日志,也称写前日志(Write Ahead Log, WAL): 在实际写数据之前,将修改的数据写到日志文件中。
另外一种是写后日志: 先执行写操作,当数据存入内存后,再记录日志。
预写式日志类似 MySQL Innodb 引擎 中的 redo log,修改数据前先记录日志,再修改。

3.2 日志格式

Redis 接收到 set keyName someValue 命令的时候,会先将数据写到内存,Redis 会按照如下格式写入 AOF 文件。
*3:表示当前指令分为三个部分,每个部分都是 $ + 数字开头,后面是3部分的具体内容:指令、键、值。
数字:表示这部分的命令、键、值多占用的字节大小。比如 $3表示这部分包含 3 个字符,也就是 set 的长度。

推荐使用写后日志的模式,避免了额外的检查开销,不需要对执行的命令进行语法检查。如果使用写前日志的话,就需要先检查语法是否有误,否则日志记录了错误的命令,在使用日志恢复的时候就会出错。另外,写后才记录日志,不会阻塞当前的  指令执行。

# set keyName someValue
*3
$3
set
$7  #长度为7
keyName
$9 #长度为9
someValue

# 执行 mset key1 1 ,key2 2 ,key33 3
# aof日志如下:
*7  # 本批命令需要往下读7行非 $ 开始的命令
$4  #接着读取4个字节宽度,‘mset’长度为4,记为 $4
mset
$4  #接着读取4个字节宽度,‘key1’长度为4,记为 $4
key1
$1  #接着读取1个字节宽度,‘1’长度为1,记为 $1
1
$4
key2
$1
2
$5  #接着读取的字节宽度,‘$key33’长度为5,记为 $5
key33
$1
3

3.3 可能存在的问题

  • 可能存在丢失:比如Redis 刚执行完指令,还没记录日志宕机了,命令数据就丢了。
  • AOF 避免了当前命令的阻塞,但是AOF 日志是主线程执行,将日志写入磁盘过程中,如果磁盘压力大就会导致执行变慢,降低后续的操作。

3.4 写回策略

上面的问题,在Redis高频读写的时候是必然存在的,想要解决,在写入的时候做一层缓冲就可以了,避免直塞。这时候Redis提供了一种执行策略叫写回策略。

3.4.1 写回策略说明

为了提高日志文件的写入效率,写回策略会做如下变化:

  • 当你调用 write 函数将数据写入到文件时,这时候不是真正的落盘,而是将写入数据暂存在操作系统的内存缓冲区里。
  • 待到缓冲区的空间被填满、或者超过了指定的阈值时候,才真正地将缓冲区中的数据写入到磁盘里面。
    这种做法显然提高了效率,但也为写入数据带来了安全性问题,如果服务器发生了单机,那么保存在内存缓冲区里面的写入数据就会丢失。
    为此,系统提供了fsyncfdatasync两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性。
    Redis 提供的 AOF 配置项 appendfsync 写回策略直接决定 AOF 持久化功能的效率和安全性,以下是 appendfsync 的3个枚举:
  • always:同步写回,写指令执行完 即将缓冲区内容回写到 AOF 文件。
  • everysec:每秒写回,写指令执行完,日志写到 AOF 文件缓冲区,缓冲区每隔一秒再把内容同步到磁盘。
  • no: 操作系统控制,写执行执行完毕,把日志写到 AOF 文件内存缓冲区,由操作系统决定何时回写到磁盘。

写磁盘会带来性能上的损耗,所以写回的策略要根据实际情况做一个取舍,比如你是偏向性能还是可靠性。
always 同步写回可以做到数据不丢失,但是每次执行写指令都需要写入磁盘,性能差。
everysec 每秒写回,避免了同步写回的性能开销,但是如果服务发生宕机,会有大约1s时间周期的数据丢失,这种模式是在性能和可靠性之间做了妥协。
no 操作系统控制,执行写指令后就写入 AOF 文件缓冲,再执行后续的写磁盘指令,性能好,但有可能丢失更多的数据。

3.4.2 写回策略的选择

我们可以根据服务的实际情况来抉择策略,看是偏向高性能还是高可靠。

  • 高性能需求,选择 No 策略
  • 高可靠性保证,就选择 Always 策略
  • 如果能够接受数据存在少量丢失,又希望性能较好的话,就选择 Everysec 策略

4 混合RDF/AOF 方式模式

现实情况下,无论使用RDB或者AOF都差点意思。使用 rdb 来恢复内存状态,势必会丢失一部分数据。 使用 AOF 日志重放,重放对性能有一定的影响,而且在 Redis 实例很大的情况下,需要花费很长的时间。
Redis 4.0 解决了这个问题,才用了一个新的持久化模式——混合持久化,该 混合模式 默认是关闭状态的。
将 RDB 文件的内容和 rdb快照时间点之后的增量的 AOF 日志文件存在一起。这时候 AOF 日志不需要再是全量的日志,而是近一次快照时间点之后到当下发生的增量 AOF 日志,通常这部分 AOF 日志很小。
所以执行有如下顺序:

  • 查找rdb内容,如果存在先加载 rdb内容再 重放剩余的 aof。
  • 没有rdb内容,直接以aof格式重放整个文件。
    这样快照就不用频繁的执行,同时由于 AOF 只需要记录近一次快照之后的数据,不需要记录所有的操作,避免了出现单次重放文件过大的问题。

5 总结

  • RDB提供了快照模式,记录某个时间的Redis内存状态。RDB设计了 bgsave 和写时复制,尽可能避免执行快照期间对读写指令的影响,但是频繁快照会给磁盘带来压力以及 fork 阻塞主线程。需把握频率。
  • AOF 日志存储了 Redis 服务的顺序指令序列,通过重放(replay)指令来写入日志文件,并通过写回策略来避免高频读写给Redis带来压力。
  • RDB快照的照片时间间隔,必然会带来数据缺失,如果允许分钟级别的数据丢失,可以只使用 RDB。
  • 如果只用 AOF,写回策略优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。
  • 数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择。

相关文章