Redis享知享学:RDB原理解析

本篇将聚焦RDB的基本工作原理。

父子进程是如何执行代码的?server.dirty_before_bgsave的作用是什么?走进这篇文章,慢慢走近这些问题的答案。

RDB是Redis提供的基于快照存储的持久化模式,核心包括保存和载入两个功能,先来看看保存功能的实现。

rdbSave

保存是rdbSave()提供的,这个方法有点长,但并不复杂,主要功能如下:

  • 创建临时文件,保存的过程中会先创建一个临时文件;
  • 写入文件前的准备工作,包括初始化IO、设置校验、写入版本号等;
  • 遍历数据库,写入临时文件;
  • 将临时文件覆盖原RDB文件;
  • 做好“善后工作”,比如考虑程序的鲁棒性,对各种可能情况设置好处理方法,并且保存/清除各种需要/不需要的数据,关闭打开的文件等等;

看看第三步中是怎么写入临时文件的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Iterate this DB writing every entry
*
* 遍历数据库,并写入每个键值对的数据
*/
while((de = dictNext(di)) != NULL) {
sds keystr = dictGetKey(de);
robj key, *o = dictGetVal(de);
long long expire;
// 根据 keystr ,在栈中创建一个 key 对象
initStaticStringObject(key,keystr);
// 获取键的过期时间
expire = getExpire(db,&key);
// 保存键值对数据
if (rdbSaveKeyValuePair(&rdb,&key,o,expire,now) == -1) goto werr;
}

di是迭代器,de是字典迭代过程中哈希表内的实体,保存着字典真正存储的键值对。因为字典的键都是Redis的字符串形式,所以需要一个initStaticStringObject转换一下,最后调用rdbSaveKeyValuePair函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val,
long long expiretime, long long now)
{
/* Save the expire time
*
* 保存键的过期时间
*/
if (expiretime != -1) {
/* If this key is already expired skip it
*
* 不写入已经过期的键
*/
if (expiretime < now) return 0;
if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
}
/* Save type, key, value
*
* 保存类型,键,值
*/
if (rdbSaveObjectType(rdb,val) == -1) return -1;
if (rdbSaveStringObject(rdb,key) == -1) return -1;
if (rdbSaveObject(rdb,val) == -1) return -1;
return 1;
}

可见它保存了过期时间、类型、键以及值,已经在此刻过期的键就不会保存。

在Redis中,可能调用rdbSave的命令包括flushallCommand、debugCommand、rdbSaveBackground、saveCommand、prepareForShutdown。下面就来研究直接保存和后台保存命令的实现。

save && bgsave

save

首先看看save,Redis会调用saveCommand函数,它的全部代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void saveCommand(redisClient *c) {
// BGSAVE 已经在执行中,不能再执行 SAVE
// 否则将产生竞争条件
if (server.rdb_child_pid != -1) {
addReplyError(c,"Background save already in progress");
return;
}
// 执行
if (rdbSave(server.rdb_filename) == REDIS_OK) {
addReply(c,shared.ok);
} else {
addReply(c,shared.err);
}
}

所以save不是任何时刻都可以执行成功,server.rdb_child_pid不等于-1说明存在执行bgsave的子进程,此时saveCommand()会直接返回。

从上面的代码可以看出,saveCommand()基本上就是简单封装了rdbSave函数。

bgsave

再来看看bgsave,Redis会调用bgsaveCommand函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void bgsaveCommand(redisClient *c) {
// 不能重复执行 BGSAVE
if (server.rdb_child_pid != -1) {
addReplyError(c,"Background save already in progress");
// 不能在 BGREWRITEAOF 正在运行时执行
} else if (server.aof_child_pid != -1) {
addReplyError(c,"Can't BGSAVE while AOF log rewriting is in progress");
// 执行 BGSAVE
} else if (rdbSaveBackground(server.rdb_filename) == REDIS_OK) {
addReplyStatus(c,"Background saving started");
} else {
addReply(c,shared.err);
}
}

除了不能重复执行,也不能在aof子进程存在的时候执行。具体核心代码又封装在rdbSaveBackground()中,代码直接贴出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
int rdbSaveBackground(char *filename) {
pid_t childpid;
long long start;
if (server.rdb_child_pid != -1) return REDIS_ERR;
// 记录后台保存前服务器状态
server.dirty_before_bgsave = server.dirty;
// 开始时间
start = ustime();
// 创建子进程
if ((childpid = fork()) == 0) {
int retval;
/* Child */
// 子进程不接收网络数据
if (server.ipfd > 0) close(server.ipfd);
if (server.sofd > 0) close(server.sofd);
// 保存数据
retval = rdbSave(filename);
if (retval == REDIS_OK) {
size_t private_dirty = zmalloc_get_private_dirty();
if (private_dirty) {
redisLog(REDIS_NOTICE,
"RDB: %lu MB of memory used by copy-on-write",
private_dirty/(1024*1024));
}
}
// 退出子进程
exitFromChild((retval == REDIS_OK) ? 0 : 1);
} else {
/* Parent */
// 记录最后一次 fork 的时间
server.stat_fork_time = ustime()-start;
// 创建子进程失败时进行错误报告
if (childpid == -1) {
redisLog(REDIS_WARNING,"Can't save in background: fork: %s",
strerror(errno));
return REDIS_ERR;
}
redisLog(REDIS_NOTICE,"Background saving started by pid %d",childpid);
// 记录保存开始的时间
server.rdb_save_time_start = time(NULL);
// 记录子进程的 id
server.rdb_child_pid = childpid;
// 在执行时关闭对数据库的 rehash
// 避免 copy-on-write
updateDictResizePolicy();
return REDIS_OK;
}
return REDIS_OK; /* unreached */
}

调用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了。

试想,在子进程保存数据的时候中,父进程仍然可能在不停更改数据,即使本次子进程保存数据成功,仍然需要计算之后父进程更新了多少数据,通过判断更新的量是否达到设定的阈值,来触发后台周期性保存功能,进行新的数据保存。更新了多少数据可以通过下面的代码完成:

1
server.dirty = server.dirty - server.dirty_before_bgsave;

为了后续行文指代的方便,将这段代码称为D代码,D存在于backgroundSaveDoneHandler()当中,并且,D应该在下一次调用rdbSaveBackground()之前执行。

在Redis中serverCron函数每隔一段时间就会执行一次,而backgroundSaveDoneHandler()就包含于其中,来看看包含它的关键的一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1) {
int statloc;
pid_t pid;
if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
int exitcode = WEXITSTATUS(statloc);
int bysignal = 0;
if (WIFSIGNALED(statloc)) bysignal = WTERMSIG(statloc);
if (pid == server.rdb_child_pid) {
backgroundSaveDoneHandler(exitcode,bysignal);
} else if (pid == server.aof_child_pid) {
backgroundRewriteDoneHandler(exitcode,bysignal);
} else {
redisLog(REDIS_WARNING,
"Warning, detected child with unmatched pid: %ld",
(long)pid);
}
// 如果 BGSAVE 和 BGREWRITEAOF 都已经完成,那么重新开始 REHASH
updateDictResizePolicy();
}
} else {
/* If there is not a background saving/rewrite in progress check if
* we have to save/rewrite now */
// 如果有需要,开始 RDB 文件的保存
for (j = 0; j < server.saveparamslen; j++) {
struct saveparam *sp = server.saveparams+j;
if (server.dirty >= sp->changes &&
server.unixtime-server.lastsave > sp->seconds) {
redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...",
sp->changes, sp->seconds);
rdbSaveBackground(server.rdb_filename);
break;
}
}
...
}

在最后一个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,调用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void loadDataFromDisk(void) {
long long start = ustime();
// 如果开启了 AOF 功能,那么优先使用 AOF 文件来还原数据
if (server.aof_state == REDIS_AOF_ON) {
if (loadAppendOnlyFile(server.aof_filename) == REDIS_OK)
redisLog(REDIS_NOTICE,"DB loaded from append only file: %.3f seconds",(float)(ustime()-start)/1000000);
} else {
// 在没有开启 AOF 功能时,才使用 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. Exiting.");
exit(1);
}
}
}

从这段代码可以看出,一旦打开了AOF功能,那么程序优先使用AOF还原数据。

rdbLoad()在加载的过程中,会安插如下代码:

1
2
3
4
5
6
7
8
/* Serve the clients from time to time */
// 间隔性服务客户端
if (!(loops++ % 1000)) {
// 刷新载入进程信息
loadingProgress(rioTell(&rdb));
// 处理事件
aeProcessEvents(server.el, AE_FILE_EVENTS|AE_DONT_WAIT);
}

每次读取一个键的信息,每读取1000次刷新载入进程信息,处理其它的请求。

小结

通过本文的解析,了解了RDB保存和载入的基本原理,对其中一些实现细节有大致的掌握。RDB在后台保存时会利用操作系统写时复制机制,不至于在内存中完整复制一份要保存的数据,从而减少开销,下一篇将重点了解此机制。

参考

Redis 设计与实现:国内解析Redis的开源资料;

附录

上文rdbSaveBackground()代码中,zmalloc_get_private_dirty实际返回0,该方法可能用于日志功能的扩展:

1
2
3
size_t zmalloc_get_private_dirty(void) {
return 0;
}