《Redis设计与实现》[第二部分]单机数据库的实现-C源码阅读

Posted zhongrui_fzr

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《Redis设计与实现》[第二部分]单机数据库的实现-C源码阅读相关的知识,希望对你有一定的参考价值。

2、RDB持久化

关键字:RDB文件解析,自动间隔性保存

Redis提供RDB持久化功能,可以将Redis在内存中的数据库状态保存到磁盘里,避免数据意外丢失。

RDB持久化可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中。

RDB文件生成

RDB持久化功能所生成的RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。

save命令生成RDB文件的方式是阻塞Redis服务器进程,直到RDB文件创建完毕,在服务器阻塞期间,服务器不能处理任何命令请求

BGSAVE命令的方式是派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求

创建RDB文件的实际工作都是由rdb.c/rdbSave函数完成,save命令和bgsave命令会以不同的方式调用这个函数。

/* Save the DB on disk. Return REDIS_ERR on error, REDIS_OK on success 
 *
 * 将数据库保存到磁盘上。
 *
 * 保存成功返回 REDIS_OK ,出错/失败返回 REDIS_ERR 。
 */
int rdbSave(char *filename) 
    dictIterator *di = NULL;
    dictEntry *de;
    char tmpfile[256];
    char magic[10];
    int j;
    long long now = mstime();
    FILE *fp;
    rio rdb;
    uint64_t cksum;

    // 创建临时文件
    snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
    fp = fopen(tmpfile,"w");
    if (!fp) 
        redisLog(REDIS_WARNING, "Failed opening .rdb for saving: %s",
            strerror(errno));
        return REDIS_ERR;
    

    // 初始化 I/O
    rioInitWithFile(&rdb,fp);

    // 设置校验和函数
    if (server.rdb_checksum)
        rdb.update_cksum = rioGenericUpdateChecksum;

    // 写入 RDB 版本号
    snprintf(magic,sizeof(magic),"REDIS%04d",REDIS_RDB_VERSION);
    if (rdbWriteRaw(&rdb,magic,9) == -1) goto werr;

    // 遍历所有数据库
    for (j = 0; j < server.dbnum; j++) 

        // 指向数据库
        redisDb *db = server.db+j;

        // 指向数据库键空间
        dict *d = db->dict;

        // 跳过空数据库
        if (dictSize(d) == 0) continue;

        // 创建键空间迭代器
        di = dictGetSafeIterator(d);
        if (!di) 
            fclose(fp);
            return REDIS_ERR;
        

        /* Write the SELECT DB opcode 
         *
         * 写入 DB 选择器
         */
        if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_SELECTDB) == -1) goto werr;
        if (rdbSaveLen(&rdb,j) == -1) goto werr;

        /* 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;
        
        dictReleaseIterator(di);
    
    di = NULL; /* So that we don't release it again on error. */

    /* EOF opcode 
     *
     * 写入 EOF 代码
     */
    if (rdbSaveType(&rdb,REDIS_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. 
     *
     * CRC64 校验和。
     *
     * 如果校验和功能已关闭,那么 rdb.cksum 将为 0 ,
     * 在这种情况下, RDB 载入时会跳过校验和检查。
     */
    cksum = rdb.cksum;
    memrev64ifbe(&cksum);
    rioWrite(&rdb,&cksum,8);

    /* Make sure data will not remain on the OS's output buffers */
    // 冲洗缓存,确保数据已写入磁盘
    if (fflush(fp) == EOF) goto werr;
    if (fsync(fileno(fp)) == -1) goto werr;
    if (fclose(fp) == EOF) goto werr;

    /* Use RENAME to make sure the DB file is changed atomically only
     * if the generate DB file is ok. 
     *
     * 使用 RENAME ,原子性地对临时文件进行改名,覆盖原来的 RDB 文件。
     */
    if (rename(tmpfile,filename) == -1) 
        redisLog(REDIS_WARNING,"Error moving temp DB file on the final destination: %s", strerror(errno));
        unlink(tmpfile);
        return REDIS_ERR;
    

    // 写入完成,打印日志
    redisLog(REDIS_NOTICE,"DB saved on disk");

    // 清零数据库脏状态
    server.dirty = 0;

    // 记录最后一次完成 SAVE 的时间
    server.lastsave = time(NULL);

    // 记录最后一次执行 SAVE 的状态
    server.lastbgsave_status = REDIS_OK;

    return REDIS_OK;

werr:
    // 关闭文件
    fclose(fp);
    // 删除文件
    unlink(tmpfile);

    redisLog(REDIS_WARNING,"Write error saving DB on disk: %s", strerror(errno));

    if (di) dictReleaseIterator(di);

    return REDIS_ERR;

RDB文件生成条件

RDB文件的载入工作是在服务器启动时自动执行的,所以redis没有专门用于载入RDB文件的命令,只要redis服务器在启动时检测到RDB文件存在,就会自动载入RDB文件

因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:

  • 若服务器开启了AOF持久化功能,那么服务器就优先使用AOF文件还原数据库状态
  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态

BGSAVE命令执行期间,客户端发送的save和BGSAVE命令都会被服务器拒绝,防止产生竞态条件

从性能考虑,BGREWRITEAOF和BGSAVE命令不能同时执行:

  • 若BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行
  • BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令会被服务器拒绝

服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止

当Redis服务器启动时,用户可以通过指定配置文件或传入启动参数的方式设置save选项,如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件:

  • save 900 1
  • save 300 10
  • save 60 10000
    服务器程序会根据save选项设置服务器状态redisSever结构的saveparams属性:
struct redisServer
    // 自从上次 SAVE 执行以来,数据库被修改的次数
    long long dirty;                /* Changes to DB from the last save */

    // BGSAVE 执行前的数据库被修改次数
    long long dirty_before_bgsave;  /* Used to restore dirty on failed BGSAVE */
    // 负责执行 BGSAVE 的子进程的 ID
    // 没在执行 BGSAVE 时,设为 -1
    pid_t rdb_child_pid;            /* PID of RDB saving child */
    // 记录了保存条件的数组
    struct saveparam *saveparams;   /* Save points array for RDB */
    int saveparamslen;              /* Number of saving points */
    char *rdb_filename;             /* Name of RDB file */
    int rdb_compression;            /* Use compression in RDB? */
    int rdb_checksum;               /* Use RDB checksum? */

    // 最后一次完成 SAVE 的时间
    time_t lastsave;                /* Unix time of last successful save */

    // 最后一次尝试执行 BGSAVE 的时间
    time_t lastbgsave_try;          /* Unix time of last attempted bgsave */

    // 最近一次 BGSAVE 执行耗费的时间
    time_t rdb_save_time_last;      /* Time used by last RDB save run. */

    // 数据库最近一次开始执行 BGSAVE 的时间
    time_t rdb_save_time_start;     /* Current RDB save start time. */

    // 最后一次执行 SAVE 的状态
    int lastbgsave_status;          /* REDIS_OK or REDIS_ERR */
    int stop_writes_on_bgsave_err;  /* Don't allow writes if can't BGSAVE */

// 服务器的保存条件(BGSAVE 自动执行的条件)
struct saveparam 
    // 多少秒之内
    time_t seconds;
    // 发生多少次修改,修改数
    int changes;

;
  • saveparams属性是一个数组,数组中的每个元素都是一个saveparam结构,每个saveparam结构都保存了一个save选项设置的保存条件
  • dirty计数器:记录距离上一次成功执行save命令或BGSAVE命令之后,服务器对数据库状态(服务器中所有数据库)进行了多少次修改(包括写入、删除、更新等操作)
  • lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行save命令BGSAVE命令的时间

Redis服务器周期性操作函数serverCron默认每隔100毫秒执行一次,该函数用于对正在运行的服务器进行维护,它其中一项工作就是检查save选项所设置的保存条件是否已经满足,若满足,就执行BGSAVE命令

/* This is our timer interrupt, called server.hz times per second.
 *
 * 这是 Redis 的时间中断器,每秒调用 server.hz 次。
 *
 * Here is where we do a number of things that need to be done asynchronously.
 * For instance:
 *
 * 以下是需要异步执行的操作:
 *
 * - Active expired keys collection (it is also performed in a lazy way on
 *   lookup).
 *   主动清除过期键。
 *
 * - Software watchdog.
 *   更新软件 watchdog 的信息。
 *
 * - Update some statistic.
 *   更新统计信息。
 *
 * - Incremental rehashing of the DBs hash tables.
 *   对数据库进行渐增式 Rehash
 *
 * - Triggering BGSAVE / AOF rewrite, and handling of terminated children.
 *   触发 BGSAVE 或者 AOF 重写,并处理之后由 BGSAVE 和 AOF 重写引发的子进程停止。
 *
 * - Clients timeout of different kinds.
 *   处理客户端超时。
 *
 * - Replication reconnection.
 *   复制重连
 *
 * - Many more...
 *   等等。。。
 *
 * Everything directly called here will be called server.hz times per second,
 * so in order to throttle execution of things we want to do less frequently
 * a macro is used: run_with_period(milliseconds)  .... 
 *
 * 因为 serverCron 函数中的所有代码都会每秒调用 server.hz 次,
 * 为了对部分代码的调用次数进行限制,
 * 使用了一个宏 run_with_period(milliseconds)  ...  ,
 * 这个宏可以将被包含代码的执行次数降低为每 milliseconds 执行一次。
 */

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) 

    //....
  /* If there is not a background saving/rewrite in progress check if
         * we have to save/rewrite now */
        // 既然没有 BGSAVE 或者 BGREWRITEAOF 在执行,那么检查是否需要执行它们

        // 遍历所有保存条件,看是否需要执行 BGSAVE 命令
         for (j = 0; j < server.saveparamslen; j++) 
            struct saveparam *sp = server.saveparams+j;

            /* Save if we reached the given amount of changes,
             * the given amount of seconds, and if the latest bgsave was
             * successful or if, in case of an error, at least
             * REDIS_BGSAVE_RETRY_DELAY seconds already elapsed. */
            // 检查是否有某个保存条件已经满足了
            if (server.dirty >= sp->changes &&
                server.unixtime-server.lastsave > sp->seconds &&
                (server.unixtime-server.lastbgsave_try >
                 REDIS_BGSAVE_RETRY_DELAY ||
                 server.lastbgsave_status == REDIS_OK))
            
                redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...",
                    sp->changes, (int)sp->seconds);
                // 执行 BGSAVE
                rdbSaveBackground(server.rdb_filename);
                break;
            
         
    // ....

RDB文件格式

由上文rdbSave()函数可以看出,一个完整RDB文件所包含的各个部分有

REDIS | db_version | database | EOF | check_sum

  • RDB文件的最开头是redis部分,长度为5字节,保存“REDIS”五个字符。通过这五个字符,程序可以在载入文件时,快速检查所载入的文件是否是RDB文件
  • db_version长度为4字节,值是一个字符串表示的整数,记录RDB文件的版本号
  • database部分包含零个或任意多个数据库,以及各个数据库中的键值对数据,若所有数据库均为空则这个部分也为空,长度为0
  • EOF常量,长度1字节,标识RDB文件正文内容的结束。
  • check_sum是一个8字节长的无符号整数,保存着一个校验和,是程序对REDIS、db_version、database、EOF四个部分的内容进行计算得出的。

服务器载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比,以此来检查RDB文件是否有出错或损坏的情况

一个RDB文件的database部分可以保存任意多个非空数据库。每个非空数据库在RDB文件中都可以保存为SELECTDB、db_number、key_value_pairs三个部分:

SELECTDB | db_number | key_value_pairs

  • SELECTDB常量的长度为1字节,当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码
  • db_number保存着一个数据库号码,根据号码的大小不同,这个部分的长度可以是1字节、2字节或5字节。当程序度日db_number部分之后,服务器会调用select命令,根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以载入到正确的数据库中。
  • key_value_pairs部分保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间会和键值对保存在一起。根据键值对的数量、类型、内容以及是否有过期时间等条件的不同,key_value_pairs部分的长度也会有所不同。

不带过期时间的键值对在RDB文件中由type、key、value三部分组成。

  • type记录了value的类型,长度为1字节,值为常量,如REDIS_RDB_TYPE_STRING、REDIS_RDB_TYPE_LIST_ZIPLIST、REDIS_RDB_TYPE_HASH_ZIPLIST等

    • type常量代表了一种对象类型或者底层编码,当服务器读入RDB文件中的键值对数据时,程序会根据type的值来决定如何读入和解释value的数据
  • key总是一个字符串对象

  • 根据type类型的不同,以及保存内容长度的不同,保存value的结构和长度也会有所不同

带有过期时间的键值对结构为:

EXPIRETIME | ms | TYPE | key | value

  • EXPIRETIME_MS常量的长度为1字节,表示接下来读入的将是一个以毫秒为单位的过期时间
  • ms是一个8字节长的带符号整数,记录着一个以毫秒为单位的UNIX时间戳,即键值对的过期时间

value的编码

RDB文件中的每个value部分都保存了一个值对象,每个值对象的类型都由与之对应的TYPE记录,根据类型的不同,value部分的结构、长度也会有所不同

  • 1、字符串对象,type为:REDIS_RDB_TYPE_STRING

字符串对象的编码若为REDIS_ENCODING_INT:对象中保存的是长度不超过32位的整数
ENCODING | Integer
ENCODING的值可以是REDIS_RDB_ENC_INT8、REDIS_RDB_ENC_INT16或者REDIS_RDB_ENC_INT32三个常量中的一个,分别代表RDB文件使用8位、16位或32位保存整数值Integer

字符串对象的编码若为REDIS_ENCODING_RAW:说明对象保存的是一个字符串值
若字符串长度小于等于20字节,那么字符串会被原样保存: len | string
若字符串长度大于20字节,那么这个字符串会被压缩之后再保存:
REDIS_RDB_ENC_LZF | compressed_len | origin_len | compressed_string
REDIS_RDB_ENC_LZF常量表示字符串被LZF算法压缩过

  • 2、列表对象,type为:REDIS_RDB_TYPE_LIST

value保存的是一个REDIS_ENCODING_LINKEDLIST编码的列表对象,结构:
list_length | item1 | item2 | 。。。| itemn

  • 3、集合对象,type为:REDIS_RDB_TYPE_SET

value保存的是一个REDIS_ENCODING_HT编码的集合对象,结构:
set_size | elem1 | elem2 | … | elemn

  • 4、哈希表对象,type为:REDIS_RDB_TYPE_HASH

value保存的是一个REDIS_ENCODING_HT编码的集合对象,结构:
hash_size | key_value_pair 1 | … | key_value_pair N

  • 5、有序集合对象,type为:REDIS_RDB_TYPE_ZSET

value保存的是REDIS_ENCODING_SKIPLIST编码的有序集合对象,结构:
sorted_set_size | member1|score1 | member2|score2 | … | memberN|scoreN

  • 6、INTSET编码的集合,type为:REDIS_RDB_TYPE_STRING

value保存的是一个整数集合对象,RDB保存这种对象的方法是,先将整数集合对象转换为字符串对象,然后将这个字符串对象保存到RDB文件中
如果程序在读入RDB文件的过程中,遇到由整数集合对象转换成的字符串对象,那么程序会根据TYPE值的指示,先读入字符串对象,再将这个字符串对象转换成原来的整数集合对象

  • 7、ZIPLIST编码的列表、哈希表或者有序集合,type为:REDIS_RDB_TYPE_ZIPLIST、REDIS_RDB_TYPE_HASH_ZIPLIST、REDIS_RDB_TYPE_ZSET_ZIPLIST

value保存的是一个压缩列表对象,RDB文件保存这种对象的方法是:
将压缩列表转换成一个字符串对象
将字符串对象保存到RDB文件

分析RDB文件

可以使用-od命令分析redis服务器产生的RDB文件,该命令可以用给定的格式转存(dump)并打印输入文件,比如,给定-c参数可以以ASCII编码的方式打印输入文件,给定-x参数可以以十六进制的方式打印输入文件

以上是关于《Redis设计与实现》[第二部分]单机数据库的实现-C源码阅读的主要内容,如果未能解决你的问题,请参考以下文章

《Redis设计与实现》[第二部分]单机数据库的实现-C源码阅读

《Redis设计与实现》[第二部分]单机数据库的实现-C源码阅读

《Redis设计与实现》[第二部分]单机数据库的实现-C源码阅读

[redis读书笔记] 第二部分 单机数据库

[redis读书笔记] 第二部分 单机数据库 RDB持久化

Redis | 第6章 事件与客户端《Redis设计与实现》