天天看點

Redis RDB 持久化詳解

Redis 是一種記憶體資料庫,将資料儲存在記憶體中,讀寫效率要比傳統的将資料儲存在磁盤上的資料庫要快很多。但是一旦程序退出,Redis 的資料就會丢失。

為了解決這個問題,Redis 提供了 RDB 和 AOF 兩種持久化方案,将記憶體中的資料儲存到磁盤中,避免資料丢失。

antirez 在《Redis 持久化解密》一文中說,一般來說有三種常見的政策來進行持久化操作,防止資料損壞:

  • 方法1 是資料庫不關心發生故障,在資料檔案損壞後通過資料備份或者快照來進行恢複。Redis 的 RDB 持久化就是這種方式。
  • 方法2 是資料庫使用記錄檔,每次操作時記錄操作行為,以便在故障後通過日志恢複到一緻性的狀态。因為記錄檔是順序追加的方式寫的,是以不會出現記錄檔也無法恢複的情況。類似于 Mysql 的 redo 和 undo 日志,具體可以看這篇 《InnoDB的磁盤檔案及落盤機制》 文章。
  • 方法3 是資料庫不進行老資料的修改,隻是以追加方式去完成寫操作,這樣資料本身就是一份日志,這樣就永遠不會出現資料無法恢複的情況了。CouchDB就是此做法的優秀範例。

RDB 就是第一種方法,它就是把目前 Redis 程序的資料生成時間點快照( point-in-time snapshot ) 儲存到儲存設備的過程。

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 檔案中。其中大概的流程如下圖所示。

上圖表明了三種觸發 RDB 持久化的手段之間的整體關系。通過

serverCron

自動觸發的 RDB 相當于直接調用了 bgsave 指令的流程進行處理。而 bgsave 的處理流程啟動子程序後,調用了 save 指令的處理流程。

下面我們從

serverCron

自動觸發邏輯開始研究。

自動觸發 RDB 持久化

如上圖所示,

redisServer

結構體的

save_params

指向擁有三個值的數組,該數組的值與 redis.conf 檔案中 save 配置項一一對應。分别是

save 900 1

save 300 10

save 60 10000

dirty

記錄着有多少鍵值發生變化,

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 的記憶體資訊寫入到這個臨時檔案中,
  • 接着調用

    fflush

    fsync

    fclose

    接口将檔案寫入磁盤中,
  • 使用

    rename

    将臨時檔案改名為 正式的 RDB 檔案,
  • 最後記錄

    dirty

    lastsave

    等狀态資訊。這些狀态資訊在

    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;
    ....
}           

這裡要簡單說一下

fflush

fsync

的差別。它們倆都是用于刷緩存,但是所屬的層次不同。

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;
}           

繼續閱讀