可靠的Redis,高效的持久化

Redis持久化——RDB和AOF

虽然名为缓存,但是我更认为Redis是基于内存的非关系型DB,Redis的持久化机制是我们对它“简单可靠”的形容中,“可靠“的重要组成部分,依靠这种机制,Redis最大程度地避免了内存数据库给人们带来的不安全的印象和风险

Redis依靠持久化机制,可以实现丢失的数据在秒级层面的可控,类似于redoLog的CrashSafe

那为何不用Redis彻底取代持久化DB如MySQL呢?

这是因为Redis的施展空间是受内存节制的,它的快,它的简单(单线程),都依托于内存风驰电掣的速度,但是由于内存的昂贵(时值2023,史称长鑫元年,内存颗粒的价格已经达到突破想象的新低,三星和东芝工厂再也不起火了),Redis肯定无法存储MySQL那样海量的数据,这是重要的原因。另外的,MySQL还有很多Redis无法替代的点,MySQL是根据严谨的数据库理论设计的,其事务性、数据的内聚性都是Redis很难替代的,但我相信随着内存价格的下探和Redis的进一步更新,它的施展空间会进一步加大

1.1 全量日志——RDB

如果要落盘,我们当然首先想到的是把键空间里的所有东西全盘保存,事实上Redis也提供了这样的方法,那就是RDB文件,其实也就叫RedisDataBase,它的文件结构如下图所示

可靠的Redis,高效的持久化_第1张图片

  • REDIS:固定的5个字节,通过这五个字符来标识这是一个Redis的RDB文件
  • db_version:4个字节,由字符串表示的整数,记录了RDB文件的版本号
  • databases:包含了非空数据库们的键值对数据,如果数据库都是空的,这个部分占用0字节
  • EOF:固定的休止符号,表明RDB文件已经加载完毕
  • check_sum:校验和,用于和载入后的databases文件计算出的校验和对比,借此确认数据库是否有损坏的状况

接下来我们再看看databases部分保存了些什么东西

可靠的Redis,高效的持久化_第2张图片

  • SELECTDB:固定字符,1个字节,注意,这不是一个8字符的字符串,Redis读到此处,它知道将要读一个新的数据库了
  • db_number:可以是1字节、2字节或者5字节的一个整数,用来标记数据库的编号
  • key_value_pairs:键值对数据,总的来讲就是序列化后的数据,不同的数据结构序列化的方式略有差异,此事先按下不表

对于RDB文件,Redis提供了三个相关的方法

redis>SAVE
redis>BGSAVE
redis>SAVE n seconds

如果我们对Redis服务端发送一个Save指令,那么Redis会立刻开始遍历键空间,存储到RDB文件中,并将此文件存储到磁盘上,这其中有两个点对我们的主线程有致命威胁:

  • 如果我们的键空间十分的大,那么主线程有阻塞风险
  • 和上一条类似,如果我们的键空间很大,RDB文件就也一定是非常大的,这样的文件无论是落盘还是网络传输出去,都是非常笨重的操作

如此笨重的操作着实不应该交给Redis主线程来完成,但是Redis又是一个单线程的应用,如何能保证不阻塞呢?答案是fork一个redis子进程,并把父进程的页表复制给子进程,让子进程全权完成此持久化

但这也并非完全没有风险,fork()本身是一个创建进程的系统级调用,非常重量级,父进程的页表如果项目繁多,fork()函数的调用也完全可能阻塞主线程,这种情况值得我们警惕,但它不会经常发生,因为页表之于整块内存,就是目录之于一本厚书。子进程在此期间遍历这个页表来生成RDB文件(实际上它接收到父进程的信号,执行save操作,所以读者朋友您或许能明白,为何要保留save这个看起来我们完全不会使用的命令,此处是原因之一),如果此时父进程对某块内存区域发生了修改,那么就会发生写时复制(Copy On Write),父进程会将需要修改的部分复制一份用作自己的修改,等待子进程发来生成完毕的信号,将会将此复制出的内存块并入它原来的部分,故这些部分在RDB生成的期间,实际上是丢失了。

记住这个创建子进程的操作,在介绍AOF的时候它仍然会出场。

最后还有一个命令比较显眼,就是SAVE m n这个命令,虽然也是SAVE,但它不会直接触发RDB持久化,而是类似于给Redis派发一个定时任务:当数据库在m秒内执行至少了n次修改时,触发RDB持久化。如:

save 900 1
save 300 10
save 60 10000
//以上是redis的默认的三个配置

实现这个功能依赖两个计数器:

  • dirty:每当有对象元素被修改,dirty就会增加1
  • lastsave:UNIX时间戳,记录的是上次进行RDB持久化的时间

当执行了Save m n命令后,这个参数会以{seconds=m;changes=n}的形式保存在RedisServer对象中的saveparams中,并在每次Redis的主函数serverCron执行时遍历saveParams,检查通过dirty和lastsave检查是否有同时满足两项条件的情况发生,如果发生,那么就执行RDB,伪代码如下

def serverCron():
    for saveparam in server.saveparams
   //计算上次保存过了多少秒
     save_interval=unixtime_now()-server.lastsave
        if server.dirty>=savaparam.changes and save_interval > saveparam.seconds
            BGSAVE();

2 追加日志——AOF

2.1 落盘时机

上文我们得知了RDB的种种好处,最重要的就是加载非常快,并且得益于子进程对于多线程的利用,对于主线程造成的影响也能降低到最小,但fork()这种创建线程的重量级特性也导致了即便我们采用BGSAVE,此操作也不能被经常执行,生产中6小时进行一次BGSAVE是比较合适的。

6小时才保存一次?那岂不是中间的数据都丢失了?

其实不完全是这样,Redis在真实的环境中都是集群部署的,RDB文件会通过网络传送给从节点从而实现备份,但这样的操作仍然不经常发生,一是一个主节点往往有多个从节点,对于100mbps的网络而言,发送6GB的RDB文件需要1分钟,多个节点对于网络吞吐量的挤占会非常严重,所以这是一种比6小时快,但也是不可多为的一种行为。

Append-Only File(仅追加文件,AOF)则是Redis在持久化方面另一个策略,如同它的名字,它仅仅对追加的、对数据库写的操作记录成文件,这种特性使得它一是开销非常小,二是可以根据不同策略实现秒级别的CrashSafe

我们先来看一下AOF文件的格式

SET KEY VALUE
*3\r\n$3\r\nSET$3\r\nKEY\r\n$5\r\nVALUE\r\n

可以看出,AOF文件实际就是一种命令日志,它会把执行完的写命令按照字节级别记录在文件中,这就导致AOF文件的体积实际上会非常的大,因为在记录多余的命令语句的同时,一个key还会被多次写入,导致成倍的AOF文件体积膨胀,这其实也就是为何RDB文件虽然是记录整个键空间,但是我们说它小且快的原因了,因为相对AOF来说RDB记录的是构建出此数据库所有必须的信息

在写入命令执行完之后,并不是立刻写入到AOF文件中的,而是先保存到redisServer中的AOF缓冲区中,因为AOF文件是需要磁盘交互的,这对提高主线程并发性能很不利

struct redisServer{
  //....

    //AOF缓冲区,是一个sds,新命令执行完则对sds追加
  sds aof_buf;

  //....
}

而交由AOF缓冲区暂存后,在主循环每次结束时,都会调用flushAppendOnlyFile来考虑aof_buf的内容如何处理,针对我们对AOF持久化设置的三种策略,有三种不同的效果,在

  • Always策略:将aof_buf全部内容直接fsync,fsync成功后返回
  • everysec策略:将aof_buf全部内容写入到AOF文件,如果上次同步的AOF文件时间距离现在超过1秒钟,那么将调用系统write操作,write返回后flushAppendOnlyFile返回,并由内核调用专门线程对AOF文件fsync,这个调用每秒一次
  • no策略:将aof_buf全部内容写入到内核缓冲区,何时落盘听从操作系统调度,此操作可以防止redis崩溃的丢失,但一旦断电,内核缓冲区中的数据将会 丢失

对其中的fsync和write,专门解释一下:

  • write:linux系统调用write会触发delayed write机制,也就是延迟写入,简单来讲就是此通过内核缓冲区来提高磁盘IO性能。write调用会在文件写入缓冲区时返回,而是否落盘则依赖于系统的调度
  • fsync:针对单个文件的阻塞式强制同步,直到落盘成功才会返回

2.2 重写机制

AOF文件的体系是会随着写命令的增多不断膨胀的,不仅会消耗Redis的内存,还会影响通过AOF载入的速度,在这个过程中,Redis会遍历那些脏键取得他们的最终值并生成AOF文件,这会极大的节约AOF文件的体积,重写机制我们可以手动和自动两种方式触发

  • 手动触发:直接调用BGREWRITEAOF
  • 自动触发:

    • auto-aof-rewrite-min-size:重写AOF文件时其最小体积
    • auto-aof-rewrite-percentage:重写AOF文件时其和上一次重写时AOF文件体积的比值
    • 满足以上两个条件时,将会调用BGREWRITEAOF

下图是AOF重写时的主要流程:

可靠的Redis,高效的持久化_第3张图片

按步骤解读:

  1. 执行AOF重写请求

    如果当前线程正在执行AOF重写,则拒绝并返回如下错误

    ERR Background append only file rewriting in progress

    如果当前进程正在执行BGSAVE操作重写命令将会被延迟

    Background append only file rewriting scheduled
  2. 父进程执行fork创建子进程,开销和BGSAVE类似
  3. 缓冲区变动

    1. 主进程fork完后继续响应命令,继续AOF追加,继续落盘,好像无事发生过
    2. 由于子进程只能通过写时复制共享fork()时间点的内存数据,而由于父进程仍然响应命令,这部分的命令采用aof_rewrite_buf保存
  4. 子进程根据内存快照,也就是遍历键空间,按照命令合并规则生成新的AOF文件,每次批量写入硬盘的数据由配置aof-rewrite-incremental-fsync决定,默认为32MB,防止一次性刷盘过多引起阻塞
  5. 收尾

    1. 新的AOF文件落盘完成,子进程发送信号给父进程,父进程更新和AOF相关的状态统计信息,具体是info persistence下的aof_*
    2. 父进程把aof_rewrite_buf中的内容同步到新的AOF文件
    3. 用新文件替换老的AOF文件,重写完成
如果我在BGSAVE时,接收到了BGREWRITEAOF/SAVE指令呢?
如果我在BGREWRITEAOF时,接收到了BGSAVE指令呢?
  • 在BGSAVE时收到BGREWRITEAOF时:会被延迟到子进程发来完毕信号时再执行,因为AOF文件并非真的重新梳理AOF文件,而是直接遍历键空间,说到这里,我知道你非常想问,为什么不直接把刚SAVE完的RDB文件转化成AOF文件从而变成一个小优化?一是RDB文件格式和AOF完全不同,RDB的书写方式完全是为了加载快速;二是其实这种操作能发挥作用的情况非常有限:RDB发生的频次并不高,AOF重写能赶上这种好时候的机会不大;三就得问作者了
  • BGSAVE时收到了SAVE指令:直接拒绝
  • BGREWRITEAOF时收到了BGSAVE指令:直接拒绝。这里有读者会感到很奇怪,同样是遍历键空间,为何先后顺序反过来就直接给我拒绝了?这也是一种重要性等级的考虑,二者都是重量级操作,且目的其实是趋于一致的,AOF的丢失控制已经到了秒级别,并且重写后的AOF已经拥有充分的空间去记录数据库的变化,此时又再来一个重量级的BGSAVE,显得没有必要,如果反过来,BGSAVE时请求重写AOF的话,由于AOF重写是属于如果不解决,就会造成AOF缓冲区进一步的膨胀,而AOF缓冲区说白了就是一个SDS,在前面的章节我们知道,在SDS膨胀超过1MB时,会仅仅会预留1MB作为,预留地,这就导致会请求会不断的冲击这个边界,导致数组迁移而对主线程产生压力,对内存空间也是极大的消耗。一般的Redis实例OPS可以达到5万,也就是0.2ms一个请求,一旦我们我们连续几次都拒绝了重写请求,将会导致连续多次的SDS迁移,对OPS的打击是毁灭性的。故这种请求无法拒绝,因为会导致更重量级的补救发生,所以这是必须要解决的,故只能延后。

2.3 混合持久化节点重启流程

开发中常用的方式是RDB和AOF一起作用于数据恢复,流程如下

可靠的Redis,高效的持久化_第4张图片

  1. AOF开启且存在AOF文件,优先加载AOF文件,日志如下

    *DB loaded from append only file :1.652 seconds
  2. AOF关闭或者不存在AOF文件时,加载RDB,日志如下

    *DB loaded from disk:5.586 seconds
  3. 加载文件成功,Redis成功启动
  4. AOF/RDB文件存在错误时,启动失败并返回错误信息

思考:子进程与阻塞点

RDB的BGSAVE子进程

AOF的重写子进程

AOF落盘时的落盘线程

AOF的载入时伪客户端

你可能感兴趣的:(可靠的Redis,高效的持久化)