不识庐山真面目,只缘身在此山中
我们先给出一副大图,来看看Redis AOF Rewrite的总体流程是怎么样的。
先看看大图里的几大组成部分
- 主进程与子进程,大家都知道,Redis AOF Rewrite是通过创建一个子进程来完成的。父子进程有一个重要特性,那就是”读时共享,写时复制”。后面我们详细聊
- 父子进程间通信使用的三个通道。
- 客户端写入Redis主进程时,涉及到的两个数据结构,aof_buf,aof_rewrite_buf_blocks;子进程涉及到的aof_child_diff数据结构。
- 一份当前使用的AOF文件,这份文件是准备退休的”现役”文件,另一份是子进程正在重写的”预备役”文件。
大致涉及的内容就是如此了,接下来按照一个AOF Rewrite执行的时间顺序来看看到底发生了什么事
万般皆由长风起
先来看看,Redis AOF Rewrite机制是怎么触发的呢?
有两种方式大家应该都很清楚了,一种是配置文件中配置的若干时间内,发生了若干键值对的变化,达到阈值就需要触发一次重写。
(这里补充一句,这个检查是在Redis后台主线程中调度时检查的,这个时间并不会是很确定的)
所谓的serverCron就是这个方法,何时触发,如何触发,我们回头细说
另一种是客户端,要求执行bgrewriteaof。
这里写的第三种,是在开启AOF时,才会进行一次。一般是在启动时就完成了AOF的启动。
但这里有一种特殊情况,就是在Redis有主从时。从服务在跟主服务同步数据时,主服务会生成一个RDB文件给从,从使用假客户端读取数据恢复至内存。这个阶段,从服务是需要停止AOF(如果原来开启的话)的。等到完成了主从同步的数据恢复后,自动开启AOF,这里就会执行一次AOF的重写。
山一程,水一程,fork子进程
不管是什么原因,当确定要进行AOF Rewrite之后,首先做的就是进行一系列检查,然后fork一个子进程。
有红线的地方就是fork子进程的地方。
if语句中的是子进程需要执行的代码,else中是主进程的。
先不着急研究主子进程分别做了什么事,看下前面的校验。
首先,如果有AOF重写子进程或RDB重写进程存在的话,就不进行本次的重写。
其次,如果创建主子进程的管道失败的话,也不进行重写。
创建子进程成功之后,子进程就会立即开始AOF文件的重写,而主进程则是继续提供对外服务,只是为了确保重写期间AOF不丢失,会多做几步操作。
这里创建的管道十分重要,一共有三个:
提示:由于fork子进程会让主子进程共享内存,那么子进程一定是要知道主进程原有的数据存储在哪里的。
这里就涉及到将原有主进程的页表复制一份过来的操作。这个操作是阻塞的,会导致fork操作卡住。
因此在流量大的时候,要注意AOF Rewrite将Redis卡住,而使RT增大。
话分两头
▐ 子进程
我们看看子进程都做了啥:
- 创建一个临时文件,名字是temp-rewriteaof-{pid}的文件,然后初始化一下文件句柄之类的引用。
- 判断是否是RDB混合模式,还是纯AOF模式,进行重写。这两者的区别,这里就不赘述了。
真正的重写其实很简单,就是挨个的读取db的内容,然后以对应的格式,写入文件中。
由于主子进程之间有”读时共享,写时复制”的限制,也就是如果是读取时两者公用一份内容,当有人要写的数据时,会将原有数据copy出来一份,在新的copy的数据上修改,旧保持不变。
Redis就利用了这一功能,保证了读到的数据是fork之前的最后版本的数据。
到这里为止,其实是AOF Rewrite的核心逻辑,其余的逻辑都是围绕在AOF Rewrite期间有数据发生变化来做的。
整个重写是非常耗费CPU的,趁着子进程加班加点干活时,我们来看一眼主进程在做什么。
▐ 主进程
主进程在fork完子进程,把ORK交代给子进程之后,就对子进程不管不问了。偶尔检查一下子进程有没有把工作完成(通信管道有没有新数据/子进程有没有消失)。
假设在AOF重写过程中,有客户端发来了一个set a 1的请求,会将原来的a的值由0改为1。
由于有主子进程”读时共享,写时复制”的存在,不用担心子进程,它会读到老的数据。
在完成内存数据变化后,会走到下面这个方法中。我们仔细来看看。
这个方法在aof.c文件中,所有写aof的操作都走这个方法。
它做了一下几件事:
- Redis是有多个db的,如果命令操作的db不是当前的db,那么就会插入一条select db的命令。根据ditcid参数来确定
- 将带有过期时间的操作,转换为PEXPIREAT(EXPIRE/PEXPIRE/EXPIREAT/SETEX/PSETEX/SET [EX seconds][PX milliseconds])
- 将操作的命令,转换为RESP格式
- 写入AOF相关缓存
这里与AOF Rewrite相关的,是步骤4,我们重点来看下这个步骤做了什么:
首先,判断是否开启了AOF,那显然我们是开启了的啊,就需要将这条语句,写入旧的AOF文件中。这个很合理啊,万一重写失败了呢,数据不可以丢啊。
其次,如果有aof子进程pid存在,那么还要多做一步aofRewriteBufferAppend(),这个是做什么的呢?
它是将刚刚生成的语句,再次保存在一个aof_rewrite_buf_blocks的结构当中。
这个aof_rewrite_buf_blocks是一个list结构,它保存的都是10M大小一个的block。block中存储的就是刚刚生成的语句。
然后方法返回。本次aof写入操作结束。
aof_rewrite_buf_blocks的数据,会等待创建的管道1是否允许写入(写入时机由别的机制保证,这里略过),如果允许写入,就将数据写入这个管道中,然后将内存中写入部分的数据释放。
提示:这里需要注意,aof_buf与aof_rewrite_buf_blocks是两个数据结构,里面的数据也是两份,不是公用一份。
因此重写阶段,数据变更会让主进程将这些数据在内存中存储两份,这对主进程是额外的压力。
子进程拿到第一个KR了
我们把目光再次回到子进程身上。
此时它已经完成对原有数据的重写,拿到第一个KR,我们恭喜一下它~
现在我们知道了Rewrite期间变化的数据,是会通过管道通知的。那么子进程是如何处理的么?
▐ 点点滴滴,聚水成河
其实在“子进程”章节中重写旧数据时,就开始处理了。子进程在重写旧数据时,会时不时的读取一下管道。
rdbSaveRio方法中
rewriteAppendOnlyFileRio方法中
这些读取出来的数据都会保存在aof_child_diff的数据结构中。
这样旧数据会直接写入到aof重写文件中,期间变化的数据会保存在内存aof_child_diff中,数据顺序就不会混乱了。最后把这部分变化的数据统一写入待aof重写文件中即可。
▐ 最重要的是向上管理
子进程在完成第一个KR,重写了旧数据之后,就马不停蹄的开展了下一项工作。从管道1中读取变化的数据。
这里有两个点需要注意,一个是主进程可能一直在接受新的数据,这就导致通道1中永远有数据,子进程就无限制的读取数据,这肯定是不能接受到,因此,限制了最多只会读取1s。
另一个,如果一直没有数据,也没必要一直等啊,大家时间都很宝贵的,如果20毫秒没有数据,那么子进程也就不会读取了。
下面就到了关键一步,要通知主进程自己的工作已经完成了80%,进行向上管理了。
子进程向通道2写入一个!号,通知主进程,请停止向管道1当中写入数据
▐ 主进程接受会邀
主进程在接受到通道2的通知之后,将aof_stop_sending_diff设置为1,变化的数据仍旧进入aof_rewrite_buf_blocks中,但是不会在写入通道1中了,全部停留在自己的内存中。
主进程接着向通道3中写入一个!,表明自己停止写入数据。
▐ 临门一脚
子进程收到通知后,最后将通道1中的数据全部读取出来,然后刷入磁盘,并将aof重写文件重写命名为temp-rewriteaof-bg-{pid}.aof,然后自己退出进程。
这里的写入,就是指将内存中的aof_child_buf中的数据一股脑的写入文件当中,然后随着进程退出,内存一并释放。
主进程:我来兜底!
此时子进程已经完成了它的工作,退出了进程,主进程在serverCron中发现子进程已经不存在了之后,就调用backgroundRewriteDoneHandler方法,处理善后的工作。
- 将之前aof_rewrite_buf_blocks还存在的数据,写入aof重写文件之中。(temp-rewriteaof-bg-{pid}.aof)
- 将aof文件设置为重写文件,重写aof正式转正,旧文件退休。
道由白云尽,春与青溪长
到此为止,一次完整的AOF Rewrite重写就结束了。
纵观整个过程,我们可以看到,核心的重点是如何处理AOF Rewrite期间变化的数据。
为了保证这部分的数据正确,Redis 5.0 版本总共使用了两个内存结构存储(主进程的aof_rewrite_buf_blocks,子进程的aof_child_diff),两个磁盘IO(主进程写入旧AOF文件,子进程写入新AOF重写文件),三个通信管道,双倍的CPU开销来完成。
有兴趣的同学可以了解下还未正式发布的Redis 7.0,Multi Part AOF的实现。这个版本的实现完美解决了上述的冗余问题。
好的,今天的分享就到此为止啦,感谢大家。