RDB 创建的入口函数和触发时机
Redis 源码中用来创建 RDB 文件的函数有三个,它们都是在rdb.c文件中实现的。
- rdbSave 函数
这是 Redis server 在本地磁盘创建 RDB 文件的入口函数。它对应了 Redis 的 save 命令,会在 save 命令的实现函数 saveCommand(在 rdb.c 文件中)中被调用。而 rdbSave 函数最终会调用 rdbSaveRio 函数(在 rdb.c 文件中)来实际创建 RDB 文件。rdbSaveRio 函数的执行逻辑就体现了 RDB 文件的格式和生成过程。
- rdbSaveBackground 函数
这是 Redis server 使用后台子进程方式,在本地磁盘创建 RDB 文件的入口函数。它对应了 Redis 的 bgsave 命令,会在 bgsave 命令的实现函数 bgsaveCommand(在 rdb.c 文件中)中被调用。这个函数会调用 fork 创建一个子进程,让子进程调用 rdbSave 函数来继续创建 RDB 文件,而父进程,也就是主线程本身可以继续处理客户端请求。
int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
...
if ((childpid = fork()) == 0) { //子进程的代码执行分支
...
retval = rdbSave(filename,rsi); //调用rdbSave函数创建RDB文件
...
exitFromChild((retval == C_OK) ? 0 : 1); //子进程退出
} else {
... //父进程代码执行分支
}
}
- rdbSaveToSlavesSockets 函数
这是 Redis server 在采用不落盘方式传输 RDB 文件进行主从复制时,创建 RDB 文件的入口函数。它会被 startBgsaveForReplication 函数调用(在replication.c文件中)。而 startBgsaveForReplication 函数会被 replication.c 文件中的 syncCommand 函数和 replicationCron 函数调用,这对应了 Redis server 执行主从复制命令,以及周期性检测主从复制状态时触发 RDB 生成。
rdbSaveToSlavesSockets 函数也是通过 fork 创建子进程,让子进程生成 RDB。不过和 rdbSaveBackground 函数不同的是,rdbSaveToSlavesSockets 函数是通过网络以字节流的形式,直接发送 RDB 文件的二进制数据给从节点。
为了让从节点能够识别用来同步数据的 RDB 内容,rdbSaveToSlavesSockets 函数调用 rdbSaveRioWithEOFMark 函数(在 rdb.c 文件中),在 RDB 二进制数据的前后加上了标识字符串,如下图所示:
int rdbSaveRioWithEOFMark(rio *rdb, int *error, rdbSaveInfo *rsi) {
...
getRandomHexChars(eofmark,RDB_EOF_MARK_SIZE); //随机生成40字节的16进制字符串,保存在eofmark中,宏定义RDB_EOF_MARK_SIZE的值为40
if (rioWrite(rdb,"$EOF:",5) == 0) goto werr; //写入$EOF
if (rioWrite(rdb,eofmark,RDB_EOF_MARK_SIZE) == 0) goto werr; //写入40字节的16进制字符串eofmark
if (rioWrite(rdb,"\r\n",2) == 0) goto werr; //写入\r\n
if (rdbSaveRio(rdb,error,RDB_SAVE_NONE,rsi) == C_ERR) goto werr; //生成RDB内容
if (rioWrite(rdb,eofmark,RDB_EOF_MARK_SIZE) == 0) goto werr; //再次写入40字节的16进制字符串eofmark
...
}
RDB 文件创建的三个时机,分别是 save 命令执行、bgsave 命令执行以及主从复制。rdbSave 还会在 flushallCommand 函数(在db.c文件中)、prepareForShutdown 函数(在server.c文件中)中被调用。这也就是说,Redis 在执行 flushall 命令以及正常关闭时,会创建 RDB 文件。
对于 rdbSaveBackground 函数来说,它除了在执行 bgsave 命令时被调用,当主从复制采用落盘文件方式传输 RDB 时,它也会被 startBgsaveForReplication 函数调用。此外,Redis server 运行时的周期性执行函数 serverCron(在server.c文件中),也会调用 rdbSaveBackground 函数来创建 RDB 文件。
RDB 文件是如何生成的
一个 RDB 文件主要是由三个部分组成的。
- 文件头:这部分内容保存了 Redis 的魔数、RDB 版本、Redis 版本、RDB 文件创建时间、键值对占用的内存大小等信息。
- 文件数据部分:这部分保存了 Redis 数据库实际的所有键值对。
- 文件尾:这部分保存了 RDB 文件的结束标识符,以及整个文件的校验值。这个校验值用来在 Redis server 加载 RDB 文件后,检查文件是否被篡改过。
生成文件头
RDB 文件头的内容首先是魔数,这对应记录了 RDB 文件的版本。在 rdbSaveRio 函数中,魔数是通过 snprintf 函数生成的,它的具体内容是字符串“REDIS”,再加上 RDB 版本的宏定义 RDB_VERSION(在rdb.h文件中,值为 9)。然后,rdbSaveRio 函数会调用 rdbWriteRaw 函数(在 rdb.c 文件中),将魔数写入 RDB 文件,如下所示:
snprintf(magic,sizeof(magic),"REDIS%04d",RDB_VERSION); //生成魔数magic
if (rdbWriteRaw(rdb,magic,9) == -1) goto werr; //将magic写入RDB文件
刚才用来写入魔数的 rdbWriteRaw 函数,它实际会调用 rioWrite 函数(在 rdb.h 文件中)来完成写入。而 rioWrite 函数是 RDB 文件内容的最终写入函数,它负责根据要写入数据的长度,把待写入缓冲区中的内容写入 RDB。这里,你需要注意的是,RDB 文件生成过程中,会有不同的函数负责写入不同部分的内容,不过这些函数最终都还是调用 rioWrite 函数,来完成数据的实际写入的。
当在 RDB 文件头中写入魔数后,rdbSaveRio 函数紧接着会调用 rdbSaveInfoAuxFields 函数,将和 Redis server 相关的一些属性信息写入 RDB 文件头,如下所示:
if (rdbSaveInfoAuxFields(rdb,flags,rsi) == -1) goto werr; //写入属性信息
当属性值为字符串时,rdbSaveInfoAuxFields 函数会调用 rdbSaveAuxFieldStrStr 函数写入属性信息;而当属性值为整数时,rdbSaveInfoAuxFields 函数会调用 rdbSaveAuxFieldStrInt 函数写入属性信息,如下所示:
if (rdbSaveAuxFieldStrStr(rdb,"redis-ver",REDIS_VERSION) == -1) return -1;
if (rdbSaveAuxFieldStrInt(rdb,"redis-bits",redis_bits) == -1) return -1;
if (rdbSaveAuxFieldStrInt(rdb,"ctime",time(NULL)) == -1) return -1;
if (rdbSaveAuxFieldStrInt(rdb,"used-mem",zmalloc_used_memory()) == -1) return -1;
无论是 rdbSaveAuxFieldStrStr 函数还是 rdbSaveAuxFieldStrInt 函数,它们都会调用 rdbSaveAuxField 函数来写入属性值。rdbSaveAuxField 函数是在 rdb.c 文件中实现的,它会分三步来完成一个属性信息的写入:
第一步,它调用 rdbSaveType 函数写入一个操作码。这个操作码的目的,是用来在 RDB 文件中标识接下来的内容是什么。当写入属性信息时,这个操作码对应了宏定义 RDB_OPCODE_AUX(在 rdb.h 文件中),值为 250,对应的十六进制值为 FA。这样一来,就方便我们解析 RDB 文件了。比如,在读取 RDB 文件时,如果程序读取到 FA 这个字节,那么,这就表明接下来的内容是一个属性信息。
第二步,rdbSaveAuxField 函数调用 rdbSaveRawString 函数(在 rdb.c 文件中)写入属性信息的键,而键通常是一个字符串。rdbSaveRawString 函数是用来写入字符串的通用函数,它会先记录字符串长度,然后再记录实际字符串,如下图所示。这个长度信息是为了解析 RDB 文件时,程序可以基于它知道当前读取的字符串应该读取多少个字节。
为了节省 RDB 文件消耗的空间,如果字符串中记录的实际是一个整数,rdbSaveRawString 函数还会调用 rdbTryIntegerEncoding 函数(在 rdb.c 文件中),尝试用紧凑结构对字符串进行编码。
第三步,rdbSaveAuxField 函数就需要写入属性信息的值了。因为属性信息的值通常也是字符串,所以和第二步写入属性信息的键类似,rdbSaveAuxField 函数会调用 rdbSaveRawString 函数来写入属性信息的值。
生成文件数据部分
因为 Redis server 上的键值对可能被保存在不同的数据库中,所以,rdbSaveRio 函数会执行一个循环,遍历每个数据库,将其中的键值对写入 RDB 文件。
在这个循环流程中,rdbSaveRio 函数会先将 SELECTDB 操作码和对应的数据库编号写入 RDB 文件,这样一来,程序在解析 RDB 文件时,就可以知道接下来的键值对是属于哪个数据库的了。这个过程如下所示:
...
for (j = 0; j < server.dbnum; j++) { //循环遍历每一个数据库
...
//写入SELECTDB操作码
if (rdbSaveType(rdb,RDB_OPCODE_SELECTDB) == -1) goto werr;
if (rdbSaveLen(rdb,j) == -1) goto werr; //写入当前数据库编号j
...
紧接着,rdbSaveRio 函数会写入 RESIZEDB 操作码,用来标识全局哈希表和过期 key 哈希表中键值对数量的记录,这个过程的执行代码如下所示:
...
db_size = dictSize(db->dict); //获取全局哈希表大小
expires_size = dictSize(db->expires); //获取过期key哈希表的大小
if (rdbSaveType(rdb,RDB_OPCODE_RESIZEDB) == -1) goto werr; //写入RESIZEDB操作码
if (rdbSaveLen(rdb,db_size) == -1) goto werr; //写入全局哈希表大小
if (rdbSaveLen(rdb,expires_size) == -1) goto werr; //写入过期key哈希表大小
...
在记录完这些信息后,rdbSaveRio 函数会接着执行一个循环流程,在该流程中,rdbSaveRio 函数会取出当前数据库中的每一个键值对,并调用 rdbSaveKeyValuePair 函数(在 rdb.c 文件中),将它写入 RDB 文件。这个基本的循环流程如下所示:
while((de = dictNext(di)) != NULL) { //读取数据库中的每一个键值对
sds keystr = dictGetKey(de); //获取键值对的key
robj key, *o = dictGetVal(de); //获取键值对的value
initStaticStringObject(key,keystr); //为key生成String对象
expire = getExpire(db,&key); //获取键值对的过期时间
//把key和value写入RDB文件
if (rdbSaveKeyValuePair(rdb,&key,o,expire) == -1) goto werr;
...
}
这里,rdbSaveKeyValuePair 函数主要是负责将键值对实际写入 RDB 文件。它会先将键值对的过期时间、LRU 空闲时间或是 LFU 访问频率写入 RDB 文件。在写入这些信息时,rdbSaveKeyValuePair 函数都会先调用 rdbSaveType 函数,写入标识这些信息的操作码,你可以看下下面的代码:
if (expiretime != -1) {
//写入过期时间操作码标识
if (rdbSaveType(rdb,RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
}
if (savelru) {
...
//写入LRU空闲时间操作码标识
if (rdbSaveType(rdb,RDB_OPCODE_IDLE) == -1) return -1;
if (rdbSaveLen(rdb,idletime) == -1) return -1;
}
if (savelfu) {
...
//写入LFU访问频率操作码标识
if (rdbSaveType(rdb,RDB_OPCODE_FREQ) == -1) return -1;
if (rdbWriteRaw(rdb,buf,1) == -1) return -1;
}
好了,到这里,rdbSaveKeyValuePair 函数就要开始实际写入键值对了。为了便于解析 RDB 文件时恢复键值对,rdbSaveKeyValuePair 函数会先调用 rdbSaveObjectType 函数,写入键值对的类型标识;然后调用 rdbSaveStringObject 写入键值对的 key;最后,它会调用 rdbSaveObject 函数写入键值对的 value。这个过程如下所示,这几个函数都是在 rdb.c 文件中实现的:
if (rdbSaveObjectType(rdb,val) == -1) return -1; //写入键值对的类型标识
if (rdbSaveStringObject(rdb,key) == -1) return -1; //写入键值对的key
if (rdbSaveObject(rdb,val,key) == -1) return -1; //写入键值对的value
rdbSaveObjectType 函数会根据键值对的 value 类型,来决定写入到 RDB 中的键值对类型标识,这些类型标识在 rdb.h 文件中有对应的宏定义。
生成文件尾
当所有键值对都写入 RDB 文件后,rdbSaveRio 函数就可以写入文件尾内容了。文件尾的内容比较简单,主要包括两个部分,一个是 RDB 文件结束的操作码标识,另一个是 RDB 文件的校验值。
rdbSaveRio 函数会先调用 rdbSaveType 函数,写入文件结束操作码 RDB_OPCODE_EOF,然后调用 rioWrite 写入检验值,如下所示:
...
//写入结束操作码
if (rdbSaveType(rdb,RDB_OPCODE_EOF) == -1) goto werr;
//写入校验值
cksum = rdb->cksum;
memrev64ifbe(&cksum);
if (rioWrite(rdb,&cksum,8) == 0) goto werr;
...
redis-rdb-tools这个工具,它能够帮助你分析 RDB 文件中的内容。
此文章为10月Day18学习笔记,内容来源于极客时间《Redis 源码剖析与实战》