Redis 持久化(Persistence)
阿新 • • 發佈:2021-02-12
作為記憶體資料庫,Redis 依然提供了持久化機制,其主要目的有兩個:
- **安全**:保證程序崩潰後資料不會丟失
- **備份**:方便資料遷移與快速恢復
Redis 同時提供兩種持久化機制: - **RDB 快照**:資料庫在某個時間點的完整狀態,其儲存內容為鍵值對 - **AOF 日誌**:包含所有改變資料庫狀態的操作,其儲存內容為命令 # RDB 快照 生成 RDB 快照的方式有兩種: - 服務程序定期生成 - 手動執行 **SAVE** 或 **BGSAVE** 命令 ## 定期生成 使用者可以通過設定儲存點`save point`,控制 RDB 快照的自動生成: ```text save 900 1 # 最近 15 分鐘內,至少有 1 個 key 發生過變更 save 300 10 # 最近 5 分鐘內,至少有 10 個 key 發生過變更 save 60 10000 # 最近 1 分鐘內,至少有 10000 個 key 發生過變更 ``` ```c struct saveparam { time_t seconds; // 秒數 int changes; // 變更數 }; struct redisServer { // ... struct saveparam *saveparams; /* RDB 儲存點陣列 */ int saveparamslen; /* 儲存點數量 */ long long dirty; /* 上一次執行快照後的變更數 */ time_t lastsave; /* 上一次執行快照的 UNIX 時間戳 */ } ``` ```text +---------------+ | 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` 會週期性地檢查是否滿足儲存點條件:
```c
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 資料。
```c
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 快照是一個二進位制檔案,其格式大致如下:
```text
# 有 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 的效能。下列配置項可用於控制這一行為:
```text
appendonly no # 是否開啟 AOF
# 落盤策略
# always:每次發生變更會立即落盤
# everysec:每秒落盤一次
# no:由作業系統決定落盤時機
appendfsync everysec
```
```c
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 快取:
```c
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 日誌檔案中:
```c
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 日誌的命令,如果日誌檔案太大,將導致整個過程會非常緩慢
導致該問題的一個重要原因就是存在**冗餘命令**:
```text
# 執行命令
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`,能夠大幅縮減不必要的冗餘命令:
```text
# 重寫日誌,並輸出到一個新的檔案中
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 重寫。下列配置項可用於控制這一行為:
```text
# 重寫策略
no-appendfsync-on-rewrite no # 重寫 AOF 日誌時禁止落盤
auto-aof-rewrite-percentage 100 # 當增長百分比超過該值時,觸發 AOF 重寫
auto-aof-rewrite-min-size 64mb # 當日志文件體積超過該值後,觸發 AOF 重寫
```
```c
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` 會週期性地檢查是否滿足重寫條件:
```c
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();
}
}
}
```
```c
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 壓
Redis 同時提供兩種持久化機制: - **RDB 快照**:資料庫在某個時間點的完整狀態,其儲存內容為鍵值對 - **AOF 日誌**:包含所有改變資料庫狀態的操作,其儲存內容為命令 # RDB 快照 生成 RDB 快照的方式有兩種: - 服務程序定期生成 - 手動執行 **SAVE** 或 **BGSAVE** 命令 ## 定期生成 使用者可以通過設定儲存點`save point`,控制 RDB 快照的自動生成: ```text save 900 1 # 最近 15 分鐘內,至少有 1 個 key 發生過變更 save 300 10 # 最近 5 分鐘內,至少有 10 個 key 發生過變更 save 60 10000 # 最近 1 分鐘內,至少有 10000 個 key 發生過變更 ``` ```c struct saveparam { time_t seconds; // 秒數 int changes; // 變更數 }; struct redisServer { // ... struct saveparam *saveparams; /* RDB 儲存點陣列 */ int saveparamslen; /* 儲存點數量 */ long long dirty; /* 上一次執行快照後的變更數 */ time_t lastsave; /* 上一次執行快照的 UNIX 時間戳 */ } ``` ```text +---------------+ | redisServer | +---------------+ +---------------+---------------+---------------+ | saveparams | ->