You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

25 KiB

18 | 如何生成和解读RDB文件

你好,我是蒋德钧。

从今天这节课开始我们又将进入一个新的模块也就是可靠性保证模块。在这个模块中我会先带你了解Redis数据持久化的实现其中包括Redis内存快照RDB文件的生成方法以及AOF日志的记录与重写。了解了这部分内容可以让你掌握RDB文件的格式学习到如何制作数据库镜像并且你也会进一步掌握AOF日志重写对Redis性能的影响。

然后我还会围绕Redis主从集群的复制过程、哨兵工作机制和故障切换这三个方面来给你介绍它们的代码实现。因为我们知道主从复制是分布式数据系统保证可靠性的一个重要机制而Redis就给我们提供了非常经典的实现所以通过学习这部分内容你就可以掌握到在数据同步实现过程中的一些关键操作和注意事项以免踩坑。

那么今天这节课我们就先从RDB文件的生成开始学起。下面呢我先带你来了解下RDB创建的入口函数以及调用这些函数的地方。

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文件而父进程也就是主线程本身可以继续处理客户端请求。

下面的代码展示了rdbSaveBackground函数创建子进程的过程你可以看下。我在第12讲中也向你介绍过fork的使用你可以再回顾下。

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生成。

和rdbSaveBackground函数类似rdbSaveToSlavesSockets函数也是通过fork创建子进程让子进程生成RDB。不过和rdbSaveBackground函数不同的是rdbSaveToSlavesSockets函数是通过网络以字节流的形式直接发送RDB文件的二进制数据给从节点

而为了让从节点能够识别用来同步数据的RDB内容rdbSaveToSlavesSockets函数调用rdbSaveRioWithEOFMark函数在rdb.c文件中在RDB二进制数据的前后加上了标识字符串如下图所示

以下代码也展示了rdbSaveRioWithEOFMark函数的基本执行逻辑。你可以看到它除了写入前后标识字符串之外还是会调用rdbSaveRio函数实际生成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文件创建的三个入口函数后我们也看到了RDB文件创建的三个时机分别是save命令执行、bgsave命令执行以及主从复制。那么除了这三个时机外在Redis源码中还有哪些地方会触发RDB文件创建呢

实际上因为rdbSaveToSlavesSockets函数只会在主从复制时调用所以我们只要通过在Redis源码中查找rdbSave、rdbSaveBackground这两个函数就可以了解触发RDB文件创建的其他时机。

那么经过查找我们可以发现在Redis源码中rdbSave还会在flushallCommand函数(在db.c文件中)、prepareForShutdown函数(在server.c文件中中被调用。这也就是说Redis在执行flushall命令以及正常关闭时会创建RDB文件。

对于rdbSaveBackground函数来说它除了在执行bgsave命令时被调用当主从复制采用落盘文件方式传输RDB时它也会被startBgsaveForReplication函数调用。此外Redis server运行时的周期性执行函数serverCronserver.c文件中也会调用rdbSaveBackground函数来创建RDB文件。

为了便于你掌握RDB文件创建的整体情况我画了下面这张图展示了Redis源码中创建RDB文件的函数调用关系你可以看下。

好了到这里你可以看到实际最终生成RDB文件的函数是rdbSaveRio。所以接下来我们就来看看rdbSaveRio函数的执行过程。同时我还会给你介绍RDB文件的格式是如何组织的。

RDB文件是如何生成的

不过在了解rdbSaveRio函数具体是如何生成RDB文件之前你还需要先了解下RDB文件的基本组成部分。这样你就可以按照RDB文件的组成部分依次了解rdbSaveRio函数的执行逻辑了。

那么一个RDB文件主要是由三个部分组成的。

  • 文件头这部分内容保存了Redis的魔数、RDB版本、Redis版本、RDB文件创建时间、键值对占用的内存大小等信息。
  • 文件数据部分这部分保存了Redis数据库实际的所有键值对。
  • 文件尾这部分保存了RDB文件的结束标识符以及整个文件的校验值。这个校验值用来在Redis server加载RDB文件后检查文件是否被篡改过。

下图就展示了RDB文件的组成你可以看下。

接下来我们就来看看rdbSaveRio函数是如何生成RDB文件中的每一部分的。这里为了方便你理解RDB文件格式以及文件内容你可以先按照如下步骤准备一个RDB文件。

第一步在你电脑上Redis的目录下启动一个用来测试的Redis server可以执行如下命令

./redis-server

第二步执行flushall命令清空当前的数据库

./redis-cli flushall   

第三步使用redis-cli登录刚启动的Redis server执行set命令插入一个String类型的键值对再执行hmset命令插入一个Hash类型的键值对。执行save命令将当前数据库内容保存到RDB文件中。这个过程如下所示

127.0.0.1:6379>set hello redis
OK
127.0.0.1:6379>hmset userinfo uid 1 name zs age 32
OK
127.0.0.1:6379> save
OK

好了到这里你就可以在刚才执行redis-cli命令的目录下找见刚生成的RDB文件文件名应该是dump.rdb。

不过因为RDB文件实际是一个二进制数据组成的文件所以如果你使用一般的文本编辑软件比如Linux系统上的Vim在打开RDB文件时你会看到文件中都是乱码。所以这里我给你提供一个小工具如果你想查看RDB文件中二进制数据和对应的ASCII字符你可以使用Linux上的od命令这个命令可以用不同进制的方式展示数据并显示对应的ASCII字符。

比如你可以执行如下的命令读取dump.rdb文件并用十六进制展示文件内容同时文件中每个字节对应的ASCII字符也会被对应显示出来。

od -A x -t x1c -v dump.rdb

以下代码展示的就是我用od命令查看刚才生成的dump.rdb文件后输出的从文件头开始的部分内容。你可以看到这四行结果中第一和第三行是用十六进制显示的dump.rdb文件的字节内容这里每两个十六进制数对应了一个字节。而第二和第四行是od命令生成的每个字节所对应的ASCII字符。

这也就是说在刚才生成的RDB文件中如果想要转换成ASCII字符它的文件头内容其实就已经包含了REDIS的字符串和一些数字而这正是RDB文件头包含的内容。

那么下面我们就来看看RDB文件的文件头是如何生成的。

生成文件头

就像刚才给你介绍的RDB文件头的内容首先是魔数这对应记录了RDB文件的版本。在rdbSaveRio函数中魔数是通过snprintf函数生成的它的具体内容是字符串“REDIS”再加上RDB版本的宏定义RDB_VERSIONrdb.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函数是在rdb.c文件中实现的它会使用键值对的形式在RDB文件头中记录Redis server的属性信息。下表中列出了RDB文件头记录的一些主要信息以及它们对应的键和值你可以看下。

那么当属性值为字符串时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这个字节那么这就表明接下来的内容是一个属性信息。

这里,你需要注意的是RDB文件使用了多个操作码来标识文件中的不同内容。它们都是在rdb.h文件中定义的下面的代码中展示了部分操作码你可以看下。

#define RDB_OPCODE_IDLE       248   //标识LRU空闲时间
#define RDB_OPCODE_FREQ       249   //标识LFU访问频率信息
#define RDB_OPCODE_AUX        250   //标识RDB文件头的属性信息
#define RDB_OPCODE_EXPIRETIME_MS 252    //标识以毫秒记录的过期时间
#define RDB_OPCODE_SELECTDB   254   //标识文件中后续键值对所属的数据库编号
#define RDB_OPCODE_EOF        255   //标识RDB文件结束用在文件尾

第二步rdbSaveAuxField函数调用rdbSaveRawString函数在rdb.c文件中写入属性信息的键而键通常是一个字符串。rdbSaveRawString函数是用来写入字符串的通用函数它会先记录字符串长度然后再记录实际字符串如下图所示。这个长度信息是为了解析RDB文件时程序可以基于它知道当前读取的字符串应该读取多少个字节。

不过为了节省RDB文件消耗的空间如果字符串中记录的实际是一个整数rdbSaveRawString函数还会调用rdbTryIntegerEncoding函数在rdb.c文件中尝试用紧凑结构对字符串进行编码。具体做法你可以进一步阅读rdbTryIntegerEncoding函数。

下图展示了rdbSaveRawString函数的基本执行逻辑你可以看下。其中它调用rdbSaveLen函数写入字符串长度调用rdbWriteRaw函数写入实际数据。

第三步rdbSaveAuxField函数就需要写入属性信息的值了。因为属性信息的值通常也是字符串所以和第二步写入属性信息的键类似rdbSaveAuxField函数会调用rdbSaveRawString函数来写入属性信息的值。

下面的代码展示了rdbSaveAuxField函数的执行整体过程你可以再回顾下。

ssize_t rdbSaveAuxField(rio *rdb, void *key, size_t keylen, void *val, size_t vallen) {
    ssize_t ret, len = 0;
    //写入操作码
    if ((ret = rdbSaveType(rdb,RDB_OPCODE_AUX)) == -1) return -1;
    len += ret;
    //写入属性信息中的键
    if ((ret = rdbSaveRawString(rdb,key,keylen)) == -1) return -1;
    len += ret;
    //写入属性信息中的值
    if ((ret = rdbSaveRawString(rdb,val,vallen)) == -1) return -1;
    len += ret;
    return len;
}

到这里RDB文件头的内容已经写完了。我把刚才创建的RDB文件头的部分内容画在了下图当中并且标识了十六进制对应的ASCII字符以及一些关键信息你可以结合图例来理解刚才介绍的代码。

这样接下来rdbSaveRio函数就要开始写入实际的键值对了这也是文件中实际记录数据的部分。下面我们就来具体看下。

生成文件数据部分

因为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
...

下图展示了刚才我创建的RDB文件中SELECTDB操作码的信息你可以看到数据库编号为0。

紧接着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哈希表大小
...

我也把刚才创建的RDB文件中RESIZEDB操作码的内容画在了下图中你可以看下。

你可以看到在RESIZEDB操作码后紧接着记录的是全局哈希表中的键值对它的数量是2然后是过期key哈希表中的键值对其数量为0。我们刚才在生成RDB文件前只插入了两个键值对所以RDB文件中记录的信息和我们刚才的操作结果是一致的。

好了在记录完这些信息后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文件前写入的键值对分别是String类型和Hash类型而Hash类型因为它包含的元素个数不多所以默认采用ziplist数据结构来保存。这两个类型标识对应的数值如下所示

#define RDB_TYPE_STRING   0
#define RDB_TYPE_HASH_ZIPLIST  13

我把刚才写入的String类型键值对“hello”“redis”在RDB文件中对应的记录内容画在了下图中你可以看下。

你可以看到这个键值对的开头类型标识就是0和刚才介绍的RDB_TYPE_STRING宏定义的值是一致的。而紧接着的key和value它们都会先记录长度信息然后才记录实际内容。

因为键值对的key都是String类型所以rdbSaveKeyValuePair函数就用rdbSaveStringObject函数来写入了。而键值对的value有不同的类型所以rdbSaveObject函数会根据value的类型执行不同的代码分支将value底层数据结构中的内容写入RDB。

好了到这里我们就了解了rdbSaveKeyValuePair函数是如何将键值对写入RDB文件中的了。在这个过程中除了键值对类型、键值对的key和value会被记录以外键值对的过期时间、LRU空闲时间或是LFU访问频率也都会记录到RDB文件中。这就生成RDB文件的数据部分。

最后我们再来看下RDB文件尾的生成。

生成文件尾

当所有键值对都写入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;
...

下图展示了我刚才生成的RDB文件的文件尾你可以看下。

这样我们也就整体了解了RDB文件从文件头、文件数据部分再到文件尾的整个生成过程了。

小结

今天这节课我给你介绍了Redis内存快照文件RDB的生成。你要知道创建RDB文件的三个入口函数分别是rdbSave、rdbSaveBackground、rdbSaveToSlavesSockets它们在Redis源码中被调用的地方也就是触发RDB文件生成的时机。

另外你也要重点关注RDB文件的基本组成并且也要结合rdbSaveRio函数的执行流程来掌握RDB文件头、文件数据部分和文件尾这三个部分的生成。我总结了以下两点方便你对RDB文件结构和内容有个整体把握

  • RDB文件使用多种操作码来标识Redis不同的属性信息以及使用类型码来标识不同value类型
  • RDB文件内容是自包含的也就是说无论是属性信息还是键值对RDB文件都会按照类型、长度、实际数据的格式来记录这样方便程序对RDB文件的解析。

最后我也想再说一下RDB文件包含了Redis数据库某一时刻的所有键值对以及这些键值对的类型、大小、过期时间等信息。当你了解了RDB文件的格式和生成方法后其实你就可以根据需求开发解析RDB文件的程序或是加载RDB文件的程序了。

比如你可以在RDB文件中查找内存空间消耗大的键值对也就是在优化Redis性能时通常需要查找的bigkey你也可以分析不同类型键值对的数量、空间占用等分布情况来了解业务数据的特点你还可以自行加载RDB文件用于测试或故障排查。

当然这里我也再给你一个小提示就是在你实际开发RDB文件分析工具之前可以看下redis-rdb-tools这个工具它能够帮助你分析RDB文件中的内容。而如果它还不能满足你的定制化需求你就可以用上这节课学习的内容来开发自己的RDB分析工具了。

每课一问

你能在serverCron函数中查找到rdbSaveBackground函数一共会被调用执行几次吗这又分别对应了什么场景呢