本篇将聚焦RDB的基本工作原理。
父子进程是如何执行代码的?server.dirty_before_bgsave的作用是什么?走进这篇文章,慢慢走近这些问题的答案。
RDB是Redis提供的基于快照存储的持久化模式,核心包括保存和载入两个功能,先来看看保存功能的实现。
rdbSave
保存是rdbSave()提供的,这个方法有点长,但并不复杂,主要功能如下:
- 创建临时文件,保存的过程中会先创建一个临时文件;
- 写入文件前的准备工作,包括初始化IO、设置校验、写入版本号等;
- 遍历数据库,写入临时文件;
- 将临时文件覆盖原RDB文件;
- 做好“善后工作”,比如考虑程序的鲁棒性,对各种可能情况设置好处理方法,并且保存/清除各种需要/不需要的数据,关闭打开的文件等等;
看看第三步中是怎么写入临时文件的:
|
|
di是迭代器,de是字典迭代过程中哈希表内的实体,保存着字典真正存储的键值对。因为字典的键都是Redis的字符串形式,所以需要一个initStaticStringObject转换一下,最后调用rdbSaveKeyValuePair函数:
|
|
可见它保存了过期时间、类型、键以及值,已经在此刻过期的键就不会保存。
在Redis中,可能调用rdbSave的命令包括flushallCommand、debugCommand、rdbSaveBackground、saveCommand、prepareForShutdown。下面就来研究直接保存和后台保存命令的实现。
save && bgsave
save
首先看看save,Redis会调用saveCommand函数,它的全部代码:
|
|
所以save不是任何时刻都可以执行成功,server.rdb_child_pid不等于-1说明存在执行bgsave的子进程,此时saveCommand()会直接返回。
从上面的代码可以看出,saveCommand()基本上就是简单封装了rdbSave函数。
bgsave
再来看看bgsave,Redis会调用bgsaveCommand函数:
|
|
除了不能重复执行,也不能在aof子进程存在的时候执行。具体核心代码又封装在rdbSaveBackground()中,代码直接贴出:
|
|
调用fork()会创建新的进程,有三种情况:
- fork()如果创建子进程成功,会返回0给子进程;
- fork()如果创建子进程成功,会返回子进程的pid号给父进程;
- fork()执行发生错误,会返回-1(此时创建新的进程失败,也就无所谓父进程子进程,返回给当前进程);
一旦childpid = fork()
创建子进程成功,则从这个地方开始,将有父子两个进程同时向下按各自逻辑执行代码,下面考虑这种情况下的父子进程的情况:
—–子进程—–
对子进程来说,由于fork()返回了0给它,它会执行从上向下数第二个if语句的内容,断掉该进程的网络连接进而不接受新进的数据,调用rdbSave()进行存储,当它执行到exitFromChild(),内部通过_exit()就结束进程。
—–父进程—–
由于fork()会返回子进程的pid给父进程,所以父进程不会执行第二个if语句中的内容,它会向下执行else中的内容,进行一些日志记录啦,打印一些可能发生的错误报告啦等等,最后返回REDIS_OK。
Redis通过RDB进行持久化的保存功能基本解析完毕了,现在的问题是,bgsave命令执行后,后台保存是一个间断性实施的过程,这个功能如何实现的?
服务器的dirty
server.dirty用来记录更改过却没有被持久化的键的个数。
rdbSaveBackground()在子进程创建之前执行server.dirty_before_bgsave = server.dirty;
,是因为一旦子进程创建之后,由于dirty在父进程和子进程中都会被更改,而它更改后的值分别保存在各自进程拷贝的内存页中,所以要在之前记录一个dirty的锚点,方便后续使用。在之前的文章中,本人提到了操作系统的写时复制(COW),Redis在这方面利用了操作系统的写时复制机制,下一篇文章将进一步阐述原理,这里只需要暂时知道,在dirty更改前,两个进程的dirty指针指向同一内存地址,一旦变更,那么父进程的dirty不再是子进程的dirty了。
试想,在子进程保存数据的时候中,父进程仍然可能在不停更改数据,即使本次子进程保存数据成功,仍然需要计算之后父进程更新了多少数据,通过判断更新的量是否达到设定的阈值,来触发后台周期性保存功能,进行新的数据保存。更新了多少数据可以通过下面的代码完成:
|
|
为了后续行文指代的方便,将这段代码称为D代码
,D存在于backgroundSaveDoneHandler()当中,并且,D应该在下一次调用rdbSaveBackground()之前执行。
在Redis中serverCron函数每隔一段时间就会执行一次,而backgroundSaveDoneHandler()就包含于其中,来看看包含它的关键的一段代码:
|
|
在最后一个for循环里,当数据更新的数目和距离上一次保存的时间均大于设定的数目以及设定的时间时,程序将执行rdbSaveBackground函数,又一次后台保存数据。
一开始本人疑惑的是,如果子进程需要保存的数据量很小且执行的速度非常快,那么执行完毕需要的时间就会小于serverCron()调用的间隔时间,在暂时不考虑AOF的情况下,if (server.rdb_child_pid != -1 || server.aof_child_pid != -1)
括号内总是返回false,程序就会跳转到else语句去执行最后一个for循环,rdbSaveBackground()就有可能在D代码之前执行。
后来才发现,server.rdb_child_pid != -1
并不代表子进程真的在执行,rdb_child_pid属性的更改回-1的状态并不是在子进程结束后马上发生的:
- 在rdbSaveBackground()中,父进程在从函数返回之前会执行
server.rdb_child_pid = childpid;
,将rdb_child_pid属性属性设置为非-1状态; - 在backgroundSaveDoneHandler()中,在D代码执行之后,会通过
server.rdb_child_pid = -1;
将rdb_child_pid属性属性设置为-1状态(D代码未执行表示子进程未保存成功,那么此时也应该将rdb_child_pid属性设置为-1);
如此程序保证了在上一次进程对数据保存成功的情况下,D代码一定会在下一次调用rdbSaveBackground()之前执行。
rdbLoad
Redis的main()会调用loadDataFromDisk(),而loadDataFromDisk()会调用rdbLoad,调用方式如下:
|
|
从这段代码可以看出,一旦打开了AOF功能,那么程序优先使用AOF还原数据。
rdbLoad()在加载的过程中,会安插如下代码:
|
|
每次读取一个键的信息,每读取1000次刷新载入进程信息,处理其它的请求。
小结
通过本文的解析,了解了RDB保存和载入的基本原理,对其中一些实现细节有大致的掌握。RDB在后台保存时会利用操作系统写时复制机制,不至于在内存中完整复制一份要保存的数据,从而减少开销,下一篇将重点了解此机制。
参考
Redis 设计与实现:国内解析Redis的开源资料;
附录
上文rdbSaveBackground()代码中,zmalloc_get_private_dirty实际返回0,该方法可能用于日志功能的扩展:
|
|