1. 程式人生 > >redis原始碼分析與思考(十八)——RDB持久化

redis原始碼分析與思考(十八)——RDB持久化

    redis是一個鍵值對的資料庫伺服器,伺服器中包含著若干個非空的資料庫,每個非空資料庫裡又包含著若干個鍵值對。因為redis是一個基於記憶體存貯的資料庫,他將自己所存的資料存於記憶體中,如果不將這些資料及時的儲存在硬碟中,當電腦關機或者進行清除記憶體的操作時,redis儲存的資料一定會發生丟失的狀況,這對於資料庫來說,是一個災難性的問題。為了解決這個問題,redis提出了RDB持久化來及時的儲存資料。

    RDB全稱Redis DataBase,也就是redis資料庫的意思。當redis關閉其伺服器時,redis會自動啟動RDB持久化,將記憶體中的鍵值對資料轉化為一個RDB檔案儲存下來,當伺服器重新啟動的時候,伺服器會把RDB檔案轉化成記憶體中的鍵值對。RDB持久化可以手動輸入指令啟用,也可以在伺服器設定裡面定期RDB持久化。

RDB持久化觸發的方式

  1. 手動觸發:在客戶端鍵入SAVE命令或者BGSAVE命令,手動啟用,而BGSAVE是開一個子執行緒,將RDB持久化放置在後臺進行;
  2. 伺服器關閉或者啟動且AOF持久化未開啟:伺服器啟動時且AOF持久化未開啟式,會自動載入、寫入RDB檔案,進行資料庫的恢復;
  3. 定期存貯:redis的時間事件會定期的處理資料庫中的"dirty"資料;

RDB持久化與伺服器

    SAVE命令執行的時候,redis伺服器會被阻塞,同理,其它從客戶端發來的請求都會被阻塞:

void saveCommand(redisClient *
c) { //如果沒有BGSAVE子執行緒執行中 // BGSAVE 已經在執行中,不能再執行 SAVE // 否則將產生競爭條件 if (server.rdb_child_pid != -1) { //返回客戶端錯誤 return; } // 執行 if (rdbSave(server.rdb_filename) == REDIS_OK)//返回客戶端命令成功執行

    而BGSAVE命令不同,BGSAVE命令是在後臺開啟一個子執行緒,所以可以接受來自客戶端的命令。BGSAVE命令與SAVE命令一樣的是,在BASAVE命令執行的時候,再次接收到BGSAVE與SAVE命令會被預設為不執行,但是BGWRITEAOF命令會在執行完BGSAVE命令後執行,因為兩者都是都子執行緒來執行的,邏輯程式碼如下:

void bgsaveCommand(redisClient *c) {
    // 不能重複執行 BGSAVE
    if (server.rdb_child_pid != -1) {
      //返回錯誤資訊給客戶端
    // 不能在 BGREWRITEAOF 正在執行時執行
    } else if (server.aof_child_pid != -1) {
      //返回錯誤資訊給客戶端
    // 執行 BGSAVE
    } else if (rdbSaveBackground(server.rdb_filename) == REDIS_OK) {
        //返回成功資訊給客戶端
    } else {
        //返回錯誤資訊給客戶端
    }
}

    在伺服器中有幾個定義與RDB持久化有關在這裡列出來:

struct redisServer {
//.......
// 這個值為真時,表示伺服器正在進行載入
    int loading; 
    //儲存RDB儲存的條件
    struct saveparam *saveparams; 
    // 自從上次 SAVE 執行以來,資料庫被修改的次數
    long long dirty;
    //記錄上次RDB持久化成功的時間       
    time_t lastsave;
//......// 伺服器的儲存條件(BGSAVE 自動執行的條件)
struct saveparam {
    // 多少秒之內
    time_t seconds;
    // 發生多少次修改
    int changes;
};

    loading屬性是表示RDB持久化載入時的狀態,當該屬性為1的時候,表示正在RDB載入時的狀態,這時候伺服器是阻塞的。saveparams陣列儲存著多個RDB持久化儲存的條件,其中只要有一個被滿足,那麼就進行持久化,在伺服器中serverCron會檢測其是否滿足陣列中的狀態:

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
//......
// 遍歷所有儲存條件,看是否需要執行 BGSAVE 命令
         for (j = 0; j < server.saveparamslen; j++) {
            struct saveparam *sp = server.saveparams+j;
            // 檢查是否有某個儲存條件已經滿足了
            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))
            {
             //......
                // 執行 BGSAVE
                rdbSaveBackground(server.rdb_filename);
                break;
            }
         }
//......
}

    dirty屬性是記錄在上次RDB持久化後對資料修改了的次數,而lastsave屬性記錄了上次RDB持久化完成的時間,是一個UNIX時間戳。

rio流

    在討論RDB的檔案結構以及其它時,先來了解一下rio流。什麼是rio流呢?

rio.c is a simple stream-oriented I/O abstraction that provides an interface to write code that can consume/produce data using different concrete input and output devices.

    上述是redis作者的原話,翻譯過來就是RIO 是一個自定義的可以面向流、可用於對多種不同的輸入(目前是檔案和記憶體位元組)進行程式設計的抽象。簡單來說rio流的作用是用來結構化讀取和寫入RDB檔案。列出rio流的結構:

struct _rio {
    // API,讀取、寫入以及獲取偏移量的函式
    size_t (*read)(struct _rio *, void *buf, size_t len);
    size_t (*write)(struct _rio *, const void *buf, size_t len);
    off_t (*tell)(struct _rio *);
    // 校驗和計算函式,每次有寫入/讀取新資料時都要計算一次
    void (*update_cksum)(struct _rio *, const void *buf, size_t len);
    // 當前校驗和
    uint64_t cksum;
    /* 讀入或者寫入rio的位元組數 */
    size_t processed_bytes;
    /* 每次最大讀取和 寫入的位元組數*/
    size_t max_processing_chunk;
    /* Backend-specific vars. */
    union {
        struct {
            // 快取指標
            sds ptr;
            // 偏移量
            off_t pos;
        } buffer;
        struct {
            // 被開啟檔案的指標
            FILE *fp;
            // 最近一次 fsync() 以來,寫入的位元組量
            off_t buffered; 
            // 寫入多少位元組之後,才會自動執行一次 fsync()
            off_t autosync; 
        } file;
    } io;
};

    cksum屬性是用來校驗資料的多少的,當完成RDB持久化時,伺服器根據配置來決定是否啟用校驗函式來校驗RDB檔案資料的正確性。而io聯合定義了一個緩衝區,以及一個檔案,緩衝區的pos代表著從RDB檔案中讀取或者寫入RDB檔案中的進度,表示寫入/讀取了多少個位元組,而檔案則指向RDB檔案,在這裡設定同步屬性是防止資料的寫入丟失,因為作業系統在寫入資料的時候,會建立一個緩衝區,當緩衝區裡面的資料滿了以後才會將資料從緩衝區裡寫入到檔案中去。下面列出兩個rio重要的函式,讀取以及寫入:

/*
 * 將 buf 中的 len 位元組寫入到 r 中。
 * 寫入成功返回實際寫入的位元組數,寫入失敗返回 -1 。
 */
static inline size_t rioWrite(rio *r, const void *buf, size_t len) {
    while (len) {
        size_t bytes_to_write = (r->max_processing_chunk && r->max_processing_chunk < len) ? r->max_processing_chunk : len;
        if (r->update_cksum) r->update_cksum(r,buf,bytes_to_write);
        if (r->write(r,buf,bytes_to_write) == 0)
            return 0;
        //buf指標往前移動bytes_to_write個位元組,
        buf = (char*)buf + bytes_to_write;
        len -= bytes_to_write;
        r->processed_bytes += bytes_to_write;
    }
    return 1;
}

/*
 * 從 r 中讀取 len 位元組,並將內容儲存到 buf 中。
 * 讀取成功返回 1 ,失敗返回 0 。
 */
static inline size_t rioRead(rio *r, void *buf, size_t len) {
    while (len) {
        size_t bytes_to_read = (r->max_processing_chunk && r->max_processing_chunk < len) ? r->max_processing_chunk : len;
        if (r->read(r,buf,bytes_to_read) == 0)
            return 0;
        if (r->update_cksum) r->update_cksum(r,buf,bytes_to_read);
        buf = (char*)buf + bytes_to_read;
        len -= bytes_to_read;
        r->processed_bytes += bytes_to_read;
    }
    return 1;
}

    rio流有幾點好處,第一就是可以直接得到寫入與讀取的進度,第二點就是可以同時對記憶體或檔案的RDB資料進行讀寫。

RDB檔案

    RDB檔案是一個壓縮過的二進位制檔案,它與壓縮列表以及整數集合一樣,分為幾大部分,如圖示:
在這裡插入圖片描述
    1. REDIS部分長度為5個位元組,儲存著"REDIS"五個字元,通過這部分,程式可判斷出該檔案是否是RDB檔案。
    2. RDB_VERSION部分長度為4個位元組,這一部分儲存著RDB檔案的版本號。
    3. DATABASE部分對應著各個資料庫,按順序排列,儲存著資料庫中的資料,長度不定。
    4. EOF部分代表著程式讀到這一塊的時候,表示已經沒有資料庫的資料可讀了,佔一個位元組。
    5. CHECK_SUM部分是用來檢驗的,當RDB檔案載入時,會將載入檔案計算得出的校驗和與該部分的資料進行對比,以此來確認RDB檔案是否損壞,佔8個位元組。
    上述分析可以從rdb.c/rdbSave函式即RDB寫入函式得出:

int rdbSave(char *filename) {

    ......
  
    // 設定校驗和函式
    if (server.rdb_checksum)
        rdb.update_cksum = rioGenericUpdateChecksum;
    // 寫入 REDIS部分以及RDB_VERSIOn部分
    snprintf(magic,sizeof(magic),"REDIS%04d",REDIS_RDB_VERSION);
    if (rdbWriteRaw(&rdb,magic,9) == -1) goto werr;
    // 遍歷所有資料庫
    for (j = 0; j < server.dbnum; j++) {
       //寫入DATABASES部分
    }
    
    .......
  
    /* 
     * 寫入 EOF 部分
     */
    if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_EOF) == -1) goto werr;
    //寫入校驗和
    /* 
     * CRC64 校驗和。
     * 如果校驗和功能已關閉,那麼 rdb.cksum 將為 0 ,
     * 在這種情況下, RDB 載入時會跳過校驗和檢查。
     */
    cksum = rdb.cksum;
    memrev64ifbe(&cksum);
    rioWrite(&rdb,&cksum,8);
    // 沖洗快取,確保資料已寫入磁碟
    if (fflush(fp) == EOF) goto werr;
    if (fsync(fileno(fp)) == -1) goto werr;
    if (fclose(fp) == EOF) goto werr;
    /* 
     * 使用 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;
    }
    
    .......
    
}

    我們著重講解分析DATABASES部分,該部分儲存每個資料庫的部分又分為三個部分,如圖示:
在這裡插入圖片描述
    1. SELECTDB部分佔一個位元組,這個部分的意義在於當RDB檔案載入的時候,程式讀到該值,就會明白它下一個位元組或者幾個位元組是儲存著資料庫的號碼的。該值以及其它的重要的值巨集定義如下:

/*
 * 資料庫特殊操作識別符號
 */
// 以 MS 計算的過期時間
#define REDIS_RDB_OPCODE_EXPIRETIME_MS 252
// 以秒計算的過期時間
#define REDIS_RDB_OPCODE_EXPIRETIME 253
// 選擇資料庫
#define REDIS_RDB_OPCODE_SELECTDB   254
// 資料庫的結尾(但不是 RDB 檔案的結尾)
#define REDIS_RDB_OPCODE_EOF        255

    2. DB_NUMBER部分是儲存著資料庫的號碼,它視自身的大小佔1個位元組、2個位元組或者5個位元組,關於它如何的判斷其佔幾個位元組,是屬於值的長度編碼,放在下面講解,在RDB寫入函式寫入DATABASE部分可以找到該段程式碼:

 /* 
  * 寫入 DB 選擇器
  */
//寫入
  if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_SELECTDB) == -1) goto werr;
  if (rdbSaveLen(&rdb,j) == -1) goto werr;

    3. KEY_VALUE_PAIRS是儲存資料的地方,我們要先明白一點,不管是上面所說的,還是接下來所描述的,它們都是二進位制存貯的,而且redis中所有的鍵值對,不管5種物件裡面的哪個物件,究其根本,都儲存著的是字串物件,所以RDB持久化是基於以字串物件來儲存資料的。它分為5部分:
在這裡插入圖片描述
    前兩個部分只有設定了過期時間的鍵才有,後面三個部分是通用的。
    EXPIRETIME部分佔一個位元組,用來在RDB載入時提示後面8個位元組表示是鍵的過期時間。
    MS部分佔8個位元組,用來描述鍵的過期時間。TYPE部分佔一個位元組,用來表達儲存的鍵是5種類型中的哪一種。
    KEY部分儲存鍵,也就是儲存了一個字串物件,VALUES部分儲存了5種物件的資料。如下程式碼所示:

/*
 * @param rdb rio
 * @param key 鍵物件
 * @param val 值物件
 * @param expiretime 過期時間
 * @param now 現在時間
 * @return 
 */
int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val,
                        long long expiretime, long long now)
{
     //儲存鍵的過期時間
    if (expiretime != -1) {
         // 不寫入已經過期的鍵
        if (expiretime < now) return 0;
        if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
        if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
    }
     // 儲存型別,鍵,值
    if (rdbSaveObjectType(rdb,val) == -1) return -1;
    if (rdbSaveStringObject(rdb,key) == -1) return -1;
    if (rdbSaveObject(rdb,val) == -1) return -1;
    return 1;
}

值的編碼

    不管是鍵物件,還是值物件,都要進行特定得到編碼才能存貯進RDB檔案,在上文中提到RDB檔案儲存的是字串,所以任何的物件都要轉換成字串來儲存,值的編碼種類有三種:整數型別、未壓縮的字串型別以及壓縮過的字串型別。在詳細講解這三種類型時,先來看看幾個封裝了rio流的重要的函式:

/*
 * 將長度為 len 的字元陣列 p 寫入到 rdb 中。
 * 寫入成功返回 len ,失敗返回 -1 。
 */
static int rdbWriteRaw(rio *rdb, void *p, size_t len) {
    if (rdb && rioWrite(rdb,p,len) == 0)
        return -1;
    return len;
}

 // 將長度為 1 位元組的字元 type 寫入到 rdb 檔案中。
int rdbSaveType(rio *rdb, unsigned char type) {
    return rdbWriteRaw(rdb,&type,1);
}

/*
 * 函式即可以用於載入鍵的型別(rdb.h/REDIS_RDB_TYPE_*),
 * 也可以用於載入特殊標識號(rdb.h/REDIS_RDB_OPCODE_*)
 */
int rdbLoadType(rio *rdb) {
    unsigned char type;
    if (rioRead(rdb,&type,1) == 0) return -1;
    return type;
}

    再來看看對長度的編碼,長度編碼的設計是因為字串不能明確的表示在二進位制中的界限,也就是為了防止讀取混亂設計的,長度的編碼不定,有1個位元組,2個位元組以及5個位元組,長度編碼的種類:

#define REDIS_RDB_6BITLEN 0
#define REDIS_RDB_14BITLEN 1
#define REDIS_RDB_32BITLEN 2

    分別對應:


編碼種類 對應格式
REDIS_RDB_6BITLEN 00******,前兩位表示哪種種類的編碼,後6位儲存儲存字串需要的位元組多少
REDIS_RDB_14BITLEN 01****** ********,前兩位表示哪種種類的編碼,後14位儲存儲存字串需要的位元組多少
REDIS_RDB_32BITLEN 10****** [**…總共32位],前兩位表示哪種種類的編碼,後32位儲存儲存字串需要的位元組多少

    對長度編碼的轉換程式碼如下:

//返回需要長度編碼所佔的位元組數
int rdbSaveLen(rio *rdb, uint32_t len) {
    unsigned char buf[2];
    size_t nwritten;
    //第一個buf前兩位存貯編碼格式,後面跟著的位元組儲存需要儲存字串的位元組數多少
    if (len < (1<<6)) {
        /* Save a 6 bit len */
        buf[0] = (len&0xFF)|(REDIS_RDB_6BITLEN<<6);
        if (rdbWriteRaw(rdb,buf,1) == -1) return -1;
        nwritten = 1;
    } else if (len < (1<<14)) {
        /* Save a 14 bit len */
        buf[0] = ((len>>8)&0xFF)|(REDIS_RDB_14BITLEN<<6);
        buf[1] = len&0xFF;
        if (rdbWriteRaw(rdb,buf,2) == -1) return -1;
        nwritten = 2;
    } else {
        /* Save a 32 bit len */
        buf[0] = (REDIS_RDB_32BITLEN<<6);
        if (rdbWriteRaw(rdb,buf,1) == -1) return -1;
        //本地位元組順序轉化為網路位元組序列
        len = htonl(len);
        if (rdbWriteRaw(rdb,&len,4) == -1) return -1;
        nwritten = 1+4;
    }
    return nwritten;
}

整數編碼

    整數編碼儲存的格式有兩部分,ENCODING部分與INTEGER部分,ENCODING部分儲存整數的編碼格式,佔一個位元組,INTERGER部分儲存值,佔1個、2個、5個位元組不等。編碼格式有三種:

#define REDIS_RDB_ENC_INT8 0        /* 8 bit signed integer */
#define REDIS_RDB_ENC_INT16 1       /* 16 bit signed integer */
#define REDIS_RDB_ENC_INT32 2       /* 32 bit signed integer */

    8位整數、16位整數以及32位整數,從客戶端傳來的整數會被預設為long long的型別,這時,需要多整數的編碼轉換,使其節約空間,因為假如整數值是1,那麼儲存它只需要短短的一個位,但是預設儲存卻需要32位來儲存,其它的31位就浪費了,整數的編碼轉換就是完成這個任務的,其對應表如下:


編碼種類 對應格式
REDIS_RDB_ENC_INT8 11000000 ********,前一節儲存編碼種類,後一位元組儲存值
REDIS_RDB_ENC_INT6 11000001 ******** ********,前一節儲存編碼種類,後2位元組儲存值
REDIS_RDB_ENC_INT32 11000010 ******…總共32位,前一節儲存編碼種類,後4位元組儲存值

    轉換程式碼如下:

#define REDIS_RDB_ENCVAL 3
int rdbEncodeInteger(long long value, unsigned char *enc) {
    if (value >= -(1<<7) && value <= (1<<7)-1) {
        enc[0] = (REDIS_RDB_ENCVAL<<6)|REDIS_RDB_ENC_INT8;
        enc[1] = value&0xFF;
        return 2;
    } else if (value >= -(1<<15) && value <= (1<<15)-1) {
        enc[0] = (REDIS_RDB_ENCVAL<<6)|REDIS_RDB_ENC_INT16;
        enc[1] = value&0xFF;
        enc[2] = (value>>8)&0xFF;
        return 3;
    } else if (value >= -((long long)1<<31) && value <= ((long long)1<<31)-1) {
        enc[0] = (REDIS_RDB_ENCVAL<<6)|REDIS_RDB_ENC_INT32;
        enc[1] = value&0xFF;
        enc[2] = (value>>8)&0xFF;
        enc[3] = (value>>16)&0xFF;