Redis RDB 持久化詳解

Redis 是一種內存數據庫,將數據保存在內存中,讀寫效率要比傳統的將數據保存在磁盤上的數據庫要快不少。可是一旦進程退出,Redis 的數據就會丟失。redis

爲了解決這個問題,Redis 提供了 RDB 和 AOF 兩種持久化方案,將內存中的數據保存到磁盤中,避免數據丟失。sql

antirez 在《Redis 持久化解密》一文中說,通常來講有三種常見的策略來進行持久化操做,防止數據損壞:數據庫

  • 方法1 是數據庫不關心發生故障,在數據文件損壞後經過數據備份或者快照來進行恢復。Redis 的 RDB 持久化就是這種方式。數組

  • 方法2 是數據庫使用操做日誌,每次操做時記錄操做行爲,以便在故障後經過日誌恢復到一致性的狀態。由於操做日誌是順序追加的方式寫的,因此不會出現操做日誌也沒法恢復的狀況。相似於 Mysql 的 redo 和 undo 日誌,具體能夠看這篇《InnoDB的磁盤文件及落盤機制》文章。緩存

  • 方法3 是數據庫不進行老數據的修改,只是以追加方式去完成寫操做,這樣數據自己就是一份日誌,這樣就永遠不會出現數據沒法恢復的狀況了。CouchDB就是此作法的優秀範例。安全

RDB 就是第一種方法,它就是把當前 Redis 進程的數據生成時間點快照( point-in-time snapshot ) 保存到存儲設備的過程。bash

RDB 的使用

RDB 觸發機制分爲使用指令手動觸發和 redis.conf 配置自動觸發。服務器

手動觸發 Redis 進行 RDB 持久化的指令的爲:數據結構

  • save ,該指令會阻塞當前 Redis 服務器,執行 save 指令期間,Redis 不能處理其餘命令,直到 RDB 過程完成爲止。
  • bgsave,執行該命令時,Redis 會在後臺異步執行快照操做,此時 Redis 仍然能夠相應客戶端請求。具體操做是 Redis 進程執行 fork 操做建立子進程,RDB 持久化過程由子進程負責,完成後自動結束。Redis 只會在 fork 期間發生阻塞,可是通常時間都很短。可是若是 Redis 數據量特別大,fork 時間就會變長,並且佔用內存會加倍,這一點須要特別注意。

自動觸發 RDB 的默認配置以下所示:異步

save 900 1 # 表示900 秒內若是至少有 1 個 key 的值變化,則觸發RDB
save 300 10 # 表示300 秒內若是至少有 10 個 key 的值變化,則觸發RDB
save 60 10000 # 表示60 秒內若是至少有 10000 個 key 的值變化,則觸發RDB
複製代碼

若是不須要 Redis 進行持久化,那麼能夠註釋掉全部的 save 行來停用保存功能,也能夠直接一個空字符串來停用持久化:save ""。

Redis 服務器週期操做函數 serverCron 默認每一個 100 毫秒就會執行一次,該函數用於正在運行的服務器進行維護,它的一項工做就是檢查 save 選項所設置的條件是否有一項被知足,若是知足的話,就執行 bgsave 指令。

RDB 總體流程

瞭解了 RDB 的基礎使用後,咱們要繼續深刻對 RDB持久化的學習。在此以前,咱們能夠先思考一下如何實現一個持久化機制,畢竟這是不少中間件所需的一個模塊。

首先,持久化保存的文件內容結構必須是緊湊的,特別對於數據庫來講,須要持久化的數據量十分大,須要保證持久化文件不至於佔用太多存儲。 其次,進行持久化時,中間件應該還能夠快速地響應用戶請求,持久化的操做應該儘可能少影響中間件的其餘功能。 最後,畢竟持久化會消耗性能,如何在性能和數據安全性之間作出平衡,如何靈活配置觸發持久化操做。

接下來咱們將帶着這些問題,到源碼中尋求答案。

本文中的源碼來自 Redis 4.0 ,RDB持久化過程的相關源碼都在 rdb.c 文件中。其中大概的流程以下圖所示。

image.png

上圖代表了三種觸發 RDB 持久化的手段之間的總體關係。經過 serverCron 自動觸發的 RDB 至關於直接調用了 bgsave 指令的流程進行處理。而 bgsave 的處理流程啓動子進程後,調用了 save 指令的處理流程。

下面咱們從 serverCron 自動觸發邏輯開始研究。

自動觸發 RDB 持久化

如上圖所示,redisServer 結構體的save_params指向擁有三個值的數組,該數組的值與 redis.conf 文件中 save 配置項一一對應。分別是 save 900 1save 300 10save 60 10000dirty 記錄着有多少鍵值發生變化,lastsave記錄着上次 RDB 持久化的時間。

serverCron 函數就是遍歷該數組的值,檢查當前 Redis 狀態是否符合觸發 RDB 持久化的條件,好比說距離上次 RDB 持久化過去了 900 秒而且有至少一條數據發生變動。

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    ....
    /* Check if a background saving or AOF rewrite in progress terminated. */
    /* 判斷後臺是否正在進行 rdb 或者 aof 操做 */
    if (server.rdb_child_pid != -1 || server.aof_child_pid != -1 ||
        ldbPendingChildren())
    {
        ....
    } else {
        // 到這兒就能肯定 當前木有進行 rdb 或者 aof 操做
        // 遍歷每個 rdb 保存條件
         for (j = 0; j < server.saveparamslen; j++) {
            struct saveparam *sp = server.saveparams+j;

            //若是數據保存記錄 大於規定的修改次數 且距離 上一次保存的時間大於規定時間或者上次BGSAVE命令執行成功,才執行 BGSAVE 操做
            if (server.dirty >= sp->changes &&
                server.unixtime-server.lastsave > sp->seconds &&
                (server.unixtime-server.lastbgsave_try >
                 CONFIG_BGSAVE_RETRY_DELAY ||
                 server.lastbgsave_status == C_OK))
            {
                //記錄日誌
                serverLog(LL_NOTICE,"%d changes in %d seconds. Saving...",
                    sp->changes, (int)sp->seconds);
                rdbSaveInfo rsi, *rsiptr;
                rsiptr = rdbPopulateSaveInfo(&rsi);
                // 異步保存操做
                rdbSaveBackground(server.rdb_filename,rsiptr);
                break;
            }
         }
    }
    ....
    server.cronloops++;
    return 1000/server.hz;
}
複製代碼

若是符合觸發 RDB 持久化的條件,serverCron會調用rdbSaveBackground函數,也就是 bgsave 指令會觸發的函數。

子進程後臺執行 RDB 持久化

執行 bgsave 指令時,Redis 會先觸發 bgsaveCommand 進行當前狀態檢查,而後纔會調用rdbSaveBackground,其中的邏輯以下圖所示。

示意圖

rdbSaveBackground 函數中最主要的工做就是調用 fork 命令生成子流程,而後在子流程中執行 rdbSave函數,也就是 save 指令最終會觸發的函數。

int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
    pid_t childpid;
    long long start;
    // 檢查後臺是否正在執行 aof 或者 rdb 操做
    if (server.aof_child_pid != -1 || server.rdb_child_pid != -1) return C_ERR;
    // 拿出 數據保存記錄,保存爲 上次記錄
    server.dirty_before_bgsave = server.dirty;
    // bgsave 時間
    server.lastbgsave_try = time(NULL);
    start = ustime();
    // fork 子進程
    if ((childpid = fork()) == 0) {
        int retval;
        /* 關閉子進程繼承的 socket 監聽 */
        closeListeningSockets(0);
        // 子進程 title 修改
        redisSetProcTitle("redis-rdb-bgsave");
        // 執行rdb 寫入操做
        retval = rdbSave(filename,rsi);
        // 執行完畢之後
        ....
        // 退出子進程
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
        /* 父進程,進行fork時間的統計和信息記錄,好比說rdb_save_time_start、rdb_child_pid、和rdb_child_type */
        ....
        // rdb 保存開始時間 bgsave 子進程
        server.rdb_save_time_start = time(NULL);
        server.rdb_child_pid = childpid;
        server.rdb_child_type = RDB_CHILD_TYPE_DISK;
        updateDictResizePolicy();
        return C_OK;
    }
    return C_OK; /* unreached */
}
複製代碼

爲何 Redis 使用子進程而不是線程來進行後臺 RDB 持久化呢?主要是出於Redis性能的考慮,咱們知道Redis對客戶端響應請求的工做模型是單進程和單線程的,若是在主進程內啓動一個線程,這樣會形成對數據的競爭條件。因此爲了不使用鎖下降性能,Redis選擇啓動新的子進程,獨立擁有一份父進程的內存拷貝,以此爲基礎執行RDB持久化。

可是須要注意的是,fork 會消耗必定時間,而且父子進程所佔據的內存是相同的,當 Redis 鍵值較大時,fork 的時間會很長,這段時間內 Redis 是沒法響應其餘命令的。除此以外,Redis 佔據的內存空間會翻倍。

生成 RDB 文件,而且持久化到硬盤

Redis 的 rdbSave 函數是真正進行 RDB 持久化的函數,它的大體流程以下:

  • 首先打開一個臨時文件,
  • 調用 rdbSaveRio函數,將當前 Redis 的內存信息寫入到這個臨時文件中,
  • 接着調用 fflushfsyncfclose 接口將文件寫入磁盤中,
  • 使用 rename 將臨時文件更名爲 正式的 RDB 文件,
  • 最後記錄 dirtylastsave等狀態信息。這些狀態信息在 serverCron時會使用到。
int rdbSave(char *filename, rdbSaveInfo *rsi) {
    char tmpfile[256];
    // 當前工做目錄
    char cwd[MAXPATHLEN];
    FILE *fp;
    rio rdb;
    int error = 0;

    /* 生成tmpfile文件名 temp-[pid].rdb */
    snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
    /* 打開文件 */
    fp = fopen(tmpfile,"w");
    .....
    /* 初始化rio結構 */
    rioInitWithFile(&rdb,fp);

    if (rdbSaveRio(&rdb,&error,RDB_SAVE_NONE,rsi) == C_ERR) {
        errno = error;
        goto werr;
    }

    if (fflush(fp) == EOF) goto werr;
    if (fsync(fileno(fp)) == -1) goto werr;
    if (fclose(fp) == EOF) goto werr;

    /* 從新命名 rdb 文件,把以前臨時的名稱修改成正式的 rdb 文件名稱 */
    if (rename(tmpfile,filename) == -1) {
        // 異常處理
        ....
    }
    // 寫入完成,打印日誌
    serverLog(LL_NOTICE,"DB saved on disk");
    // 清理數據保存記錄
    server.dirty = 0;
    // 最後一次完成 SAVE 命令的時間
    server.lastsave = time(NULL);
    // 最後一次 bgsave 的狀態置位 成功
    server.lastbgsave_status = C_OK;
    return C_OK;
    ....
}
複製代碼

這裏要簡單說一下 fflushfsync的區別。它們倆都是用於刷緩存,可是所屬的層次不一樣。fflush函數用於 FILE* 指針上,將緩存數據從應用層緩存刷新到內核中,而fsync 函數則更加底層,做用於文件描述符,用於將內核緩存刷新到物理設備上。

關於 Linux IO 的具體原理能夠參考《聊聊Linux IO》

內存數據到 RDB 文件

rdbSaveRio 會將 Redis 內存中的數據以相對緊湊的格式寫入到文件中,其文件格式的示意圖以下所示。

rdbSaveRio函數的寫入大體流程以下:

  • 先寫入 REDIS 魔法值,而後是 RDB 文件的版本( rdb_version ),額外輔助信息 ( aux )。輔助信息中包含了 Redis 的版本,內存佔用和複製庫( repl-id )和偏移量( repl-offset )等。

  • 而後 rdbSaveRio 會遍歷當前 Redis 的全部數據庫,將數據庫的信息依次寫入。先寫入 RDB_OPCODE_SELECTDB識別碼和數據庫編號,接着寫入RDB_OPCODE_RESIZEDB識別碼和數據庫鍵值數量和待失效鍵值數量,最後會遍歷全部的鍵值,依次寫入。

  • 在寫入鍵值時,當該鍵值有失效時間時,會先寫入RDB_OPCODE_EXPIRETIME_MS識別碼和失效時間,而後寫入鍵值類型的識別碼,最後再寫入鍵和值。

  • 寫完數據庫信息後,還會把 Lua 相關的信息寫入,最後再寫入 RDB_OPCODE_EOF結束符識別碼和校驗值。

int rdbSaveRio(rio *rdb, int *error, int flags, rdbSaveInfo *rsi) {
    snprintf(magic,sizeof(magic),"REDIS%04d",RDB_VERSION);
    /* 1 寫入 magic字符'REDIS' 和 RDB 版本 */
    if (rdbWriteRaw(rdb,magic,9) == -1) goto werr;
    /* 2 寫入輔助信息  REDIS版本,服務器操做系統位數,當前時間,複製信息好比repl-stream-db,repl-id和repl-offset等等數據*/
    if (rdbSaveInfoAuxFields(rdb,flags,rsi) == -1) goto werr;
    /* 3 遍歷每個數據庫,逐個數據庫數據保存 */
    for (j = 0; j < server.dbnum; j++) {
        /* 獲取數據庫指針地址和數據庫字典 */
        redisDb *db = server.db+j;
        dict *d = db->dict;
        /* 3.1 寫入數據庫部分的開始標識 */
        if (rdbSaveType(rdb,RDB_OPCODE_SELECTDB) == -1) goto werr;
        /* 3.2 寫入當前數據庫號 */
        if (rdbSaveLen(rdb,j) == -1) goto werr;

        uint32_t db_size, expires_size;
        /* 獲取數據庫字典大小和過時鍵字典大小 */
        db_size = (dictSize(db->dict) <= UINT32_MAX) ?
                                dictSize(db->dict) :
                                UINT32_MAX;
        expires_size = (dictSize(db->expires) <= UINT32_MAX) ?
                                dictSize(db->expires) :
                                UINT32_MAX;
        /* 3.3 寫入當前待寫入數據的類型,此處爲 RDB_OPCODE_RESIZEDB,表示數據庫大小 */
        if (rdbSaveType(rdb,RDB_OPCODE_RESIZEDB) == -1) goto werr;
        /* 3.4 寫入獲取數據庫字典大小和過時鍵字典大小 */
        if (rdbSaveLen(rdb,db_size) == -1) goto werr;
        if (rdbSaveLen(rdb,expires_size) == -1) goto werr;
        /* 4 遍歷當前數據庫的鍵值對 */
        while((de = dictNext(di)) != NULL) {
            sds keystr = dictGetKey(de);
            robj key, *o = dictGetVal(de);
            long long expire;

            /* 初始化 key,由於操做的是 key 字符串對象,而不是直接操做 鍵的字符串內容 */
            initStaticStringObject(key,keystr);
            /* 獲取鍵的過時數據 */
            expire = getExpire(db,&key);
            /* 4.1 保存鍵值對數據 */
            if (rdbSaveKeyValuePair(rdb,&key,o,expire) == -1) goto werr;
        }

    }

    /* 5 保存 Lua 腳本*/
    if (rsi && dictSize(server.lua_scripts)) {
        di = dictGetIterator(server.lua_scripts);
        while((de = dictNext(di)) != NULL) {
            robj *body = dictGetVal(de);
            if (rdbSaveAuxField(rdb,"lua",3,body->ptr,sdslen(body->ptr)) == -1)
                goto werr;
        }
        dictReleaseIterator(di);
    }

    /* 6 寫入結束符 */
    if (rdbSaveType(rdb,RDB_OPCODE_EOF) == -1) goto werr;

    /* 7 寫入CRC64校驗和 */
    cksum = rdb->cksum;
    memrev64ifbe(&cksum);
    if (rioWrite(rdb,&cksum,8) == 0) goto werr;
    return C_OK;
}
複製代碼

rdbSaveRio在寫鍵值時,會調用rdbSaveKeyValuePair 函數。該函數會依次寫入鍵值的過時時間,鍵的類型,鍵和值。

int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val, long long expiretime)
{
    /* 若是有過時信息 */
    if (expiretime != -1) {
        /* 保存過時信息標識 */
        if (rdbSaveType(rdb,RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
        /* 保存過時具體數據內容 */
        if (rdbSaveMillisecondTime(rdb,expiretime) == -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;
}
複製代碼

根據鍵的不一樣類型寫入不一樣格式,各類鍵值的類型和格式以下所示。

Redis 有龐大的對象和數據結構體系,它使用六種底層數據結構構建了包含字符串對象、列表對象、哈希對象、集合對象和有序集合對象的對象系統。感興趣的同窗能夠參考 《十二張圖帶你瞭解 Redis 的數據結構和對象系統》一文。

不一樣的數據結構進行 RDB 持久化的格式都不一樣。咱們今天只看一下集合對象是如何持久化的。

ssize_t rdbSaveObject(rio *rdb, robj *o) {
    ssize_t n = 0, nwritten = 0;
    ....
    } else if (o->type == OBJ_SET) {
        /* Save a set value */
        if (o->encoding == OBJ_ENCODING_HT) {
            dict *set = o->ptr;
            // 集合迭代器
            dictIterator *di = dictGetIterator(set);
            dictEntry *de;
            // 寫入集合長度
            if ((n = rdbSaveLen(rdb,dictSize(set))) == -1) return -1;
            nwritten += n;
            // 遍歷集合元素
            while((de = dictNext(di)) != NULL) {
                sds ele = dictGetKey(de);
                // 以字符串的形式寫入,由於是SET 因此只寫入 Key 便可
                if ((n = rdbSaveRawString(rdb,(unsigned char*)ele,sdslen(ele)))
                    == -1) return -1;
                nwritten += n;
            }
            dictReleaseIterator(di);
        } 
    .....
    return nwritten;
}
複製代碼

公衆號原文

博客原文

相關文章
相關標籤/搜索