1. 操作系统基础
1.1. fork
当我们在一个程序的函数中调用 fork 函数时,fork 函数会创建一个子进程。而原本这个程序对应的进程,就称为这个子进程的父进程。我们可以根据 fork 函数的不同返回值,来编写相应的分支代码,这些分支代码就对应了父进程和子进程各自要执行的逻辑。
fork 函数的不同返回值,其实代表了不同的含义,具体来说:
- 当返回值小于 0 时,此时表明 fork 函数执行有误;
- 当返回值等于 0 时,此时,返回值对应的代码分支就会在子进程中运行;
- 当返回值大于 0 时,此时,返回值对应的代码分支仍然会在父进程中运行。
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
printf("hello main\n");
int rv = fork(); //fork函数的返回值
//返回值小于0,表示fork执行错误
if (rv < 0) {
fprintf(stderr, "fork failed\n");
}
//返回值等于0,对应子进程执行
else if (rv == 0) {
printf("I am child process %d\n", getpid());
}
//返回值大于0,对应父进程执行
else {
printf("I am parent process of (%d), %d\n", rc, getpid());
}
return 0;
}
fork 的时候,父进程的虚拟地址映射着物理内存的实际的物理地址,clone 的时候,并不是在物理地址中直接再复制一份和父进程一样的物理内存块,而是子进程的虚拟地址也直接映射到同一物理内存块中,这就是读时共享。当 父进程/子进程 操作这个物理内存块时(比如修改变量的值),复制该部分的实际物理内存到子进程中,并不是全部复制。这就是写时复制。
1.2. 进程间通信-管道
在父子进程通信时,我们通常都需要依赖操作系统提供的通信机制,而管道(pipe)就是一种用于父子进程间通信的常用机制。具体来说,管道机制在操作系统内核中创建了一块缓冲区,父进程 A 可以打开管道,并往这块缓冲区中写入数据。同时,子进程 B 也可以打开管道,从这块缓冲区中读取数据。
进程每次往管道中写入数据时,只能追加写到缓冲区中当前数据所在的尾部,而进程每次从管道中读取数据时,只能从缓冲区的头部读取数据。其实,管道创建的这块缓冲区就像一个先进先出的队列一样,写数据的进程写到队列尾部,而读数据的进程则从队列头读取。
管道中的数据在一个时刻只能向一个方向流动,这也就是说,如果父进程 A 往管道中写入了数据,那么此时子进程 B 只能从管道中读取数据。类似的,如果子进程 B 往管道中写入了数据,那么此时父进程 A 只能从管道中读取数据。而如果父子进程间需要同时进行数据传输通信,我们就需要创建两个管道了。
操作系统提供的管道的系统调用 pipe:
int pipe(int pipefd[2]);
pipe 的参数是一个数组 pipefd,表示的是管道的文件描述符。这是因为进程在往管道中写入或读取数据时,其实是使用 write 或 read 函数的,而 write 和 read 函数需要通过文件描述符才能进行写数据和读数据操作。数组 pipefd 有两个元素 pipefd[0]和 pipefd[1],分别对应了管道的读描述符和写描述符。这也就是说,当进程需要从管道中读数据时,就需要用到 pipefd[0],而往管道中写入数据时,就使用 pipefd[1]。
int main()
{
int fd[2], nr = 0, nw = 0;
char buf[128];
pipe(fd);
pid = fork();
if(pid == 0) {
//子进程调用read从fd[0]描述符中读取数据
printf("child process wait for message\n");
nr = read(fds[0], buf, sizeof(buf))
printf("child process receive %s\n", buf);
}else{
//父进程调用write往fd[1]描述符中写入数据
printf("parent process send message\n");
strcpy(buf, "Hello from parent");
nw = write(fd[1], buf, sizeof(buf));
printf("parent process send %d bytes to child.\n", nw);
}
return 0;
}
2. AOF 重写的基本过程
2.1. 创建重写子进程
AOF 重写的函数是 rewriteAppendOnlyFileBackground,它是在aof.c文件中实现的。在这个函数中,会调用 fork 函数创建一个 AOF 重写子进程,然后在子进程中调用 rewriteAppendOnlyFile 函数进行 AOF 文件重写。
int rewriteAppendOnlyFileBackground(void) {
......
//创建管道
if (aofCreatePipes() != C_OK) return C_ERR;
......
//创建子进程
if ((childpid = fork()) == 0) {
......
//子进程调用rewriteAppendOnlyFile进行AOF重写
if (rewriteAppendOnlyFile(tmpfile) == C_OK) {
......
} else {
......
}
} else {
......
//记录重写子进程的进程号
server.aof_child_pid = childpid;
//关闭rehash功能
updateDictResizePolicy();
......
return C_OK;
}
return C_OK; /* unreached */
}
在这个函数中,会调用 aofCreatePipes 函数创建管道,用来实现父子进程间的通信,这个函数稍后再详细介绍。
- 子进程:调用 rewriteAppendOnlyFile 函数来完成 AOF 日志文件的重写。
- 父进程:记录子进程进程号,禁止在 AOF 重写期间进行 rehash 操作。这是因为 rehash 操作会带来较多的数据移动操作,对于 AOF 重写子进程来说,这就意味着父进程中的内存修改会比较多。因此,就需要执行更多的写时aa完成 AOF 文件的写入,这就会给 Redis 系统的性能造成负面影响。
rewriteAppendOnlyFile 函数是在 aof.c 文件中实现的。它主要会调用 rewriteAppendOnlyFileRio 函数(在 aof.c 文件中)来执行 AOF 重写操作:
int rewriteAppendOnlyFile(char *filename) {
......
if (rewriteAppendOnlyFileRio(&aof) == C_ERR) goto werr;
......
}
关于 rewriteAppendOnlyFile 函数更多的操作,稍后会在介绍进程通信中详细介绍。
rewriteAppendOnlyFileRio 具体来说,就是遍历 Redis server 的每一个数据库,把其中的每个键值对读取出来,然后记录该键值对类型对应的插入命令,以及键值对本身的内容。比如,如果读取的是一个 String 类型的键值对,那么 rewriteAppendOnlyFileRio 函数,就会记录 SET 命令和键值对本身内容;而如果读取的是 Set 类型键值对,那么它会记录 SADD 命令和键值对内容。这样一来,当需要恢复 Redis 数据库时,我们重新执行一遍 AOF 重写日志中记录的命令操作,就可以依次插入所有键值对了。
int rewriteAppendOnlyFileRio(rio *aof) {
......
for (j = 0; j < server.dbnum; j++) {
......
/* Iterate this DB writing every entry */
while((de = dictNext(di)) != NULL) {
/* Save the key and associated value */
if (o->type == OBJ_STRING) {
/* Emit a SET command */
char cmd[]="*3\r\n$3\r\nSET\r\n";
if (rioWrite(aof,cmd,sizeof(cmd)-1) == 0) goto werr;
/* Key and value */
if (rioWriteBulkObject(aof,&key) == 0) goto werr;
if (rioWriteBulkObject(aof,o) == 0) goto werr;
} else if ......
}
......
}
......
}
2.2. 同步写操作
AOF 重写子进程在执行重写操作期间,父进程仍然在接收客户端的写操作,而子进程需要把这些写操作写到重写日志里面。这就用到了刚才介绍的管道机制,在AOF 重写的函数是 rewriteAppendOnlyFileBackground 最开始,调用了aofCreatePipes 函数来创建管道,下面来看下这个函数:
int aofCreatePipes(void) {
int fds[6] = {-1, -1, -1, -1, -1, -1};
int j;
//1. 创建三个管道,每个管道对应两个FD,一个读,一个写
if (pipe(fds) == -1) goto error; /* parent -> children data. */
if (pipe(fds+2) == -1) goto error; /* children -> parent ack. */
if (pipe(fds+4) == -1) goto error; /* parent -> children ack. */
/* Parent -> children data is non blocking. */
//第一和第二个描述符设置为非阻塞
if (anetNonBlock(NULL,fds[0]) != ANET_OK) goto error;
if (anetNonBlock(NULL,fds[1]) != ANET_OK) goto error;
//注册读事件的监听,当 fds[2] 上有读事件发生时,会执行 aofChildPipeReadable 函数
if (aeCreateFileEvent(server.el, fds[2], AE_READABLE, aofChildPipeReadable, NULL) == AE_ERR) goto error;
//设置全局变量,让子进程也能访问到这些管道
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;
return C_OK;
......
}
从 server 变量的成员变量名中,看到 aofCreatePipes 函数创建的三个管道,以及它们各自的用途:
- fds[0]和 fds[1]:对应了主进程和重写子进程间用于传递操作命令的管道,它们分别对应读描述符和写描述符。
- fds[2]和 fds[3]:对应了重写子进程向父进程发送 ACK 信息的管道,它们分别对应读描述符和写描述符。
- fds[4]和 fds[5]:对应了父进程向重写子进程发送 ACK 信息的管道,它们分别对应读描述符和写描述符。
Redis 主进程在收到写操作之后,和 AOF 重写子进程的交互流程如下图所示:
下面依次来分析图中的流程:
-
在 AOF 重写期间,父进程接收到客户端写操作,会调用 feedAppendOnlyFile(在 aof.c 文件中) 函数来将接收到的写操作写入 AOF 日志,而在 feedAppendOnlyFile 函数的最后,会判断当前是否有 AOF 重写子进程在运行。如果有的话,它就会调用 aofRewriteBufferAppend 函数(在 aof.c 文件中)来写操作命令管道,如下所示:
void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc) { ...... /* If a background append only file rewriting is in progress we want to * accumulate the differences between the child DB and the current one * in a buffer, so that when the child process will do its work we * can append the differences to the new append only file. */ if (server.aof_child_pid != -1) aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf)); sdsfree(buf); }
-
aofRewriteBufferAppend 函数的作用是将字节数组参数 buf,追加写到全局变量 server 的 aof_rewrite_buf_blocks 这个列表中。
当 aofRewriteBufferAppend 函数将命令操作记录到 aof_rewrite_buf_blocks 列表中之后,它还会检查 aof_pipe_write_data_to_child 管道描述符上是否注册了写事件,这个管道描述符就对应了我刚才给你介绍的 fds[1]。如果没有注册写事件,那么 aofRewriteBufferAppend 函数就会调用 aeCreateFileEvent 函数,注册一个写事件,这个写事件会监听 aof_pipe_write_data_to_child 这个管道描述符,也就是主进程和重写子进程间的操作命令传输管道。
当这个管道可以写入数据时,写事件对应的回调函数 aofChildWriteDiffData(在 aof.c 文件中)就会被调用执行。这个过程你可以参考下面的代码:
/* Append data to the AOF rewrite buffer, allocating new blocks if needed. */ void aofRewriteBufferAppend(unsigned char *s, unsigned long len) { //从 aof_rewrite_buf_blocks 列表中取出最后一个 aofrwblock 类型的数据块 listNode *ln = listLast(server.aof_rewrite_buf_blocks); //aofrwblock 记录了重写AOF子进程过程中,主进程接收到的命令 aofrwblock *block = ln ? ln->value : NULL; while(len) { //把传入的 char *s 写到 aofrwblock 数据块中 ...... } /* Install a file event to send data to the rewrite child if there is * not one already. */ //检查aof_pipe_write_data_to_child描述符上是否有事件 if (aeGetFileEvents(server.el,server.aof_pipe_write_data_to_child) == 0) { //如果没有注册事件,那么注册一个写事件,回调函数是aofChildWriteDiffData aeCreateFileEvent(server.el, server.aof_pipe_write_data_to_child, AE_WRITABLE, aofChildWriteDiffData, NULL); } } typedef struct aofrwblock { //buf数组已用空间和剩余可用空间 unsigned long used, free; //宏定义AOF_RW_BUF_BLOCK_SIZE默认为10MB char buf[AOF_RW_BUF_BLOCK_SIZE]; } aofrwblock;
而 aofChildWriteDiffData 函数负责把 server.aof_rewrite_buf_blocks 列表块中的数据取出来,并写到 aof_pipe_write_data_to_child 管道中:
/* Event handler used to send data to the child process doing the AOF * rewrite. We send pieces of our AOF differences buffer so that the final * write when the child finishes the rewrite will be small. */ void aofChildWriteDiffData(aeEventLoop *el, int fd, void *privdata, int mask) { listNode *ln; aofrwblock *block; ...... while(1) { //从aof_rewrite_buf_blocks列表中取出数据块 ln = listFirst(server.aof_rewrite_buf_blocks); block = ln ? ln->value : NULL; if (server.aof_stop_sending_diff || !block) { aeDeleteFileEvent(server.el,server.aof_pipe_write_data_to_child, AE_WRITABLE); return; } if (block->used > 0) { //调用write将数据块写入主进程和重写子进程间的管道 nwritten = write(server.aof_pipe_write_data_to_child, block->buf,block->used); ...... } } }
-
子进程从管道中读取父进程写操作的逻辑是在 aofReadDiffFromParent 函数中,它会将读取的操作命令追加到全局变量 server 的 aof_child_diff 字符串中。而在 AOF 重写函数 rewriteAppendOnlyFile 的执行过程最后,aof_child_diff 字符串会被写入 AOF 重写日志文件,以便我们在使用 AOF 重写日志时,能尽可能地恢复重写期间收到的操作。
/* This function is called by the child rewriting the AOF file to read * the difference accumulated from the parent into a buffer, that is * concatenated(连接) at the end of the rewrite. */ ssize_t aofReadDiffFromParent(void) { //管道默认的缓冲区大小 char buf[65536]; /* Default pipe buffer size on most Linux systems. */ ssize_t nread, total = 0; //调用read函数从aof_pipe_read_data_from_parent中读取数据 while ((nread = read(server.aof_pipe_read_data_from_parent,buf,sizeof(buf))) > 0) { server.aof_child_diff = sdscatlen(server.aof_child_diff,buf,nread); total += nread; } return total; }
而 aofReadDiffFromParent 函数被调用的时机有下面三种:
- rewriteAppendOnlyFileRio 函数:这个函数是由重写子进程执行的,它负责遍历 Redis 每个数据库,生成 AOF 重写日志,在这个过程中,它会不时地调用 aofReadDiffFromParent 函数。
- rewriteAppendOnlyFile 函数:这个函数是重写日志的主体函数,也是由重写子进程执行的,它本身会调用 rewriteAppendOnlyFileRio 函数。此外,它在调用完 rewriteAppendOnlyFileRio 函数后,还会多次调用 aofReadDiffFromParent 函数,以尽可能多地读取主进程在重写日志期间收到的操作命令。
- rdbSaveRio 函数:这个函数是创建 RDB 文件的主体函数。当我们使用 AOF 和 RDB 混合持久化机制时,这个函数也会调用 aofReadDiffFromParent 函数。
-
子进程向父进程写入"!",让主进程停止写入新的操作。这一部分逻辑是在重写子进程的主体函数 rewriteAppendOnlyFile 中,实现如下所示:
int rewriteAppendOnlyFile(char *filename) { /* Ask the master to stop sending diffs. */ //这就是重写子进程向主进程发送 ACK 信号,让主进程停止发送收到的新写操作 if (write(server.aof_pipe_write_ack_to_parent,"!",1) != 1) goto werr; }
-
父进程读取子进程 ACK 管道中的 "!"。在创建管道的时候,会调用事件驱动框架的创建事件函数,注册读取函数 aofChildPipeReadable。并判断写入的字符是否是"!"
/* This event handler is called when the AOF rewriting child sends us a * single '!' char to signal we should stop sending buffer diffs. The * parent sends a '!' as well to acknowledge. */ void aofChildPipeReadable(aeEventLoop *el, int fd, void *privdata, int mask) { ...... if (read(fd,&byte,1) == 1 && byte == '!') { ...... } }
-
父进程向子进程写入"!",确认收到子进程的 ACK。同样也是在 aofChildPipeReadable 函数中
if (write(server.aof_pipe_write_ack_to_child,"!",1) != 1) { /* If we can't send the ack, inform the user, but don't try again * since in the other side the children will use a timeout if the * kernel can't buffer our write, or, the children was * terminated. */ serverLog(LL_WARNING,"Can't send ACK to AOF child: %s", strerror(errno)); }
-
子进程同步读取父进程的确认ACK。在重写子进程主体函数 rewriteAppendOnlyFile 中,调用 syncRead 函数同步读取父进程的确认ACK。并设置了超时时间,看注释上说是10s,但是代码里面传参好像是5s,这个先忽略吧。如果经过超时时间未读取到父进程的ACK,就退出子进程。
/* We read the ACK from the server using a 10 seconds timeout. Normally * it should reply ASAP, but just in case we lose its reply, we are sure * the child will eventually get terminated. */ //从 aof_pipe_read_ack_from_parent 管道描述符上,读取主进程发送给它的 ACK 信息 if (syncRead(server.aof_pipe_read_ack_from_parent,&byte,1,5000) != 1 || byte != '!') goto werr;
2.3. 触发时机
前面大致讨论了一下 AOF 重写的执行流程,接下来说一下 AOF 重写在什么时候会被触发。
AOF 重写的函数 rewriteAppendOnlyFileBackground 一共会在三个地方被调用:
- bgrewriteaofCommand 函数:对应了我们在 Redis server 上执行 bgrewriteaof 命令,也就是说,我们手动触发了 AOF rewrite 的执行。
- startAppendOnly 函数:该函数是在执行开启 AOF 功能的 config 命令和 主从节点的复制过程中被调用。简单来说,就是当主从节点在进行复制时,如果从节点的 AOF 选项被打开,那么在加载解析 RDB 文件时,AOF 选项就会被关闭。然后,无论从节点是否成功加载了 RDB 文件,都会恢复被关闭的 AOF 功能,进而触发 AOF 重写。
- serverCron 函数:serverCron 函数是会被周期性执行的,它在执行的过程中,会做两次判断来决定是否执行 AOF 重写。
这里重点介绍下第三个函数 serverCron 函数,他是在 server.c 文件中,redis一启动,就会注册一个时间事件,该事件1ms后会被触发,触发后的回调函数就是这个 serverCron 函数,然后这个函数会每100ms(默认,取决于 hz 配置)执行一次。
首先进行三个判断,如果没有RDB子进程,也没有AOF重写子进程,并且AOF重写被设置为待调度执行,那么调用rewriteAppendOnlyFileBackground函数进行AOF重写。接下来看下 server.aof_rewrite_scheduled 什么时候会被设置未1。
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
......
/* Start a scheduled AOF rewrite if this was requested by the user while
* a BGSAVE was in progress. */
//如果没有RDB子进程,也没有AOF重写子进程,并且AOF重写被设置为待调度执行,
//那么调用rewriteAppendOnlyFileBackground函数进行AOF重写
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 &&
server.aof_rewrite_scheduled)
{
rewriteAppendOnlyFileBackground();
}
.......
}
同样在 serverCron 函数中,会接下来进行一个判断:
/* Trigger an AOF rewrite if needed. */
//如果AOF功能启用、没有RDB子进程和AOF重写子进程在执行、
//AOF文件大小比例设定了阈值,以及AOF文件大小绝对值超出了阈值,那么,进一步判断AOF文件大小比例是否超出阈值
if (server.aof_state == AOF_ON &&
server.rdb_child_pid == -1 &&
server.aof_child_pid == -1 &&
server.aof_rewrite_perc &&
server.aof_current_size > server.aof_rewrite_min_size)
{
//计算AOF文件当前大小超出基础大小的比例
long long base = server.aof_rewrite_base_size ?
server.aof_rewrite_base_size : 1;
long long growth = (server.aof_current_size*100/base) - 100;
//如果AOF文件当前大小超出基础大小的比例已经超出预设阈值,那么执行AOF重写
if (growth >= server.aof_rewrite_perc) {
serverLog(LL_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
rewriteAppendOnlyFileBackground();
}
}
那么,从这里的代码中,你会看到,为了避免 AOF 文件过大导致占用过多的磁盘空间,以及增加恢复时长,你其实可以通过设置 redis.conf 文件中的以下两个阈值,来让 Redis server 自动重写 AOF 文件。
- auto-aof-rewrite-percentage:AOF 文件大小超出基础大小的比例,默认值为 100%,即超出 1 倍大小。
- auto-aof-rewrite-min-size:AOF 文件大小绝对值的最小值,默认为 64MB。
在这里有一个比较关键的变量 server.aof_rewrite_base_size,在aof.c文件的 backgroundRewriteDoneHandler 函数中,会把 server.aof_rewrite_base_size 设置为 server.aof_current_size。从命名上看应该是在 AOF 重写操作完成后,会调这个函数。但是我的编辑器有问题,看不出来是哪里调用了他,这个问题后面在公司电脑上看看能不能解决。
void backgroundRewriteDoneHandler(int exitcode, int bysignal) {
if (server.aof_fd == -1) {
/* AOF disabled, we don't need to set the AOF file descriptor
* to this new file, so we can close it. */
close(newfd);
} else {
/* AOF enabled, replace the old fd with the new one. */
......
server.aof_rewrite_base_size = server.aof_current_size;
......
}
3. 潜在风险
AOF 重写机制可能引起 Redis 主线程阻塞的原因:
Redis 主线程 fork 创建 bgrewriteaof 子进程时,内核需要创建用于管理子进程的相关数据结构,这些数据结构在操作系统中通常叫作进程控制块(Process Control Block,简称为 PCB)。内核要把主线程的 PCB 内容拷贝给子进程。这个创建和拷贝过程由内核执行,是会阻塞主线程的。而且,在拷贝过程中,子进程要拷贝父进程的页表,这个过程的耗时和 Redis 实例的内存大小有关。如果 Redis 实例内存大,页表就会大,fork 执行时间就会长,这就会给主线程带来阻塞风险。
bgrewriteaof 子进程会和主线程共享内存。当主线程收到新写或修改的操作时,主线程会申请新的内存空间,用来保存新写或修改的数据,如果操作的是 bigkey,也就是数据量大的集合类型数据,那么,主线程会因为申请大空间而面临阻塞风险。因为操作系统在分配内存空间时,有查找和锁的开销,这就会导致阻塞。
-
AOF 重写会对磁盘进行大量 IO 操作,同时,fsync 又需要等到数据写到磁盘后才能返回,所以,当 AOF 重写的压力比较大时,就会导致 fsync 被阻塞。虽然 fsync 是由后台子线程负责执行的,但是,主线程会监控 fsync 的执行进度。
当主线程使用后台子线程执行了一次 fsync,需要再次把新接收的操作记录写回磁盘时,如果主线程发现上一次的 fsync 还没有执行完,那么它就会阻塞。所以,如果后台子线程执行的 fsync 频繁阻塞的话(比如 AOF 重写占用了大量的磁盘 IO 带宽),主线程也会阻塞,导致 Redis 性能变慢。
如果业务应用对延迟非常敏感,但同时允许一定量的数据丢失,那么,可以把配置项 no-appendfsync-on-rewrite 设置为 yes,如下所示:
no-appendfsync-on-rewrite yes
这个配置项设置为 yes 时,表示在 AOF 重写时,不进行 fsync 操作。也就是说,Redis 实例把写命令写到内存后,不调用后台线程进行 fsync 操作,就可以直接返回了。当然,如果此时实例发生宕机,就会导致数据丢失。反之,如果这个配置项设置为 no(也是默认配置),在 AOF 重写时,Redis 实例仍然会调用后台线程进行 fsync 操作,这就会给实例带来阻塞。
参考资料:
- 极客时间专栏《Redis源码剖析与实战》.蒋德钧.2021
- 极客时间专栏《Redis核心技术与实战》.蒋德钧.2020
- 极客时间专栏《趣谈Linux操作系统》.刘超.2019
- Redis 5.0.14 源码:https://github.com/redis/redis/tree/5.0