Redis (五)数据持久化-RDB快照

217 阅读7分钟

RDB快照持久化

所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。这就类似于照片,当你给朋友拍照时,一张照片就能把朋友一瞬间的形象完全记下来。就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这个快照文件就称为RDB文件以。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。

  • save:在主线程中执行,会导致阻塞;
  • bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。

何时创建rdb文件?

  • save
// save 命令的实现函数
void saveCommand(client *c) 
// savecommand函数调用 rdbSave 函数
int rdbSave(char *filename, rdbSaveInfo *rsi);
// rdbSaveRio 函数主要负责RDB文件的格式和生成过程
int rdbSaveRio(rio *rdb, int *error, int flags, rdbSaveInfo *rsi) ;
  • bgsave 与rdbSave命令不同的是,这个函数会调用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 {
       ...  //父进程代码执行分支
    }
}

如果执行了bgsave命令后,子进程在生成rdb文件时,主进程正在修改某条数据,对rdb文件会有影响吗?

如果因为子进程在快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。

  • 主从复制rdbSaveToSlavesSockets

在采用不落盘方式传输 RDB 文件进行主从复制时,创建 RDB 文件的入口函数。它会被startBgsaveForReplication 函数调用(在replication.c文件中)。而 startBgsaveForReplication 函数会被 replication.c 文件中的 syncCommand 函数和 replicationCron 函数调用,这对应了 Redis server 执行主从复制命令,以及周期性检测主从复制状态时触发RDB生成。rdbSaveToSlavesSockets 函数也是通过 fork 创建子进程,让子进程生成 RDB。不过和 rdbSaveBackground 函数不同的是,rdbSaveToSlavesSockets 函数是通过网络以字节流的形式,直接发送 RDB 文件的二进制数据给从节点。

  • 数据刷盘
/*
 * Flushes the whole server data set. 
 */
void flushallCommand(client *c) {
    int flags;
    ....
    if (server.saveparamslen > 0) {
        int saved_dirty = server.dirty;
        rdbSaveInfo rsi, *rsiptr;
        rsiptr = rdbPopulateSaveInfo(&rsi);
        // 调用rdbSave函数,创建rdb文件
        rdbSave(server.rdb_filename,rsiptr);
        server.dirty = saved_dirty;
    }
    server.dirty++;
}

  • 正常关机

int prepareForShutdown(int flags) {
int save = flags & SHUTDOWN_SAVE;
int nosave = flags & SHUTDOWN_NOSAVE;
  .....
/* Create a new RDB file before exiting. */
if ((server.saveparamslen > 0 && !nosave) || save) {
    serverLog(LL_NOTICE,"Saving the final RDB snapshot before exiting.");
    /* Snapshotting. Perform a SYNC SAVE and exit */
    rdbSaveInfo rsi, *rsiptr;
    rsiptr = rdbPopulateSaveInfo(&rsi);
    // 创建rdb文件,如果失败记录日志。并且不能关机
    if (rdbSave(server.rdb_filename,rsiptr) != C_OK) {
        serverLog(LL_WARNING,"Error trying to save the DB, can't exit.");
        return C_ERR;
    }
}
    ....
}

  • 创建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 文件。

//生成魔数magic
snprintf(magic,sizeof(magic),"REDIS%04d",RDB_VERSION); 
//将magic写入RDB文件
if (rdbWriteRaw(rdb,magic,9) == -1) goto werr;  

当在 RDB 文件头中写入魔数后,rdbSaveRio函数紧接着会调用rdbSaveInfoAuxFields函数将,redis版本信息、创建时间、已使用的内存等相关的一些属性信息写入RDB文件头中。

 //写入相关属性信息
if (rdbSaveInfoAuxFields(rdb,flags,rsi) == -1) goto werr;

// 版本信息
if (rdbSaveAuxFieldStrStr(rdb,"redis-ver",REDIS_VERSION) == -1) return -1;
// redis平台架构 32还64位
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;

生成文件数据

因为 Redis server上的键值对可能被保存在不同的数据库中,所以,rdbSaveRio函数会执行一个循环,遍历每个数据库,将其中的键值对写入 RDB 文件。rdbSaveRio 函数会先将 SELECTDB 操作码和对应的数据库编号写入 RDB文件,这样一来,程序在解析 RDB 文件时,就可以知道接下来的键值对是属于哪个数据库的了。紧接着,rdbSaveRio 函数会写入 RESIZEDB 操作码,用来标识全局哈希表和过期 key 哈希表中键值对数量的记录


...
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
...
...
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 函数,写入键值对的类型标识。rdbSaveStringObject 写入键值对的 key;最后,它会调用 rdbSaveObject 函数写入键值对的 value。rdbSaveKeyValuePair函数也在rdb.c文件中。

int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val, long long expiretime) {
    int savelru = server.maxmemory_policy & MAXMEMORY_FLAG_LRU;
    int savelfu = server.maxmemory_policy & MAXMEMORY_FLAG_LFU;

    /* Save the expire time */
    if (expiretime != -1) {
        if (rdbSaveType(rdb,RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
        if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
    }

    /* Save the LRU info. */
    if (savelru) {
        uint64_t idletime = estimateObjectIdleTime(val);
        idletime /= 1000; /* Using seconds is enough and requires less space.*/
        if (rdbSaveType(rdb,RDB_OPCODE_IDLE) == -1) return -1;
        if (rdbSaveLen(rdb,idletime) == -1) return -1;
    }

    /* Save the LFU info. */
    if (savelfu) {
        uint8_t buf[1];
        buf[0] = LFUDecrAndReturn(val);
        if (rdbSaveType(rdb,RDB_OPCODE_FREQ) == -1) return -1;
        if (rdbWriteRaw(rdb,buf,1) == -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;
}

生成文件尾

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