Redis 持久化(Persistence)


作為內存數據庫,Redis 依然提供了持久化機制,其主要目的有兩個:

  • 安全:保證進程崩潰后數據不會丟失
  • 備份:方便數據遷移與快速恢復

Redis 同時提供兩種持久化機制:

  • RDB 快照:數據庫在某個時間點的完整狀態,其存儲內容為鍵值對
  • AOF 日志:包含所有改變數據庫狀態的操作,其存儲內容為命令

RDB 快照

生成 RDB 快照的方式有兩種:

  • 服務進程定期生成
  • 手動執行 SAVEBGSAVE 命令

定期生成

用戶可以通過設置保存點save point,控制 RDB 快照的自動生成:

save 900 1    # 最近 15 分鍾內,至少有 1 個 key 發生過變更
save 300 10   # 最近 5 分鍾內,至少有 10 個 key 發生過變更
save 60 10000 # 最近 1 分鍾內,至少有 10000 個 key 發生過變更
struct saveparam {
    time_t seconds; // 秒數
    int changes;    // 變更數
};

struct redisServer {
    // ...
    struct saveparam *saveparams;   /* RDB 保存點數組 */
    int saveparamslen;              /* 保存點數量 */
    long long dirty;                /* 上一次執行快照后的變更數 */
    time_t lastsave;                /* 上一次執行快照的 UNIX 時間戳 */
}
+---------------+
|  redisServer  |
+---------------+    +---------------+---------------+---------------+
|  saveparams   | -> | saveparams[0] | saveparams[1] | saveparams[2] |
+---------------+    +---------------+---------------+---------------+
| saveparamslen |    |    seconds    |    seconds    |    seconds    |
|       3       |    |      900      |      300      |       60      |
+---------------+    +---------------+---------------+---------------+
|     dirty     |    |    changes    |    changes    |    changes    |
|      120      |    |       1       |       10      |     10000     |
+---------------+    +---------------+---------------+---------------+
|   lastsave    |
|  1378270800   |
+---------------+     

自動保存的過程:

  1. 每執行一個數據庫修改命令,計數器 dirty 就會記錄該記錄導致的變更數量
  2. Redis 的定時任務 serverCron 會周期性地檢查是否滿足保存點條件:
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    // ...
    for (j = 0; j < server.saveparamslen; j++) {
        struct saveparam *sp = server.saveparams+j;
        if (server.dirty >= sp->changes && // 檢查變更數是否足夠
            server.unixtime-server.lastsave > sp->seconds) // 檢查最近一次快照時間
        {
            // 如果當前狀態滿足保存點設置,打印日志並開始執行 BGSAVE
            serverLog(LL_NOTICE,"%d changes in %d seconds. Saving...", sp->changes, (int)sp->seconds);
            // ...
            // 執行 BGSAVE
            rdbSaveBackground(server.rdb_filename,rsiptr);
            break;
        }
    }
}

手動備份

為了避免在流量高峰期發生性能抖動,在生產環境中往往會關閉 Redis 的自動生成快照的功能。為了保證數據安全,此時運維會使用定時腳本的方式,在系統空閑時執行 BGSAVE 命令備份 Redis 數據。

int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
    // ...
    if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) { // 產生子進程
        /* 子進程負責生成 RDB 快照 */
        int retval = rdbSave(filename,rsi);
        // ...
    } else {
        /* 主進程不阻塞直接返回 */
        serverLog(LL_NOTICE,"Background saving started by pid %d",childpid);
        updateDictResizePolicy(); // 如果子進程正生成快照,禁止 dict 進行 rehash 操作
        // ...
        return C_OK;
    }
}

RDB 文件由子進程生成的,操作系統寫時復制 copy-on-write 的優化特性,決定了父子進程間的內存在邏輯上是獨立的。
因此主進程所產生的任何修改操作都不會被包含在 RDB 文件中,間接保證了 RDB 所記錄狀態的一致性。

RDB 文件

RDB 快照是一個二進制文件,其格式大致如下:

# 有 n 個數據庫的 RDB 文件
+-------+------------+-------+-----+-------+-----+-----------+
| REDIS | db_version | db[0] | ... | db[n] | EOF | check_sum |
+-------+------------+-------+-----+-------+-----+-----------+

# 每個數據庫包含任意長度的鍵值對
+-------+    +----------+---+------------+-----+------------+
| db[0] | => | SELECTDB | 0 | kv_pair[0] | ... | kv_pair[n] | 
+-------+    +----------+---+------------+-----+------------+

# 鍵值對,常量 TYPE 指示了 value 的編碼類型
+---------+    +------+-----+-------+
| kv_pair | => | TYPE | key | value |
+---------+    +------+-----+-------+

# 帶過期時間的鍵值對,常量 EXPIRETIME_MS 緊接着一個 8 字節的時間戳
+------------------+    +---------------+--------------+------+-----+-------+
| kv_pair_with_ttl | => | EXPIRETIME_MS | ms_timestamp | TYPE | key | value |
+------------------+    +---------------+--------------+------+-----+-------+

RDB 快照存儲了數據庫在某個時間點的完整狀態,且格式緊湊,十分適合作為數據備份:

  • 方便通過網絡傳輸到異地機櫃,實現多機房容災
  • 通過使用 RESTORE 命令加載 RDB 快照,可以實現數據初始化或者緊急回滾

AOF 日志

生成 RDB 快照的過程比較耗時,無法頻繁執行 BGSAVE。但如果狀態變更長時間不落盤,一旦進程崩潰,將會丟失大量未持久化的數據。

為了避免全量備份的開銷,Redis 支持以增量更新的方式,將狀態變更持久化到 AOF 日志中,減少對磁盤 I/O 的壓力。

由於 AOF 日志落盤是由主線程完成的,因此落盤策略會明顯影響到 Redis 的性能。下列配置項可用於控制這一行為:

appendonly no         # 是否開啟 AOF

# 落盤策略
# always:每次發生變更會立即落盤
# everysec:每秒落盤一次
# no:由操作系統決定落盤時機
appendfsync everysec
struct redisServer {
    // ...
    int aof_enabled;                /* AOF 開關 */
    int aof_state;                  /* AOF 狀態(開啟、關閉、等待重寫)*/
    int aof_fsync;                  /* fsync 策略 */
    sds aof_buf;                    /* AOF 緩沖 */
    time_t aof_flush_postponed_start; /* AOF 延遲刷新 UNIX 時間戳 */
}

追加命令

每當成功執行完一條命令,會通過 processCommand -> call -> propagate -> feedAppendOnlyFile 這條調用鏈,將命令寫入 AOF 緩存:

void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc) {
    // 將命令追加到緩沖末尾,在向客戶端返回結果前將其寫入 AOF 文件中
    if (server.aof_state == AOF_ON)
        server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf));

     // 如果有子線程正在執行 AOF 重寫,期間會將新增的修改記錄入一個新的 AOF 日志
    if (server.aof_child_pid != -1)
        aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf));
}

寫入文件

serverCron 事件循環結束前,會調用 flushAppendOnlyFile 將緩沖中的命令寫入到 AOF 日志文件中:

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    // ...
    // AOF延遲刷新:每個 cron 循環都執行執行一次 fsync
    if (server.aof_flush_postponed_start) flushAppendOnlyFile(0);
}

void flushAppendOnlyFile(int force) {
    ssize_t nwritten;
    int sync_in_progress = 0;

    if (sdslen(server.aof_buf) == 0) { // 緩沖為空直接返回
        // ...
        return;
    }

    // 將命令寫入 AOF 文件,此時尚未落盤
    nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));
    
    server.aof_flush_postponed_start = 0; // 寫入完成,重置延遲刷新時間戳,避免再次觸發

    // ...

    if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
        // 落盤策略為 always,則立即執行 fsync
        redis_fsync(server.aof_fd);
        server.aof_fsync_offset = server.aof_current_size;
        server.aof_last_fsync = server.unixtime;
    } else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC &&
                server.unixtime > server.aof_last_fsync)) { 
        // 落盤策略為 everysec,則 fsync 交由后台進程異步完成
        if (!sync_in_progress) {
            aof_background_fsync(server.aof_fd);
            server.aof_fsync_offset = server.aof_current_size;
        }
        server.aof_last_fsync = server.unixtime;
    }
}

值得注意的是,如果寫入 AOF 文件過程中發生錯誤,且落盤策略為 always,此時 Redis 進程會直接退出。

日志重寫

在不斷接收寫命令的過程中,AOF 文件會越來越大,這將導致以下問題:

  • 文件系統對文件大小有限制,無法保存過大的文件
  • 故障恢復時,需要逐個執行 AOF 日志的命令,如果日志文件太大,將導致整個過程會非常緩慢

導致該問題的一個重要原因就是存在冗余命令

# 執行命令
127.0.0.1:6379> INCR counter
(integer) 1
127.0.0.1:6379> INCR counter
(integer) 2
127.0.0.1:6379> INCR counter
(integer) 3

# 對應的 AOF 日志
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n
*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n
*2\r\n$4\r\nINCR\r\n$7\r\ncounter\r\n

Redis 提供了重寫機制rewrite,能夠大幅縮減不必要的冗余命令:

# 重寫日志,並輸出到一個新的文件中
127.0.0.1:6379> BGREWRITEAOF

# 重寫后的 AOF 日志將 3 個 INCR 命令轉化為 1 個 SET 命令
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$7\r\ncounter\r\n$1\r\n3

除了手動執行 BGREWRITEAOF 命令之外,Redis 也支持自動觸發 AOF 重寫。下列配置項可用於控制這一行為:

# 重寫策略
no-appendfsync-on-rewrite no    # 重寫 AOF 日志時禁止落盤
auto-aof-rewrite-percentage 100 # 當增長百分比超過該值時,觸發 AOF 重寫
auto-aof-rewrite-min-size 64mb  # 當日志文件體積超過該值后,觸發 AOF 重寫
struct redisServer {
    // ...
    int aof_no_fsync_on_rewrite;    /* 重寫 AOF 過程中禁止調用 fsync 落盤 */
    int aof_rewrite_perc;           /* 觸發 AOF 重寫的文件增長百分比 */
    off_t aof_rewrite_min_size;     /* 觸發 AOF 重寫的最小文件體積 */
    int aof_rewrite_scheduled;      /* 是否有重寫操作在等待 BGSAVE 完成 */
    list *aof_rewrite_buf_blocks;   /* AOF 重寫緩沖 */
}

Redis 的定時任務 serverCron 會周期性地檢查是否滿足重寫條件:

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    
    /*
      延遲重寫:在服務器執行 BGSAVE 命令期間,如果接收到 BGWRITEAOF 命令,會將其延遲到 BGSAVE 完成后再執行,避免相互爭搶磁盤資源 I/O
    */
    if (!hasActiveChildProcess() &&   // 無執行后台操作的子進程,意味着 BGSAVE 已經完成
        server.aof_rewrite_scheduled) // 存在等待執行的 BGWRITEAOF 命令
    {
        rewriteAppendOnlyFileBackground();
    }
    
    // ...

    if (server.aof_state == AOF_ON && 
        server.aof_rewrite_perc && 
        server.aof_current_size > server.aof_rewrite_min_size) // 檢查日志體積是否達標
    {
        // 檢查日志增量是否達標
        long long base = server.aof_rewrite_base_size ?
            server.aof_rewrite_base_size : 1;
        long long growth = (server.aof_current_size*100/base) - 100;
        if (growth >= server.aof_rewrite_perc) {
            // 如果當前狀態滿足重寫條件,打印日志並開始執行 BGREWRITEAOF
            serverLog(LL_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
            rewriteAppendOnlyFileBackground();
        }
    }
}
int rewriteAppendOnlyFileBackground(void) {
    // ...
    if ((childpid = redisFork(CHILD_TYPE_AOF)) == 0) {
        /* 子進程負責重寫 AOF 日志 */
        char tmpfile[256];
        if (rewriteAppendOnlyFile(tmpfile) == C_OK) {
            // ...
        }
    } else {
        /* 主進程不阻塞直接返回 */
        serverLog(LL_NOTICE,
            "Background append only file rewriting started by pid %d",childpid);
        updateDictResizePolicy();
        return C_OK;
    }
}

重寫過程中,主線程仍然正常對外服務,數據庫狀態仍然會進行變更,但子進程重寫后的 AOF 不會包含這些變更。

因此,這些新增的命令會被同時追加到 AOF 緩沖 server.aof_buf重寫緩沖 server.aof_rewrite_buf_blocks 中。當子進程重寫完成后,將 重寫緩沖 追加至重寫完成的 AOF 日志中即可。

此外,為了避免與子進程的重寫過程爭搶磁盤I/O,可以通過 aof_no_fsync_on_rewrite 禁止主進程在重寫期間調用 fsync 落盤 AOF 日志。

兩者比較

RDB 快照

優點:文件結構緊湊,節省空間,易於傳輸,能夠快速恢復

缺點:生成快照的開銷只與數據庫大小相關,當數據庫較大時,生成快照耗時,無法頻繁進行該操作

AOF 日志

優點:細粒度記錄對磁盤I/O壓力小,允許頻繁落盤,數據丟失的概率極低

缺點:恢復速度慢;記錄日志開銷與更新頻率有關,頻繁更新會導致磁盤 I/O 壓力上升


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM