本文带大家了解一下 Redis 数据一种持久化方式 RDB 的实现。包括 Redis 内存快照 RDB ⽂件的创建时机以及⽣成⽅法。可以让你掌握 RDB ⽂件的格式,学习如何制作数据库镜像。
RDB 创建的入口函数
Redis 创建 RDB 文件的函数有三个,分别是 rdbSave, rdbSaveBackground, rdbSaveToSlavesSockets 这三个函数。
rdbSave
rdbSave 是 Redis 在本地磁盘创建 RDB ⽂件的入口函数。它对应了 Redis 的 save 命令,会在 save 命令的实现函数 saveCommand 中被调用,这个命令是使用主线程执行的,会阻塞其他命令的执行。rdbSave 函数最终会调用 rdbSaveRio 函数来实际创建RDB⽂件。
rdbSaveBackground
rdbSaveBackground 是 Redis 使⽤⼦进程方式在本地磁盘创建 RDB ⽂件的入口函数。它对应了 Redis 的 bgsave 命令,会在 bgsave 命令的实现函数 bgsaveCommand 中被调⽤。这个函数会调⽤ fork 创建 ⼀个⼦进程,让⼦进程调用 rdbSave 函数来创建 RDB ⽂件,而主线程本⾝可以继续处理客户端请求。
int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
...
if ((childpid = fork()) == 0) {
// 子进程执行方法
...
// 调用 rdbSave 创建 RDB 文件
retval = rdbSave(filename,rsi);
...
// 子进程退出
exitFromChild((retval == C_OK) ? 0 : 1);
} else {
/* Parent */
// 父进程也就是主线程执行方法
...
return C_OK;
}
return C_OK; /* unreached */
}
rdbSaveToSlavesSockets
rdbSaveToSlavesSockets 函数是 Redis 采用不落盘方式传输 RDB 文件进行主从复制时,创建 RDB文件的入口函数。
与 rdbSaveBackground 函数类似,rdbSaveToSlavesSockets 也是通过创建子进程,让子进程创建 RDB 文件。与 rdbSaveBackground 不同的是,rdbSaveToSlavesSockets 是通过网络以字节流的形式直接发送 RDB 文件的二进制文件数据给从节点。
RDB 创建时机
从上面的分析中我们知道 RDB 文件创建的三个时机分别是执行 save 命令、执行 bgsave 命令和进行主从复制。除了这几个时机还有哪些地方会触发 RDB 文件的创建呢?接下来我们将分析一下其他的创建时机。
rdbSave
通过查找 rdbSave 函数的调用,我们发现在 db.c 文件中的 flushallCommand 函数和 server.c 文件中的 prepareForShutdown 函数会调用 rdbSave 函数,也就是说在 Redis 执行 flushall 命令或 Redis 正常关闭时会创建 RDB 文件。
rdbSaveBackground
通过查找rdbSaveBackground 函数的调用,我们发现在 replication.c 中的 startBgsaveForReplication 函数和 server.c 文件中的 serverCron 函数会调用 rdbSaveBackground 函数,也就是说在主从复制以及定时任务按周期会调用 rdbSaveBackground 来创建 RDB 文件。
serverCron 函数会在下面两种情况下调用 rdbSaveBackground 生成 RDB 文件。
-
满足配置的定时生成 RDB 文件的配置时。
-
bgsave 因为 AOF 重写导致 bgsave 被迫推迟时。
可见 RDB 文件只是周期性的保存某一时刻的数据。
rdbSaveToSlavesSockets
过查找rdbSaveToSlavesSockets 函数的调用,我们发现只有在 replication.c 中的 startBgsaveForReplication 函数会被调用,而 startBgsaveForReplication 函数被 replication.c ⽂件中的 syncCommand 函数和 replicationCron 函数调⽤,也就是说 Redis 执行主从复制命令以及周期性检测主从复制状态时会触发 RDB ⽣成。为了让从节点能够识别⽤来同步数据的 RDB 内容,rdbSaveToSlavesSockets 函数调⽤ rdbSaveRioWithEOFMark 函数在 RDB ⼆进制数据的前后加上了标识字符串,我们来看下代码:
#define RDB_EOF_MARK_SIZE 40
int rdbSaveRioWithEOFMark(rio *rdb, int *error, rdbSaveInfo *rsi) {
char eofmark[RDB_EOF_MARK_SIZE];
// 生成随机成 40 字节的 16 进制字符串,保存在 eofmark 中
getRandomHexChars(eofmark,RDB_EOF_MARK_SIZE);
if (error) *error = 0;
// 写入 $EOF:
if (rioWrite(rdb,"$EOF:",5) == 0) goto werr;
// 写入 eofmark
if (rioWrite(rdb,eofmark,RDB_EOF_MARK_SIZE) == 0) goto werr;
// 写入 \r\n
if (rioWrite(rdb,"\r\n",2) == 0) goto werr;
// 写入 rdb 中的数据
if (rdbSaveRio(rdb,error,RDB_SAVE_NONE,rsi) == C_ERR) goto werr;
// 再次写入 eofmark
if (rioWrite(rdb,eofmark,RDB_EOF_MARK_SIZE) == 0) goto werr;
return C_OK;
werr: /* Write error. */
/* Set 'error' only if not already set by rdbSaveRio() call. */
if (error && *error == 0) *error = errno;
return C_ERR;
}
新增的标识字符串如下图所示:
好了到这里我们找到了所有 RDB 创建的时机,下面这张图展示了函数的调用关系。
RDB 文件
一个 RDB 文件是主要由三部分组成的。
-
文件头: 这部分内容保存了Redis 的魔数、RDB 版本、Redis 版本、RDB ⽂件创建时间、键值对占⽤的内存大小等信息。
-
文件数据: 这部分保存了 Redis 数据库实际的所有键值对。
-
文件尾: 这部分保存了 RDB ⽂件的结束标识符,以及整个⽂件的校验值。用于校验文件是否被篡改。 RDB 文件组成如下图所示:
真正创建 RDB 文件的函数是 rdbSaveRio,下面我们通过 rdbSaveRio 函数分别看文件的这三部分具体实现。
文件头
rdbSaveRio 首先会将魔数写入 RDB文件,当在 RDB ⽂件头中写⼊魔数后,rdbSaveRio 函数紧接着会调⽤ rdbSaveInfoAuxFields 函数将和 Redis server 相关的⼀些属性信息写⼊ RDB ⽂件头。
...
// 生成魔数 magic
snprintf(magic,sizeof(magic),"REDIS%04d",RDB_VERSION);
// 将魔数写到 RDB 中
if (rdbWriteRaw(rdb,magic,9) == -1) goto werr;
// 写入属性信息
if (rdbSaveInfoAuxFields(rdb,flags,rsi) == -1) goto werr;
if (rdbSaveModulesAux(rdb, REDISMODULE_AUX_BEFORE_RDB) == -1) goto werr;
...
文件数据
Redis Server 中会有多个数据库,rdbSaveRio 会遍历所有的数据库,并将里面的数据写入到 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;
uint64_t db_size, expires_size;
// 获取全局哈希表大小
db_size = dictSize(db->dict);
// 获取过期键哈希表大小
expires_size = dictSize(db->expires);
// 写入 RESIZEDB 操作符
if (rdbSaveType(rdb,RDB_OPCODE_RESIZEDB) == -1) goto werr;
// 写入全局哈希表大小
if (rdbSaveLen(rdb,db_size) == -1) goto werr;
// 写入过期键哈希表大小
if (rdbSaveLen(rdb,expires_size) == -1) goto werr;
// 遍历所有的键值对
while((de = dictNext(di)) != NULL) {
// 获取键
sds keystr = dictGetKey(de);
// 获取值对象
robj key, *o = dictGetVal(de);
long long expire;
// 将 key 从 sds 类型转换为 robj
initStaticStringObject(key,keystr);
// 获取键的过期时间
expire = getExpire(db,&key);
// 将键、值以及过期时间写入 RDB 文件
if (rdbSaveKeyValuePair(rdb,&key,o,expire) == -1) goto werr;
/* When this RDB is produced as part of an AOF rewrite, move
* accumulated diff from parent to child while rewriting in
* order to have a smaller final write. */
if (flags & RDB_SAVE_AOF_PREAMBLE &&
rdb->processed_bytes > processed+AOF_READ_DIFF_INTERVAL_BYTES)
{
processed = rdb->processed_bytes;
aofReadDiffFromParent();
}
}
dictReleaseIterator(di);
...
}
...
通过上面的代码我们可以看到,rdbSaveRio 函数会先将 SELECTDB 操作码和对应的数据库编号写⼊ RDB ⽂件中,这样方便解析时知道下面的数据是哪个数据库的。然后 rdbSaveRio 函数会写⼊ RESIZEDB 操作码,⽤来标识全局哈希表和过期 key 哈希表中键值对数量。最后 rdbSaveRio 函数会遍历当前数据库的所有键值对,把键、值以及过期时间写入 RDB 文件中。到这文件数据就写入完成了。
文件尾
写入文件尾的操作比较简单,主要写入两部分内容,一个是文件结束的操作码标识,另一个是校验码。下面我们看一下代码中是如何实现的:
...
// 写入结束操作码
if (rdbSaveType(rdb,RDB_OPCODE_EOF) == -1) goto werr;
/* CRC64 checksum. It will be zero if checksum computation is disabled, the
* loading code skips the check in this case. */
cksum = rdb->cksum;
memrev64ifbe(&cksum);
// 写入校验码
if (rioWrite(rdb,&cksum,8) == 0) goto werr;
...
好了,到这我们就分析完 Redis 中 RDB 文件的创建过程。
小结
本文主要介绍了内存快照文件 RDB 的三个入口函数以及创建 RDB 文件的时机还有 RDB 文件的生成过程。RDB 文件保存了 Redis 某一时刻所有的键值对,以及这些键值对的类型、大小、过期时间等信息。