引言


RDB 和 AOF 是 Redis 提供的两种数据持久化方式,其中 RDB 存了数据库的完整快照,比较损耗性能,而 AOF 只是把会产生副作用的命令一条条记下来,相对轻量快速,为了降低丢数据的风险,一般会打开 AOF,为了避免 AOF 文件过大,Redis 也会时不时地对其进行整理。

RDB


手动触发:BGSAVE

我们可以使用命令 SAVE 或者 BGSAVE 来强制触发数据库重写 RDB 文件,SAVE使用数据库的主进程来重写 RDB,会导致数据库无法处理更多的命令,所以我们一般使用 BGSAVE 创建一个子进程来重写,rdb.c:1083

    // fork() 开始前的时间,记录 fork() 返回耗时用
    start = ustime();

    if ((childpid = fork()) == 0) {
        //...
        // 在子进程中重写 rdb
        retval = rdbSave(filename);
    }

走马观花 RDB 文件格式

继续阅读上面代码中的 rdbSave 函数,它会先创建一个叫做 temp-xx.rdb 的临时文件(其中 xx 是指子进程的进程 id),先把数据写到这个文件中,然后再把它重命名为用户指定的 rdb 文件,rdb.c:938

    // 创建临时文件
    snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
    fp = fopen(tmpfile,"w");

之后 Redis 首先写入自己的 magic number,其实就是 “REDIS” + 版本号,对于 Redis 3.0 来说,就是 “REDIS6”,rdb.c:954

    // 写入 RDB 版本号 REDIS0006
    snprintf(magic,sizeof(magic),"REDIS%04d",REDIS_RDB_VERSION);
    if (rdbWriteRaw(&rdb,magic,9) == -1) goto werr;

之后遍历所有数据库,遍历所有键值对写入后,写入 EOF 和校验和:

    // 遍历所有数据库
    for (j = 0; j < server.dbnum; j++) {
        //...
    }
    if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_EOF) == -1) goto werr;
    cksum = rdb.cksum;
    rioWrite(&rdb,&cksum,8);

到此我们就知道一个 RDB 文件大概的样子了:

rdb文件结构

下面再粗略看看 databases 部分的结构。在 databases 部分,每一个数据的第一个字节标识自己的类型,可以是 OPCODE 或者具体的数据类型(比如 REDIS_RDB_TYPE_LIST_ZIPLIST,杂揉了数据结构和其底层的具体编码)rdb.h:97

#define REDIS_RDB_TYPE_STRING 0
#define REDIS_RDB_TYPE_LIST   1
#define REDIS_RDB_TYPE_SET    2
#define REDIS_RDB_TYPE_ZSET   3
#define REDIS_RDB_TYPE_HASH   4
//...

//...
#define REDIS_RDB_OPCODE_SELECTDB   254
#define REDIS_RDB_OPCODE_EOF        255

databases 部分的开头就是一个 REDIS_RDB_OPCODE_SELECTDB,后面紧跟着数据库的编号(RDB 对于数字的存储做了专门的优化,后面再聊)。然后就是所有的键值对,每个键值对的存储格式大概是(如图):过期时间(如果有设置的话,没有设置就省略这一部分)+ 类型(就是上面给出的这些宏,占一个字节) + 值。其中值会根据不同的类型,采用不同的编码,因为这里只是走马观花,就不细看每种类型值的编码了,如果想要了解的话,可以很容易在代码中找到相关部分并阅读。

read中单个 kv 存储

RDB 中对数字存储的优化

RDB中会存储很多数字信息,主要是长度相关信息,比如字符串的长度,字典,集合等的长度等等,所以 Redis 专门对数字的存储进行了优化,如果是6位就能表示的数字,那么就只用一个字节存该数字,以 00 开头,剩下 6 位存数字;如果 6 位放不下,但是 14 位能放得下,那么就用两个字节存储该数字,以 01 开头,剩下的 14 个字节存数字;如果 14 个字节还存不下的话,那么就用 5 个字节存数字,第一个字节固定就是 11 000000,然后剩下的 4 个字节放该整型数字。

范围 字节数 开头
x < 64 1 00
64<= x < 16384 2 01
16384 <= x < 4294967295(uint最大值) 5 11000000

数据库自动触发

如果我们不手动触发,数据库会在什么时机重写呢?

在本系列的第三篇文章(网络层实现)中我们提到,在 Redis 网络层中唯一的定时事件就是 serverCron,它默认每隔100ms执行一次, 在 serverCron 会进行是否需要重写 RDB 文件的判定,redis.c:1467

        // 遍历所有保存条件,看是否需要执行 BGSAVE 命令
         for (j = 0; j < server.saveparamslen; j++) {
            struct saveparam *sp = server.saveparams+j;

            // 检查是否有某个保存条件已经满足了
            if (server.dirty >= sp->changes &&
                server.unixtime-server.lastsave > sp->seconds &&
                // 有可能是上次 bgsave 执行失败, 需要重试
                (server.unixtime-server.lastbgsave_try >
                 REDIS_BGSAVE_RETRY_DELAY ||
                 server.lastbgsave_status == REDIS_OK))
            {
                redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...",
                    sp->changes, (int)sp->seconds);
                // 执行 BGSAVE
                rdbSaveBackground(server.rdb_filename);
                break;
            }
         }

可以看到里面遍历了 server 结构体的 saveparams 字段保存的一些列条件,在服务器的初始化代码中可以找到这些条件:

    appendServerSaveParams(60*60,1);  /* 1 小时内的修改大于等于 1 次 */
    appendServerSaveParams(300,100);  /* 5 分钟内的修改大于等于 100 次 */
    appendServerSaveParams(60,10000); /* 1 分钟内的修改大于等于 10000 次 */

从 RDB 恢复数据

在 Redis 服务器启动时,如果开启了 AOF,会优先从 AOF 数据,没有开启 AOF 时,才从 RDB 读取,redis.c:3892

    // AOF 持久化已打开?
    if (server.aof_state == REDIS_AOF_ON) {
        // 尝试载入 AOF 文件
        if (loadAppendOnlyFile(server.aof_filename) == REDIS_OK)
            // 打印载入信息,并计算载入耗时长度
            redisLog(REDIS_NOTICE,"DB loaded from append only file: %.3f seconds",(float)(ustime()-start)/1000000);
    // AOF 持久化未打开
    } else {
        // 尝试载入 RDB 文件
        if (rdbLoad(server.rdb_filename) == REDIS_OK) {
            // 打印载入信息,并计算载入耗时长度
            redisLog(REDIS_NOTICE,"DB loaded from disk: %.3f seconds",
                (float)(ustime()-start)/1000000);
        } else if (errno != ENOENT) {
            redisLog(REDIS_WARNING,"Fatal error loading the DB: %s. Exiting.",strerror(errno));
            exit(1);
        }
    }

AOF


AOF 存储格式走马观花

AOF 文件里存的其实就是纯文件格式的一条一条命令,但是和我们在 Redis 客户端写的命令不太一样,是用于 Redis 客户端与服务器交互的格式,,比如查看 list 长度的命令 LLEN mylist,会被存储成:

*2\r\n
$4\r\n
LLEN\r\n
$6\r\n
mylist\r\n

逐行翻译一下:

整体翻译一遍,就相当于["LLEN", "mylist"],所有 Redis 命令都会先被 Redis 客户端翻译成类似的格式后传输给服务器。

更加详细的格式见官方文档:Redis 协议文档

命令的及时存储

AOF 和 RDB 不同,它按序存储每条修改数据库的命令,所以每次执行更新数据命令时,它都必须将他们保存下来,但是又不能每次都写到文件里,因为这样会大大降低性能,所以 Redis 提供了一个叫做 appendfsync 的选项用于配置将缓存刷到文件的频率,Redis 三种可选项:

这里的”刷”其实是指调用 linux 的系统库函数 fsync(从配置的名称 appendfsync 就能看出来),因为虽然每当一条命令执行结束时,Redis 都会使用 write 函数将其写入 AOF 文件中,但是系统的 write 函数并不会真的将其写入文件中,而是先写入操作系统的缓存(叫做”Page Cache”),等到积累到一定的数据量再统一写入文件,除非用户通过 fsync 函数强制让操作系统写入:

write 与 fsync 函数

下面我们来梳理一下源码中在一条命令执行的过程中和 AOF 相关的流程。

当一条命令完成后,通过检查 server.dirty 值来判断数据库有没有被修改(对数据库有修改的命令,会主动进行 server.dirty++),如果发现被修改,说明这是一条会更新数据库的命令,需要记下来,redis.c:2488

    // 保留旧 dirty 计数器值
    dirty = server.dirty;
    // 计算命令开始执行的时间
    start = ustime();
    //... 执行 Redis 命令
  
    // 计算命令执行之后的 dirty 值
    dirty = server.dirty-dirty;
    // 将命令复制到 AOF 和 slave 节点
    if (flags & REDIS_CALL_PROPAGATE) {
        //...
        // 如果数据库有被修改,那么启用 REPL 和 AOF 传播
        if (dirty)  // 数据库中数据被修改
            flags |= (REDIS_PROPAGATE_REPL | REDIS_PROPAGATE_AOF);

        if (flags != REDIS_PROPAGATE_NONE)
            // 在这个函数里会记录下这条命令
            propagate(c->cmd,c->db->id,c->argv,c->argc,flags);
    }

再继续深入 propagate 函数,我们发现它会调用 feedAppendOnlyFile 函数,最终将命令写入 server.aof_buf 中,aof.c:743

    if (server.aof_state == REDIS_AOF_ON)
        server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf));

    // 如果此时刚好有子线程在重写(前面提到当 AOF 膨胀得太大时,Redis会启子线程来整理)
    // 那么数据还会同时写到 server.aof_rewrite_buf_blocks 中
    // 不用担心数据被重复写,因为当重写结束时,会自动清空 server.aof_buf
    if (server.aof_child_pid != -1)
        aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf));

当命令执行结束后,程序又一次进入 eventLoop 的事件等待之前(网络层将这一段逻辑称为 beforeSleep),会使用 write 函数将 server.aof_buf 的内容写入 AOF 文件,redis.c:1604

    // 将 AOF 缓冲区的内容写入到 AOF 文件
    flushAppendOnlyFile(0);

flushAppendOnlyFile 中你可以找到 write 函数的调用,aof.c:441

    nwritten = write(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));

AOF 文件重写

和 RDB 类似,AOF 也可以通过 BGREWRITEAOF 来强制重写 AOF 文件,同时 Redis 也会自动判断是否需要重写,redis.c:1505

            // 上一次完成 AOF 写入之后,AOF 文件的大小
            long long base = server.aof_rewrite_base_size ?
                            server.aof_rewrite_base_size : 1;
            // AOF 文件当前的体积相对于 base 的体积的百分比
            long long growth = (server.aof_current_size*100/base) - 100;
            // 如果增长体积的百分比超过了 growth ,那么执行 BGREWRITEAOF
            // 默认是增长超过一倍
            if (growth >= server.aof_rewrite_perc) {
                redisLog(REDIS_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
                // 执行 BGREWRITEAOF
                rewriteAppendOnlyFileBackground();
            }

server.aof_rewrite_perc 的默认值是 100,即 AOF 增长大于等于 1 倍才会出发重写。

AOF 文件重写的过程中还可能有新的命令需要写入,所以相比 RDB 会更复杂一些,需要父子进程合作来完成。

子线程做的事情类似于 RDB 中子进程做的事情,先创建一个临时文件 temp-rewriteaof-xx.aof(其中xx 是指进程的 ID),遍历 Redis 中所有的数据库,以及数据库中所有的键值对,根据键值对的类型和内容在 AOF 中写下能产生该键值的命令,aof.c:1328

int rewriteAppendOnlyFile(char *filename) {
    //...
    snprintf(tmpfile,256,"temp-rewriteaof-%d.aof", (int) getpid());
    fp = fopen(tmpfile,"w");
    //...
    
    // 遍历所有数据库
    for (j = 0; j < server.dbnum; j++) {
        //...遍历所有键值对
    }
}

但是和 RDB 不同的是,AOF 需要考虑在重写的过程中被执行的命令(比如子线程正在重写 AOF 文件的时候,我用 SET 命令又把一个键的值给更新了),此时这条命令肯定不能只往 AOF 文件中写,因为这个 AOF 文件不久之后就会被重写的临时文件给替换掉,所以之前提到,当有子线程正在重写时,命令不仅会被写到 server.aof_buf 中,还会被写到 server.aof_rewrite_buf_blocks 中,而父进程在得知子进程重写结束后,就会将 aof_rewrite_buf_blocks 中的内容 append 到子进程刚刚创建的临时文件中(父进程其实就是 Redis 的主进程,此时 Redis 停止处理一切命令专门来写这个aof_rewrite_buf_blocks,这样就保证了 AOF 中的数据和数据库中一致),然后再把它重命名为用户指定的 AOF 文件,整个流程如下:

父子进程的协作

需要说明的是,父进程是在每一个 serverCron 定时事件中通过非阻塞的 wait 来查看子进程是否结束,如果发现子进程已经结束,那么父进程就会进行收尾工作,redis.c:1451

        if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
            //...
            if (pid == server.aof_child_pid) {
                // 父进程的后续收尾工作,
                // 其实就是将 server.aof_rewrite_buf_blocks 的内容 append 上去
                backgroundRewriteDoneHandler(exitcode,bysignal);
            }
        }