Redis专题:万字长文详解持久化原理

2021/07/21

[转载]Redis 专题:万字长文详解持久化原理

转载信息

本文将从以下几个方面介绍 Redis 持久化机制:

37ccc9cbc60a0f27fd248d614896ab98.jpg

写在前面

本文从整体上详细介绍 Redis 的两种持久化方式,包含工作原理、持久化流程及实践策略,以及背后的一些理论知识。上一篇文章仅介绍了 RDB 持久化,但是 Redis 持久化是一个整体,单独介绍不成体系,故重新整理。

Redis 是一个内存数据库,所有的数据将保存在内存中,这与传统的 MySQL、Oracle、SqlServer 等关系型数据库直接把数据保存到硬盘相比,Redis 的读写效率非常高。但是保存在内存中也有一个很大的缺陷,一旦断电或者宕机,内存数据库中的内容将会全部丢失。为了弥补这一缺陷,Redis 提供了把内存数据持久化到硬盘文件,以及通过备份文件来恢复数据的功能,即 Redis 持久化机制。

Redis 支持两种方式的持久化:RDB 快照和 AOF。

RDB 持久化

RDB 快照用官方的话来说:RDB 持久化方案是按照指定时间间隔对你的数据集生成的时间点快照(point-to-time snapshot)。它以紧缩的二进制文件保存 Redis 数据库某一时刻所有数据对象的内存快照,可用于 Redis 的数据备份、转移与恢复。到目前为止,仍是官方的默认支持方案。

RDB 工作原理

既然说 RDB 是 Redis 中数据集的时间点快照,那我们先简单了解一下 Redis 内的数据对象在内存中是如何存储与组织的。

默认情况下,Redis 中有 16 个数据库,编号从 0-15,每个 Redis 数据库使用一个 redisDb 对象来表示,redisDb 使用 hashtable 存储 K-V 对象。为方便理解,我以其中一个 db 为例绘制 Redis 内部数据的存储结构示意图。

4df468b0ebb0aa56f57f7031263e4a65.png

时间点快照也就是某一时刻 Redis 内每个 DB 中每个数据对象的状态,先假设在这一时刻所有的数据对象不再改变,我们就可以按照上图中的数据结构关系,把这些数据对象依次读取出来并写入到文件中,以此实现 Redis 的持久化。然后,当 Redis 重启时按照规则读取这个文件中的内容,再写入到 Redis 内存即可恢复至持久化时的状态。

当然,这个前提时我们上面的假设成立,否则面对一个时刻变化的数据集,我们无从下手。我们知道 Redis 中客户端命令处理是单线程模型,如果把持久化作为一个命令处理,那数据集肯定时处于静止状态。另外,操作系统提供的 fork()函数创建的子进程可获得与父进程一致的内存数据,相当于获取了内存数据副本;fork 完成后,父进程该干嘛干嘛,持久化状态的工作交给子进程就行了。

很显然,第一种情况不可取,持久化备份会导致短时间内 Redis 服务不可用,这对于高 HA 的系统来讲是无法容忍的。所以,第二种方式是 RDB 持久化的主要实践方式。由于 fork 子进程后,父进程数据一直在变化,子进程并不与父进程同步,RDB 持久化必然无法保证实时性;RDB 持久化完成后发生断电或宕机,会导致部分数据丢失;备份频率决定了丢失数据量的大小,提高备份频率,意味着 fork 过程消耗较多的 CPU 资源,也会导致较大的磁盘 I/O。

持久化流程

在 Redis 内完成 RDB 持久化的方法有 rdbSave 和 rdbSaveBackground 两个函数方法(源码文件 rdb.c 中),先简单说下两者差别:

  • rdbSave:是同步执行的,方法调用后就会立刻启动持久化流程。由于 Redis 是单线程模型,持久化过程中会阻塞,Redis 无法对外提供服务;
  • rdbSaveBackground:是后台(异步)执行的,该方法会 fork 出子进程,真正的持久化过程是在子进程中执行的(调用 rdbSave),主进程会继续提供服务;

RDB 持久化的触发必然离不开以上两个方法,触发的方式分为手动和自动。手动触发容易理解,是指我们通过 Redis 客户端人为的对 Redis 服务端发起持久化备份指令,然后 Redis 服务端开始执行持久化流程,这里的指令有 save 和 bgsave。自动触发是 Redis 根据自身运行要求,在满足预设条件时自动触发的持久化流程,自动触发的场景有如下几个(摘自这篇文章):

  • serverCron 中 save m n 配置规则自动触发;
  • 从节点全量复制时,主节点发送 rdb 文件给从节点完成复制操作,主节点会出发 bgsave;
  • 执行 debug reload 命令重新加载 redis 时;
  • 默认情况下(未开启 AOF)执行 shutdown 命令时,自动执行 bgsave;

结合源码及参考文章,我整理了 RDB 持久化流程来帮助大家有个整体的了解,然后再从一些细节进行说明。

11b6e27c0bc8f8f0eff65312f51cf39e.png

从上图可以知道:

  • 自动触发的 RDB 持久化是通过 rdbSaveBackground 以子进程方式执行的持久化策略;
  • 手动触发是以客户端命令方式触发的,包含 save 和 bgsave 两个命令,其中 save 命令是在 Redis 的命令处理线程以阻塞的方式调用 rdbSave 方法完成的。

自动触发流程是一个完整的链路,涵盖了 rdbSaveBackground、rdbSave 等,接下来我以 serverCron 为例分析一下整个流程。

save 规则及检查

serverCron 是 Redis 内的一个周期性函数,每隔 100 毫秒执行一次,它的其中一项工作就是:根据配置文件中 save 规则来判断当前需要进行自动持久化流程,如果满足条件则尝试开始持久化。了解一下这部分的实现。

在 redisServer 中有几个与 RDB 持久化有关的字段,我从代码中摘出来,中英文对照着看下:

struct redisServer {
    /* 省略其他字段 */ 
    /* RDB persistence */
    long long dirty;                /* Changes to DB from the last save
                                     * 上次持久化后修改 key 的次数 */
    struct saveparam *saveparams;   /* Save points array for RDB,
                                     * 对应配置文件多个 save 参数 */
    int saveparamslen;              /* Number of saving points,
                                     * save 参数的数量 */
    time_t lastsave;                /* Unix time of last successful save 
                                     * 上次持久化时间*/
    /* 省略其他字段 */
}

/* 对应 redis.conf 中的 save 参数 */
struct saveparam {
    time_t seconds;                    /* 统计时间范围 */   
    int changes;                    /* 数据修改次数 */
};

saveparams 对应 redis.conf 下的 save 规则,save 参数是 Redis 触发自动备份的触发策略,seconds 为统计时间(单位:秒), changes 为在统计时间内发生写入的次数。save m n 的意思是:m 秒内有 n 条写入就触发一次快照,即备份一次。save 参数可以配置多组,满足在不同条件的备份要求。如果需要关闭 RDB 的自动备份策略,可以使用 save ““。以下为几种配置的说明:

# 表示 900 秒(15 分钟)内至少有 1 个 key 的值发生变化,则执行
save 900 1
# 表示 300 秒(5 分钟)内至少有 1 个 key 的值发生变化,则执行
save 300 10
# 表示 60 秒(1 分钟)内至少有 10000 个 key 的值发生变化,则执行
save 60 10000
# 该配置将会关闭 RDB 方式的持久化
save ""

serverCron 对 RDB save 规则的检测代码如下所示:

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    /* 省略其他逻辑 */
    
    /* 如果用户请求进行 AOF 文件重写时,Redis 正在执行 RDB 持久化,Redis 会安排在 RDB 持久化完成后执行 AOF 文件重写,
     * 如果 aof_rewrite_scheduled 为 true,说明需要执行用户的请求 */
    /* Check if a background saving or AOF rewrite in progress terminated. */
    if (hasActiveChildProcess() || ldbPendingChildren())
    {
        run_with_period(1000) receiveChildInfo();
        checkChildrenDone();
    } else {
        /* 后台无 saving/rewrite 子进程才会进行,逐个检查每个 save 规则*/
        for (j = 0; j < server.saveparamslen; j++) {
            struct saveparam *sp = server.saveparams+j;
            
            /* 检查规则有几个:满足修改次数,满足统计周期,达到重试时间间隔或者上次持久化完成*/
            if (server.dirty >= sp->changes 
                && server.unixtime-server.lastsave > sp->seconds 
                &&(server.unixtime-server.lastbgsave_try > CONFIG_BGSAVE_RETRY_DELAY || server.lastbgsave_status == C_OK))
            {
                serverLog(LL_NOTICE,"%d changes in %d seconds. Saving...", sp->changes, (int)sp->seconds);
                rdbSaveInfo rsi, *rsiptr;
                rsiptr = rdbPopulateSaveInfo(&rsi);
                /* 执行 bgsave 过程 */
                rdbSaveBackground(server.rdb_filename,rsiptr);
                break;
            }
        }

        /* 省略:Trigger an AOF rewrite if needed. */
    }
    /* 省略其他逻辑 */
}

如果没有后台的 RDB 持久化或 AOF 重写进程,serverCron 会根据以上配置及状态判断是否需要执行持久化操作,判断依据就是看 lastsave、dirty 是否满足 saveparams 数组中的其中一个条件。如果有一个条件匹配,则调用 rdbSaveBackground 方法,执行异步持久化流程。

rdbSaveBackground

rdbSaveBackground 是 RDB 持久化的辅助性方法,主要工作是 fork 子进程,然后根据调用方(父进程或者子进程)不同,有两种不同的执行逻辑。

  • 如果调用方是父进程,则 fork 出子进程,保存子进程信息后直接返回。
  • 如果调用方是子进程则调用 rdbSave 执行 RDB 持久化逻辑,持久化完成后退出子进程。
int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
    pid_t childpid;

    if (hasActiveChildProcess()) return C_ERR;

    server.dirty_before_bgsave = server.dirty;
    server.lastbgsave_try = time(NULL);

    // fork 子进程
    if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
        int retval;

        /* Child 子进程:修改进程标题 */
        redisSetProcTitle("redis-rdb-bgsave");
        redisSetCpuAffinity(server.bgsave_cpulist);
        // 执行 rdb 持久化
        retval = rdbSave(filename,rsi);
        if (retval == C_OK) {
            sendChildCOWInfo(CHILD_TYPE_RDB, 1, "RDB");
        }
        // 持久化完成后,退出子进程
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
        /* Parent 父进程:记录 fork 子进程的时间等信息*/
        if (childpid == -1) {
            server.lastbgsave_status = C_ERR;
            serverLog(LL_WARNING,"Can't save in background: fork: %s",
                strerror(errno));
            return C_ERR;
        }
        serverLog(LL_NOTICE,"Background saving started by pid %ld",(long) childpid);
        // 记录子进程开始的时间、类型等。
        server.rdb_save_time_start = time(NULL);
        server.rdb_child_type = RDB_CHILD_TYPE_DISK;
        return C_OK;
    }
    return C_OK; /* unreached */
}

rdbSave 是真正执行持久化的方法,它在执行时存在大量的 I/O、计算操作,耗时、CPU 占用较大,在 Redis 的单线程模型中持久化过程会持续占用线程资源,进而导致 Redis 无法提供其他服务。为了解决这一问题 Redis 在 rdbSaveBackground 中 fork 出子进程,由子进程完成持久化工作,避免了占用父进程过多的资源。

需要注意的是,如果父进程内存占用过大,fork 过程会比较耗时,在这个过程中父进程无法对外提供服务;另外,需要综合考虑计算机内存使用量,fork 子进程后会占用双倍的内存资源,需要确保内存够用。通过 info stats 命令查看 latest_fork_usec 选项,可以获取最近一个 fork 以操作的耗时。

rdbSave

Redis 的 rdbSave 函数是真正进行 RDB 持久化的函数,流程、细节贼多,整体流程可以总结为:创建并打开临时文件、Redis 内存数据写入临时文件、临时文件写入磁盘、临时文件重命名为正式 RDB 文件、更新持久化状态信息(dirty、lastsave)。其中“Redis 内存数据写入临时文件”最为核心和复杂,写入过程直接体现了 RDB 文件的文件格式,本着一图胜千言的理念,我按照源码流程绘制了下图。

2854f66726cceb547f931bcb10ef3e8e.png

补充说明一下,上图右下角“遍历当前数据库的键值对并写入”这个环节会根据不同类型的 Redis 数据类型及底层数据结构采用不同的格式写入到 RDB 文件中,不再展开了。我觉得大家对整个过程有个直观的理解就好,这对于我们理解 Redis 内部的运作机制大有裨益。

AOF 持久化

上一节我们知道 RDB 是一种时间点(point-to-time)快照,适合数据备份及灾难恢复,由于工作原理的“先天性缺陷”无法保证实时性持久化,这对于缓存丢失零容忍的系统来说是个硬伤,于是就有了 AOF。

AOF 工作原理

AOF 是 Append Only File 的缩写,它是 Redis 的完全持久化策略,从 1.1 版本开始支持;这里的 file 存储的是引起 Redis 数据修改的命令集合(比如:set/hset/del 等),这些集合按照 Redis Server 的处理顺序追加到文件中。当重启 Redis 时,Redis 就可以从头读取 AOF 中的指令并重放,进而恢复关闭前的数据状态。

AOF 持久化默认是关闭的,修改 redis.conf 以下信息并重启,即可开启 AOF 持久化功能。

# no-关闭,yes-开启,默认 no
appendonly yes
appendfilename appendonly.aof

AOF 本质是为了持久化,持久化对象是 Redis 内每一个 key 的状态,持久化的目的是为了在 Reids 发生故障重启后能够恢复至重启前或故障前的状态。相比于 RDB,AOF 采取的策略是按照执行顺序持久化每一条能够引起 Redis 中对象状态变更的命令,命令是有序的、有选择的。把 aof 文件转移至任何一台 Redis Server,从头到尾按序重放这些命令即可恢复如初。举个例子:

首先执行指令 set number 0,然后随机调用 incr number、get number 各 5 次,最后再执行一次 get number ,我们得到的结果肯定是 5。

因为在这个过程中,能够引起 number 状态变更的只有 set/incr 类型的指令,并且它们执行的先后顺序是已知的,无论执行多少次 get 都不会影响 number 的状态。所以,保留所有 set/incr 命令并持久化至 aof 文件即可。按照 aof 的设计原理,aof 文件中的内容应该是这样的(这里是假设,实际为 RESP 协议):

set number 0
incr number
incr number
incr number
incr number
incr number

最本质的原理用“命令重放”四个字就可以概括。但是,考虑实际生产环境的复杂性及操作系统等方面的限制,Redis 所要考虑的工作要比这个例子复杂的多:

  • Redis Server 启动后,aof 文件一直在追加命令,文件会越来越大。文件越大,Redis 重启后恢复耗时越久;文件太大,转移工作就越难;不加管理,可能撑爆硬盘。很显然,需要在合适的时机对文件进行精简。例子中的 5 条 incr 指令很明显的可以替换为为一条 set 命令,存在很大的压缩空间。
  • 众所周知,文件 I/O 是操作系统性能的短板,为了提高效率,文件系统设计了一套复杂的缓存机制,Redis 操作命令的追加操作只是把数据写入了缓冲区(aof_buf),从缓冲区到写入物理文件在性能与安全之间权衡会有不同的选择。
  • 文件压缩即意味着重写,重写时即可依据已有的 aof 文件做命令整合,也可以先根据当前 Redis 内数据的状态做快照,再把存储快照过程中的新增的命令做追加。
  • aof 备份后的文件是为了恢复数据,结合 aof 文件的格式、完整性等因素,Redis 也要设计一套完整的方案做支持。

持久化流程

从流程上来看,AOF 的工作原理可以概括为几个步骤:命令追加(append)、文件写入与同步(fsync)、文件重写(rewrite)、重启加载(load),接下来依次了解每个步骤的细节及背后的设计哲学。

06c0ff2b9acde15a536b252abc66d229.png

命令追加

当 AOF 持久化功能处于打开状态时,Redis 在执行完一个写命令之后,会以协议格式(也就是 RESP,即 Redis 客户端和服务器交互的通信协议 )把被执行的写命令追加到 Redis 服务端维护的 AOF 缓冲区末尾。对 AOF 文件只有单线程的追加操作,没有 seek 等复杂的操作,即使断电或宕机也不存在文件损坏风险。另外,使用文本协议好处多多:

  • 文本协议有很好的兼容性;
  • 文本协议就是客户端的请求命令,不需要二次处理,节省了存储及加载时的处理开销;
  • 文本协议具有可读性,方便查看、修改等处理。

AOF 缓冲区类型为 Redis 自主设计的数据结构 sds,Redis 会根据命令的类型采用不同的方法(catAppendOnlyGenericCommand、catAppendOnlyExpireAtCommand 等)对命令内容进行处理,最后写入缓冲区。

需要注意的是:如果命令追加时正在进行 AOF 重写,这些命令还会追加到重写缓冲区(aof_rewrite_buffer)。

文件写入与同步

AOF 文件的写入与同步离不开操作系统的支持,开始介绍之前,我们需要补充一下 Linux I/O 缓冲区相关知识。硬盘 I/O 性能较差,文件读写速度远远比不上 CPU 的处理速度,如果每次文件写入都等待数据写入硬盘,会整体拉低操作系统的性能。为了解决这个问题,操作系统提供了延迟写(delayed write)机制来提高硬盘的 I/O 性能。

da82a1a246415ab49c695634c73f9936.png

传统的 UNIX 实现在内核中设有缓冲区高速缓存或页面高速缓存,大多数磁盘 I/O 都通过缓冲进行。 当将数据写入文件时,内核通常先将该数据复制到其中一个缓冲区中,如果该缓冲区尚未写满,则并不将其排入输出队列,而是等待其写满或者当内核需要重用该缓冲区以便存放其他磁盘块数据时, 再将该缓冲排入到输出队列,然后待其到达队首时,才进行实际的 I/O 操作。这种输出方式就被称为延迟写。

延迟写减少了磁盘读写次数,但是却降低了文件内容的更新速度,使得欲写到文件中的数据在一段时间内并没有写到磁盘上。当系统发生故障时,这种延迟可能造成文件更新内容的丢失。为了保证磁盘上实际文件系统与缓冲区高速缓存中内容的一致性,UNIX 系统提供了 sync、fsync 和 fdatasync 三个函数为强制写入硬盘提供支持。

Redis 每次事件轮训结束前(beforeSleep)都会调用函数 flushAppendOnlyFile,flushAppendOnlyFile 会把 AOF 缓冲区(aof_buf)中的数据写入内核缓冲区,并且根据 appendfsync 配置来决定采用何种策略把内核缓冲区中的数据写入磁盘,即调用 fsync()。该配置有三个可选项 always、no、everysec,具体说明如下:

  • always:每次都调用 fsync(),是安全性最高、性能最差的一种策略。
  • no:不会调用 fsync()。性能最好,安全性最差。
  • everysec:仅在满足同步条件时调用 fsync()。这是官方建议的同步策略,也是默认配置,做到兼顾性能和数据安全性,理论上只有在系统突然宕机的情况下丢失 1 秒的数据。

注意:上面介绍的策略受配置项 no-appendfsync-on-rewrite 的影响,它的作用是告知 Redis:AOF 文件重写期间是否禁止调用 fsync(),默认是 no。

如果 appendfsync 设置为 always 或 everysec,后台正在进行的 BGSAVE 或者 BGREWRITEAOF 消耗过多的磁盘 I/O,在某些 Linux 系统配置下,Redis 对 fsync()的调用可能阻塞很长时间。然而这个问题还没有修复,因为即使是在不同的线程中执行 fsync(),同步写入操作也会被阻塞。

为了缓解此问题,可以使用该选项,以防止在进行 BGSAVE 或 BGREWRITEAOF 时在主进程中调用 fsync()。

  • 设置为 yes 意味着,如果子进程正在进行 BGSAVE 或 BGREWRITEAOF,AOF 的持久化能力就与 appendfsync 设置为 no 有着相同的效果。最糟糕的情况下,这可能会导致 30 秒的缓存数据丢失。
  • 如果你的系统有上面描述的延迟问题,就把这个选项设置为 yes,否则保持为 no。

文件重写

如前面提到的,Redis 长时间运行,命令不断写入 AOF,文件会越来越大,不加控制可能影响宿主机的安全。

为了解决 AOF 文件体积问题,Redis 引入了 AOF 文件重写功能,它会根据 Redis 内数据对象的最新状态生成新的 AOF 文件,新旧文件对应的数据状态一致,但是新文件会具有较小的体积。重写既减少了 AOF 文件对磁盘空间的占用,又可以提高 Redis 重启时数据恢复的速度。还是下面这个例子,旧文件中的 6 条命令等同于新文件中的 1 条命令,压缩效果显而易见。

0ccb0d6d5d5499c1a3115017b499949f.png

我们说,AOF 文件太大时会触发 AOF 文件重写,那到底是多大呢?有哪些情况会触发重写操作呢?

与 RDB 方式一样,AOF 文件重写既可以手动触发,也会自动触发。手动触发直接调用 bgrewriteaof 命令,如果当时无子进程执行会立刻执行,否则安排在子进程结束后执行。自动触发由 Redis 的周期性方法 serverCron 检查在满足一定条件时触发。先了解两个配置项:

  • auto-aof-rewrite-percentage:代表当前 AOF 文件大小(aof_current_size)和上一次重写后 AOF 文件大小(aof_base_size)相比,增长的比例。
  • auto-aof-rewrite-min-size:表示运行 BGREWRITEAOF 时 AOF 文件占用空间最小值,默认为 64MB;

Redis 启动时把 aof_base_size 初始化为当时 aof 文件的大小,Redis 运行过程中,当 AOF 文件重写操作完成时,会对其进行更新;aof_current_size 为 serverCron 执行时 AOF 文件的实时大小。当满足以下两个条件时,AOF 文件重写就会触发:

增长比例:(aof_current_size - aof_base_size) / aof_base_size > auto-aof-rewrite-percentage
文件大小:aof_current_size > auto-aof-rewrite-min-size

手动触发与自动触发的代码如下,同样在周期性方法 serverCron 中:

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    /* 省略其他逻辑 */
    
    /* 如果用户请求进行 AOF 文件重写时,Redis 正在执行 RDB 持久化,Redis 会安排在 RDB 持久化完成后执行 AOF 文件重写,
     * 如果 aof_rewrite_scheduled 为 true,说明需要执行用户的请求 */
    if (!hasActiveChildProcess() &&
        server.aof_rewrite_scheduled)
    {
        rewriteAppendOnlyFileBackground();
    }

    /* Check if a background saving or AOF rewrite in progress terminated. */
    if (hasActiveChildProcess() || ldbPendingChildren())
    {
        run_with_period(1000) receiveChildInfo();
        checkChildrenDone();
    } else {
        /* 省略 rdb 持久化条件检查 */

        /* AOF 重写条件检查:aof 开启、无子进程运行、增长百分比已设置、当前文件大小超过阈值 */
        if (server.aof_state == AOF_ON &&
            !hasActiveChildProcess() &&
            server.aof_rewrite_perc &&
            server.aof_current_size > server.aof_rewrite_min_size)
        {
            long long base = server.aof_rewrite_base_size ?
                server.aof_rewrite_base_size : 1;
            /* 计算增长百分比 */
            long long growth = (server.aof_current_size*100/base) - 100;
            if (growth >= server.aof_rewrite_perc) {
                serverLog(LL_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
                rewriteAppendOnlyFileBackground();
            }
        }
    }
    /**/
}
AOF 文件重写的流程是什么?听说 Redis 支持混合持久化,对 AOF 文件重写有什么影响?

从 4.0 版本开始,Redis 在 AOF 模式中引入了混合持久化方案,即:纯 AOF 方式、RDB+AOF 方式,这一策略由配置参数 aof-use-rdb-preamble(使用 RDB 作为 AOF 文件的前半段)控制,默认关闭(no),设置为 yes 可开启。所以,在 AOF 重写过程中文件的写入会有两种不同的方式。当 aof-use-rdb-preamble 的值是:

  • no:按照 AOF 格式写入命令,与 4.0 前版本无差别;
  • yes:先按照 RDB 格式写入数据状态,然后把重写期间 AOF 缓冲区的内容以 AOF 格式写入,文件前半部分为 RDB 格式,后半部分为 AOF 格式。

结合源码(6.0 版本,源码太多这里不贴出,可参考 aof.c)及参考资料,绘制 AOF 重写(BGREWRITEAOF)流程图:

e57c83277f566293a183c16cbd2dc43d.png

结合上图,总结一下 AOF 文件重写的流程:

  • rewriteAppendOnlyFileBackground 开始执行,检查是否有正在进行的 AOF 重写或 RDB 持久化子进程:如果有,则退出该流程;如果没有,则继续创建接下来父子进程间数据传输的通信管道。执行 fork()操作,成功后父子进程分别执行不同的流程。
  • 父进程:
    • 记录子进程信息(pid)、时间戳等;
    • 继续响应其他客户端请求;
    • 收集 AOF 重写期间的命令,追加至 aof_rewrite_buffer;
    • 等待并向子进程同步 aof_rewrite_buffer 的内容;
  • 子进程:
    • 修改当前进程名称,创建重写所需的临时文件,调用 rewriteAppendOnlyFile 函数;
    • 根据 aof-use-rdb-preamble 配置,以 RDB 或 AOF 方式写入前半部分,并同步至硬盘;
    • 从父进程接收增量 AOF 命令,以 AOF 方式写入后半部分,并同步至硬盘;
    • 重命名 AOF 文件,子进程退出。

数据加载

Redis 启动后通过 loadDataFromDisk 函数执行数据加载工作。这里需要注意,虽然持久化方式可以选择 AOF、RDB 或者两者兼用,但是数据加载时必须做出选择,两种方式各自加载一遍就乱套了。

理论上,AOF 持久化比 RDB 具有更好的实时性,当开启了 AOF 持久化方式,Redis 在数据加载时优先考虑 AOF 方式。而且,Redis 4.0 版本后 AOF 支持了混合持久化,加载 AOF 文件需要考虑版本兼容性。Redis 数据加载流程如下图所示:

b3ff69011bc7e3faecb474d0603ed547.png

在 AOF 方式下,开启混合持久化机制生成的文件是“RDB 头 +AOF 尾”,未开启时生成的文件全部为 AOF 格式。考虑两种文件格式的兼容性,如果 Redis 发现 AOF 文件为 RDB 头,会使用 RDB 数据加载的方法读取并恢复前半部分;然后再使用 AOF 方式读取并恢复后半部分。由于 AOF 格式存储的数据为 RESP 协议命令,Redis 采用伪客户端执行命令的方式来恢复数据。

如果在 AOF 命令追加过程中发生宕机,由于延迟写的技术特点,AOF 的 RESP 命令可能不完整(被截断)。遇到这种情况时,Redis 会按照配置项 aof-load-truncated 执行不同的处理策略。这个配置是告诉 Redis 启动时读取 aof 文件,如果发现文件被截断(不完整)时该如何处理:

  • yes:则尽可能多的加载数据,并以日志的方式通知用户;
  • no:则以系统错误的方式崩溃,并禁止启动,需要用户修复文件后再重启。

总结

Redis 提供了两种持久化的选择:RDB 支持以特定的实践间隔为数据集生成时间点快照;AOF 把 Redis Server 收到的每条写指令持久化到日志中,待 Redis 重启时通过重放命令恢复数据。日志格式为 RESP 协议,对日志文件只做 append 操作,无损坏风险。并且当 AOF 文件过大时可以自动重写压缩文件。

当然,如果你不需要对数据进行持久化,也可以禁用 Redis 的持久化功能,但是大多数情况并非如此。实际上,我们时有可能同时使用 RDB 和 AOF 两种方式的,最重要的就是我们要理解两者的区别,以便合理使用。

RDB vs AOF

RDB 优点

  • RDB 是一个紧凑压缩的二进制文件,代表 Redis 在某一个时间点上的数据快照,非常适合用于备份、全量复制等场景。
  • RDB 对灾难恢复、数据迁移非常友好,RDB 文件可以转移至任何需要的地方并重新加载。
  • RDB 是 Redis 数据的内存快照,数据恢复速度较快,相比于 AOF 的命令重放有着更高的性能。

RDB 缺点

  • RDB 方式无法做到实时或秒级持久化。因为持久化过程是通过 fork 子进程后由子进程完成的,子进程的内存只是在 fork 操作那一时刻父进程的数据快照,而 fork 操作后父进程持续对外服务,内部数据时刻变更,子进程的数据不再更新,两者始终存在差异,所以无法做到实时性。
  • RDB 持久化过程中的 fork 操作,会导致内存占用加倍,而且父进程数据越多,fork 过程越长。
  • Redis 请求高并发可能会频繁命中 save 规则,导致 fork 操作及持久化备份的频率不可控;
  • RDB 文件有文件格式要求,不同版本的 Redis 会对文件格式进行调整,存在老版本无法兼容新版本的问题。

AOF 优点

  • AOF 持久化有更好的实时性,我们可以选择三种不同的方式(appendfsync):no、every second、always,every second 作为默认的策略具有最好的性能,极端情况下可能会丢失一秒的数据。
  • AOF 文件只有 append 操作,无复杂的 seek 等文件操作,没有损坏风险。即使最后写入数据被截断,也很容易使用 redis-check-aof 工具修复;
  • 当 AOF 文件变大时,Redis 可在后台自动重写。重写过程中旧文件会持续写入,重写完成后新文件将变得更小,并且重写过程中的增量命令也会 append 到新文件。
  • AOF 文件以已于理解与解析的方式包含了对 Redis 中数据的所有操作命令。即使不小心错误的清除了所有数据,只要没有对 AOF 文件重写,我们就可以通过移除最后一条命令找回所有数据。
  • AOF 已经支持混合持久化,文件大小可以有效控制,并提高了数据加载时的效率。

AOF 缺点

  • 对于相同的数据集合,AOF 文件通常会比 RDB 文件大;
  • 在特定的 fsync 策略下,AOF 会比 RDB 略慢。一般来讲,fsync_every_second 的性能仍然很高,fsync_no 的性能与 RDB 相当。但是在巨大的写压力下,RDB 更能提供最大的低延时保障。
  • 在 AOF 上,Redis 曾经遇到一些几乎不可能在 RDB 上遇到的罕见 bug。一些特殊的指令(如 BRPOPLPUSH)导致重新加载的数据与持久化之前不一致,Redis 官方曾经在相同的条件下进行测试,但是无法复现问题。

使用建议

对 RDB 和 AOF 两种持久化方式的工作原理、执行流程及优缺点了解后,我们来思考下,实际场景中应该怎么权衡利弊,合理的使用两种持久化方式。如果仅仅是使用 Redis 作为缓存工具,所有数据可以根据持久化数据库进行重建,则可关闭持久化功能,做好预热、缓存穿透、击穿、雪崩之类的防护工作即可。

一般情况下,Redis 会承担更多的工作,如分布式锁、排行榜、注册中心等,持久化功能在灾难恢复、数据迁移方面将发挥较大的作用。建议遵循几个原则:

  • 不要把 Redis 作为数据库,所有数据尽可能可由应用服务自动重建。
  • 使用 4.0 以上版本 Redis,使用 AOF+RDB 混合持久化功能。
  • 合理规划 Redis 最大占用内存,防止 AOF 重写或 save 过程中资源不足。
  • 避免单机部署多实例。
  • 生产环境多为集群化部署,可在 slave 开启持久化能力,让 master 更好的对外提供写服务。
  • 备份文件应自动上传至异地机房或云存储,做好灾难备份。

关于 fork()

通过上面的分析,我们都知道 RDB 的快照、AOF 的重写都需要 fork,这是一个重量级操作,会对 Redis 造成阻塞。因此为了不影响 Redis 主进程响应,我们需要尽可能降低阻塞。

  • 降低 fork 的频率,比如可以手动来触发 RDB 生成快照、与 AOF 重写;
  • 控制 Redis 最大使用内存,防止 fork 耗时过长;
  • 使用更高性能的硬件;
  • 合理配置 Linux 的内存分配策略,避免因为物理内存不足导致 fork 失败。

参考文献

Search

    Table of Contents