redis持久化——AOF

2,721 阅读12分钟

什么是持久化?

Redis 是一种内存数据库,将数据保存在内存中,读写效率要比传统的将数据保存在磁盘上的数据库要快很多。但是一旦进程退出,Redis 的数据就会丢失。持久化就是把数据存在磁盘里,出了问题重启能够重新读到数据

redis持久化分类

Redis 提供了 RDB 和 AOF 两种持久化方案,将内存中的数据保存到磁盘中

  • AOF :( append only file )持久化以独立日志的方式记录每次写命令,并在 Redis 重启时在重新执行 AOF 文件中的命令以达到恢复数据的目的。AOF 的主要作用是解决数据持久化的实时性。
  • RDB :把当前 Redis 进程的数据生成时间点快照( point-in-time snapshot ) 保存到存储设备的过程。

本篇文章介绍AOF

AOF持久化介绍及流程

AOF通过4点实现持久化:

  1. 写入缓存:每次执行命令后,进行append操作写入AOF缓存
  2. 同步磁盘:AOF 缓冲区根据对应的策略向硬盘进行同步操作。
  3. AOF重写:随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
  4. 重启加载: 当 Redis 重启时,可以加载 AOF 文件进行数据恢复。

写入缓存

每次执行命令都是通过call(),call的时候会把命令写入aof缓存,也就是server.aof_buf

调用链: call() -> propogate() -> feedAppendOnlyFile

void call(client *c, int flags) {
    ...
    propagate(c->cmd,c->db->id,c->argv,c->argc,propagate_flags);
}

void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc,
               int flags)
{
    ...
    if (server.aof_state != AOF_OFF && flags & PROPAGATE_AOF)
        feedAppendOnlyFile(cmd,dbid,argv,argc);
    ...
}

我们看一下feedAppendOnlyFile()这个函数

feedAppendOnlyFile()

void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc) {
    把命令解析编码,比较复杂,
    buf = catAppendOnlyGenericCommand(buf,argc,argv);
    然后存入server.aof_buf
    server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf));
    如果子进程正在重写AOF,就把buf写入server.aof_rewrite_buf_blocks链表
    if (server.child_type == CHILD_TYPE_AOF)
        aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf));
}
  1. 解析命令

buf = catAppendOnlyGenericCommand(buf,argc,argv);

该函数主要工作就是解析命令,把传入的cmd和argv,argc解析成"*3\r\n3\r\nSET\r\n5\r\nmykey\r\n$7\r\nmyvalue\r\n"的样子,存在buff里

  1. 写入缓存

server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf)); 这句把解析好的命令写入缓存,接下来要同步给磁盘了

  1. 如果子进程正在重写AOF文件,则把解析好的命令写入server.aof_rewrite_buf_blocks链表

    server.child_type表示子进程正在进行什么工作,在AOF重写(rewrite)过程中会创建子进程执行重写工作,这个在下面介绍AOF重写的时候会解释这里

同步磁盘

同步磁盘的操作在函数flushAppendOnlyFike()中,

flushAppendOnlyFile 函数的行为由 redis.conf 配置中的 appendfsync 选项的值来决定。该选项有三个可选值,分别是 alwayseverysecno

  • always:Redis 在每个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件,并且同步 AOF 文件,所以 always 的效率是 appendfsync 选项三个值当中最差的一个,但从安全性来说,也是最安全的。当发生故障停机时,AOF 持久化也只会丢失一个事件循环中所产生的命令数据。
  • everysec:Redis 在每个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件中,并且每隔一秒就要在子线程中对 AOF 文件进行一次同步。从效率上看,该模式足够快。当发生故障停机时,只会丢失一秒钟的命令数据。
  • no:Redis 在每一个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件。而 AOF 文件的同步由操作系统控制。这种模式下速度最快,但是同步的时间间隔较长,出现故障时可能会丢失较多数据。

write和fsync

Linux 系统下 write 操作会触发延迟写( delayed write )机制。Linux 在内核提供页缓存区用来提供硬盘 IO 性能。write 操作在写入系统缓冲区之后直接返回。同步硬盘操作依赖于系统调度机制,例如:缓冲区页空间写满或者达到特定时间周期。同步文件之前,如果此时系统故障宕机,缓冲区内数据将丢失。

fsync 针对单个文件操作,对其进行强制硬盘同步,fsync 将阻塞直到写入磁盘完成后返回,保证了数据持久化。

appendfsync的三个值代表着三种不同的调用 fsync的策略。调用fsync周期越频繁,读写效率就越差,但是相应的安全性越高,发生宕机时丢失的数据越少。

介绍一些成员变量的作用:

  • server.aof_fsync:上述三个AOF级别
  • server.aof_fd:
  • server.aof_current_size:
  • server.aof_fsync_offset:
  • server.aof_last_fsync:
  • server.unixtime:
void flushAppendOnlyFile(int force) {
    // 1.写入fd
    nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));
    
    // 2.同步磁盘
    if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
        redis_fsync(server.aof_fd) 
        server.aof_fsync_offset = server.aof_current_size;
        server.aof_last_fsync = server.unixtime;
    } else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC &&
                server.unixtime > server.aof_last_fsync)) {
        if (!sync_in_progress) {
            aof_background_fsync(server.aof_fd);
            server.aof_fsync_offset = server.aof_current_size;
        }
        server.aof_last_fsync = server.unixtime;
    }
    
}

可以看到,flushAppendOnlyFile()函数步骤如下:

  1. 调用aofWrite()将缓存内容写入server.aof_fd,aofWrite就是write() 函数的一个包装
  2. 根据aof模式调用下面方法之一:
    • AOF_FSYNC_ALWAYS:调用redis_fsync(server.aof_fd)直接同步
    • AOF_FSYNC_EVERYSEC:调用aof_background_fsync(server.aof_fd)在后台同步,后台同步过程下一个小节介绍

这里还有一些规则细节需要介绍:

flushAppendOnlyFile()函数执行以下两个工作:

  • WRITE:根据条件,将 aof_buf 中的缓存写入到 AOF 文件。
  • SAVE:根据条件,调用 fsync 或 fdatasync 函数,将 AOF 文件保存到磁盘中。 在AOF_FSYNC_EVERYSEC模式下:

每当 flushAppendOnlyFile 函数被调用时, 可能会出现以下四种情况:

  • 子线程正在执行 SAVE ,并且:

    1. 这个 SAVE 的执行时间未超过 2 秒,那么程序直接返回,并不执行 WRITE 或新的 SAVE 。
    2. 这个 SAVE 已经执行超过 2 秒,那么程序执行 WRITE ,但不执行新的 SAVE 。注意,因为这时 WRITE 的写入必须等待子线程先完成(旧的) SAVE ,因此这里 WRITE 会比平时阻塞更长时间。
  • 子线程没有在执行 SAVE ,并且:

    1. 上次成功执行 SAVE 距今不超过 1 秒,那么程序执行 WRITE ,但不执行 SAVE 。
    2. 上次成功执行 SAVE 距今已经超过 1 秒,那么程序执行 WRITE 和 SAVE 。 根据以上说明可以知道, 在“每一秒钟保存一次”模式下, 如果在情况 1 中发生故障停机, 那么用户最多损失小于 2 秒内所产生的所有数据。

如果在情况 2 中发生故障停机, 那么用户损失的数据是可以超过 2 秒的。

redis_fsync就是系统调用fsync,没啥说的,主要看后台同步的过程

后台同步 aof_background_fsync(server.aof_fd)

调用链
    aof_background_fsync(server.aof_fd)
        bioCreateFsyncJob(server.aof_fd);
            struct bio_job *job = zmalloc(sizeof(*job));
            job->fd = fd;
            bioSubmitJob(BIO_AOF_FSYNC, job);
                job->time = time(NULL);
                pthread_mutex_lock(&bio_mutex[BIO_AOF_FSYNC]);
                listAddNodeTail(bio_jobs[BIO_AOF_FSYNC],job);
                bio_pending[BIO_AOF_FSYNC]++;
                pthread_cond_signal(&bio_newjob_cond[BIO_AOF_FSYNC]);
                pthread_mutex_unlock(&bio_mutex[BIO_AOF_FSYNC]);
                

后台有一个bio线程,在执行bioProcessBackgroundJobs() ,不停地redis_fsync(job->fd)

AOF重写

AOF重写比较复杂,篇幅较长

为什么要AOF重写?

redis长时间运行,随着执行的命令增加,AOF 文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的 AOF 文件很可能对 Redis 甚至宿主计算机造成影响。所以redis会定时重写AOF文件,压缩大小。新旧两个 AOF 文件所保存的 Redis 状态相同,但是新的 AOF 文件不会包含任何浪费空间的荣誉命令,所以新 AOF 文件的体积通常比旧 AOF 文件的体积要小得很多。

image.png 如上图所示,重写前要记录名为list的键的状态,AOF 文件要保存五条命令,而重写后,则只需要保存一条命令

AOF重写概括

redis的AOF重写是由主进程fork出一条子进程来执行的

子进程重写一个新的AOF文件,在子进程进行 AOF 重写期间,Redis主进程继续接收客户端命令,会对现有数据库状态进行修改,从而导致数据当前状态和 重写后的 AOF 文件所保存的数据库状态不一致。

为此,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当 Redis 执行完一个写命令之后,它会同时将这个写命令发送给 AOF 缓冲区和 AOF 重写缓冲区。

image.png

在AOF重写过程中,AOF重写缓冲区中的数据会被主进程写入管道发送给子进程,子进程写入新的AOF文件,完成任务。主进程发现子进程结束,把AOF重写缓冲区中剩下的写入新AOF文件,替换旧的AOF文件,然后继续接收命令并执行。

AOF重写详细过程——深入源码

重写过程在rewriteAppendOnlyFileBackground函数中

int rewriteAppendOnlyFileBackground(void) {
    pid_t childpid;
    // 创建管道
    aofCreatePipes()
    if ((childpid = redisFork(CHILD_TYPE_AOF)) == 0) {
        char tmpfile[256];
        /* Child */
        redisSetProcTitle("redis-aof-rewrite");
        redisSetCpuAffinity(server.aof_rewrite_cpulist);
        snprintf(tmpfile,256,"temp-rewriteaof-bg-%d.aof", (int) getpid());
        // 执行aof重写
        rewriteAppendOnlyFile(tmpfile)
        sendChildCowInfo(CHILD_INFO_TYPE_AOF_COW_SIZE, "AOF rewrite");
        exitFromChild(0);
    } else {
        /* Parent */
        server.aof_rewrite_scheduled = 0;
        server.aof_rewrite_time_start = time(NULL);
        server.aof_selected_db = -1;
        replicationScriptCacheFlush();
        return C_OK;
    }
    return C_OK; /* unreached */
}
  • aofCreatePipes()创建aof父子进程之间通讯需要的管道
  • 这里通过redisFork()函数创建了一个子进程
  • 子进程通过rewriteAppendOnlyFile()进行AOF重写工作

下面我们分别看看父进程和子进程做了什么

子进程

server.in_fork_child = CHILD_TYPE_AOF;
setOOMScoreAdj(CONFIG_OOM_BGCHILD);
setupChildSignalHandlers();
closeChildUnusedResourceAfterFork();
redisSetProcTitle("redis-aof-rewrite");
redisSetCpuAffinity(server.aof_rewrite_cpulist);
snprintf(tmpfile,256,"temp-rewriteaof-bg-%d.aof", (int) getpid());
// 重写逻辑
rewriteAppendOnlyFile(tmpfile)

exitFromChild(0);

子进程重写的逻辑在rewriteAppendOnlyFile()函数里

int rewriteAppendOnlyFile(char *filename) {
    // 1. 创建一个临时文件,名字是temp-rewriteaof-pid.aof
    snprintf(tmpfile,256,"temp-rewriteaof-%d.aof", (int) getpid());
    fp = fopen(tmpfile,"w");
    rioInitWithFile(&aof,fp);
    
    // 遍历每个db里的每个kv对,为其生成一条set语句
    // 然后调用aof的write函数把生成的语句写入aof.io.file.fp
    // r->io.file.autosync决定了是否每次写后刷新
    rewriteAppendOnlyFileRio(&aof)
    fflush(fp)
    fsync(fileno(fp))
    
    // 父进程还在处理命令,这时候可能又有新的命令需要记录了
    // 从管道接收父进程发送的数据
    int nodata = 0;
    mstime_t start = mstime();
    while(mstime()-start < 1000 && nodata < 20) {
        if (aeWait(server.aof_pipe_read_data_from_parent, AE_READABLE, 1) <= 0) {
            nodata++;
            continue;
        }
        nodata = 0;  
        //从管道读数据写入server.aof_child_diff ********【mark】********
        aofReadDiffFromParent();
    }
    // 给父进程发送一个“!”,表示已经收到消息,让父进程停止发送,并且接收父进程的ACK
    write(server.aof_pipe_write_ack_to_parent,"!",1)
    anetNonBlock(NULL,server.aof_pipe_read_ack_from_parent)
    syncRead(server.aof_pipe_read_ack_from_parent,&byte,1,5000)
    
    // 从【mark】到这个地方,可能又有数据了,所以再读一遍
    aofReadDiffFromParent();
    
    // 开始把diff数据从server.aof_child_diff写入临时文件了!
    size_t bytes_to_write = sdslen(server.aof_child_diff);
    const char *buf = server.aof_child_diff;
    long long cow_updated_time = mstime();
    long long key_count = dbTotalServerKeyCount();
    while (bytes_to_write) {
        size_t chunk_size = bytes_to_write < (8<<20) ? bytes_to_write : (8<<20);
        // server.aof_child_diff写入aof.io.file.fp
        rioWrite(&aof,buf,chunk_size)

        bytes_to_write -= chunk_size;
        buf += chunk_size;

    }
    
    // 刷新同步
    fflush(fp)
    fsync(fileno(fp)
    fclose(fp)
    // 重命名文件
    rename(tmpfile,filename)
}
    

父进程

父进程去继续执行命令了,该干嘛干嘛,只不过有了一个变化:每次执行命令完成后都要把命令存入AOF重写缓冲区,并通过管道发送给子进程!

之前介绍过,redis每次执行命令,都会调用feedAppendOnlyFile()函数,feedAppendOnlyFile()函数最后一段代码是:

 if (server.child_type == CHILD_TYPE_AOF)
        aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf));

AOF子进程把server.in_fork_child 设成了 CHILD_TYPE_AOF,所以当后台AOF的时候,这段逻辑就会生效,执行aofRewriteBufferAppend()函数

aofRewriteBufferAppend()函数的工作:

  1. 把命令写入server.aof_rewrite_buf_blocks,这是一个链表,专门用来存开启AOF重写后主进程执行的命令,用来同步给子进程消除diff,我们称之为AOF重写缓冲区
  2. 注册一个事件,监听管道server.aof_pipe_write_data_to_child,执行aofChildWriteDiffData(),就是把server.aof_rewrite_buf_blocks链表的数据都写入管道,发送给子进程

这里注册的写事件,epoll_ctl注册的是EPOLLOUT事件,EPOLLOUT是有在两种情况下被触发:

  1. 缓冲区由满变空.
  2. 同时注册EPOLLIN | EPOLLOUT事件,也会触发一次EPOLLOUT事件 我猜测是fd只要缓冲区有空间,就会被触发

aofChildWriteDiffData函数做的事就是不断把数据从server.aof_rewrite_buf_blocks链表写入管道aof_pipe_write_data_to_child发送给子进程。每次事件循环,只要管道为空,就会触发该事件。

之前代码中,子进程接收完父进程的数据后会发送一个“!”给父进程告诉他收到,可以停止发送了,我要准备替换文件了,在这个过程中不能再有diff了。

当父进程收到这个信号,触发的函数是aofChildPipeReadable():

  • 这个函数首先把server.aof_stop_sending_diff置为1,下次事件循环会在aofChildWriteDiffData()中把之前注册的写diff事件注销,之后的命令都不会写入管道发给子进程了,但还是会写进AOF重写缓冲区
  • 然后会再往管道aof_pipe_write_ack_to_child写入一个“!”作为ACK发给子进程,告诉子进程我收到了你的ACK,这个过程有点像三次握手。

主进程定时任务serverCron()里的checkChildrenDone()函数调用了wait3()函数,子进程没退出时,这个函数不阻塞立马返回0,当子进程退出后,wait3()会返回子进程pid,调用backgroundRewriteDoneHandler(),这个函数负责AOF收尾工作

void checkChildrenDone(void) {
    if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
        if (server.child_type == CHILD_TYPE_AOF) {
            backgroundRewriteDoneHandler(exitcode, bysignal);
        } 
    }
}

void backgroundRewriteDoneHandler(int exitcode, int bysignal) {
    newfd = open(tmpfile,O_WRONLY|O_APPEND);
    aofRewriteBufferWrite(newfd);
    aof_background_fsync(newfd);
    oldfd = open(server.aof_filename,O_RDONLY|O_NONBLOCK);
    rename(tmpfile,server.aof_filename)
    close(oldfd);
    server.aof_fd = newfd;
}

backgroundRewriteDoneHandler()做的事情大致有:

  1. 把AOF重写缓冲区力度数据全部写入子线程写好的新AOF文件:aofRewriteBufferWrite(newfd)
  2. 把新AOF文件同步到磁盘: aof_background_fsync()
  3. 重命名覆盖旧的AOF文件:rename(tmpfile,server.aof_filename)
  4. 关闭旧的文件描述符以及一系列清理工作 至此,子线程结束,父进程在子进程写的AOF文件后写入AOF重写缓冲区里的命令,并替换掉旧的AOF文件,AOF重写完成

管道的使用

在AOF重写过程中用到了管道,其实如果不用管道也能完成任务,只需要主进程在子进程结束后把AOF重写缓冲区的数据写入文件就行,但是如果redis本身数据量较大子进程执行时间较长,或者写入流量较高,就会导致aof-rewrite-buffer积攒较多,父进程就要进行大量写磁盘操作,这对于redis来说显然是不够高效的。所以引入了管道,每次执行命令后清掉缓冲区一部分数据写入管道发给子进程,这样提高写的频率,避免最后子线程结束时AOF重写缓冲区中积攒大量数据

管道的初始化在aofCreatePipes()

int aofCreatePipes(void) {
    int fds[6] = {-1, -1, -1, -1, -1, -1};
    int j;
    pipe(fds) 
    pipe(fds+2)
    pipe(fds+4) 
   
    aeCreateFileEvent(server.el, fds[2], AE_READABLE, aofChildPipeReadable, NULL)

    server.aof_pipe_write_data_to_child = fds[1];
    server.aof_pipe_read_data_from_parent = fds[0];
    server.aof_pipe_write_ack_to_parent = fds[3];
    server.aof_pipe_read_ack_from_child = fds[2];
    server.aof_pipe_write_ack_to_child = fds[5];
    server.aof_pipe_read_ack_from_parent = fds[4];
    server.aof_stop_sending_diff = 0;
}

这里共有3对管道

  • aof_pipe_write_data_to_childaof_pipe_read_data_from_parent
    • 用于父进程把重写缓冲区里的命令发送给子进程
  • aof_pipe_write_ack_to_parentaof_pipe_read_ack_from_child
    • 用于子进程发送ACK给父进程,表明读到数据,停止发送
  • aof_pipe_write_ack_to_childaof_pipe_read_ack_from_parent
    • 用于父进程发送ACK给子进程,表明父进程已读到子进程ACK

rewrite的时机

rewrite的触发机制主要有一下三个:

  • 手动调用 bgrewriteaof 命令,如果当前有正在运行的 rewrite 子进程,则本次rewrite 会推迟执行,否则,直接触发一次 rewrite。
  • 通过配置指令手动开启 AOF 功能,如果没有 RDB 子进程的情况下,会触发一次 rewrite,将当前数据库中的数据写入 rewrite 文件。
  • 在 Redis 定时器中,如果有需要退出执行的 rewrite 并且没有正在运行的 RDB 或者 rewrite 子进程时,触发一次或者 AOF 文件大小已经到达配置的 rewrite 条件也会自动触发一次。

redis重启

AOF 文件里边包含了重建 Redis 执行过的所有写命令,所以 Redis 只要读入并重新执行一遍 AOF 文件里边保存的写命令,就可以还原 Redis 关闭之前的状态。

函数调用链如下,就不细说了

main()
    loadDataFromDisk(void)
            loadAppendOnlyFile(char *filename)
                    cmd->proc(fakeClient);

参考

juejin.cn/post/684490… 很多是从 程序员历小冰 这位博主文章里粘过来的,非常推荐他的文章,写的很好,我觉得是写redis写的最好的,我的文章多是在他的基础上进行一些代码层面的细节分析