Redis 所有的資料和狀态存儲在記憶體中,為了避免程序退出而導緻資料丢失,需要将資料和狀态儲存到硬碟上。
為了達到這一目的,通常有兩種實作方式:
- 将 Redis 當作一個狀态機,記錄每一次的對 Redis 的操作,也就是狀态轉移。需要恢複時再從初始狀态開始,依次重放記錄的操作,這樣的方式稱作邏輯備份
- 将 Redis 完整的狀态儲存下來,待必要時原樣恢複,這樣的方式稱作實體備份
Redis 也實作了這兩種持久化方式,分别時 AOF 和 RDB
AOF
AOF 通過儲存 Redis 伺服器執行的寫指令記錄資料庫狀态。
AOF 配置
Redis 源碼中的配置檔案示例: redis.conf
# AOF 配置示例
# https://github.com/redis/redis/blob/48e24d54b736b162617112ce27ec724b9290592e/redis.conf#L489
# 重要參數:
appendonly yes # 是否開啟 AOF,如果開啟了 AOF,後續恢複資料庫時會優先使用 AOF,跳過 RDB
appendfsync everysec # 持久化判斷規則
appendfilename appendonly.aof # AOF 檔案位置
指令執行完成後才會寫入 AOF 日志
AOF 是寫後日志,與寫前日志(Write Ahead Log, WAL)相反,寫入指令執行完成後才會記錄到 AOF 日志。這樣設計是因為 AOF 記錄的是接收到的指令,并且記錄時不會進行文法檢查(保證性能),使用寫後日志有 2 個
優點:
- 可以保證日志中記錄的指令都是正确的
- 指令執行後才記錄到日志,不會阻塞目前寫操作
- 剛執行完指令,還沒寫入,此時當機,這個指令和相應的資料有丢失的風險
- 避免了目前指令的阻塞,但是可能阻塞下一個指令
AOF 持久化執行步驟
- 伺服器在執行完指令後,會将指令寫入到
的struct redisServer
` 緩沖區末尾sds aof_buf
- Redis 程序每一次事件循環(處理用戶端請求的循環)末尾都會調用
檢查時候需要将緩沖區中的指令寫入 AOF 檔案void flushAppendOnlyFile
AOF 寫入條件判斷規則
flushAppendOnlyFile
中根據配置檔案中的 appendfsync 參數判斷是否寫入 AOF 檔案。将 aof_buf 中的指令寫入 AOF 檔案分為兩個步驟:
- 調用 OS 的
函數,将 aof_buf 中的指令儲存到記憶體緩沖區write
- OS 将 記憶體緩沖區中的寫入磁盤
如果隻執行了第一步,從 redis 的視角來看,資料已經寫入了檔案,但實際上并沒有寫入,如果此時停機,資料仍然會丢失,是以可以使用 OS 提供的
fsync
和
fdatasync
強制将緩沖區中的資料寫入磁盤
flushAppendOnlyFile 行為 | appndfsync 選項 ---|---|--- 總是将 aof_buf 緩沖區中的内容寫入記憶體緩沖區,并同步到 AOF 檔案 | always 将 aof_buf 緩沖區中的内容寫入記憶體緩沖區,如果距離上一次同步超過一秒,則同步到 AOF 檔案 | everysec 隻寫入到記憶體緩沖區,由 OS 後續決定何時同步到 AOF 檔案 | no
AOF 判斷過程如下:
void flushAppendOnlyFile(int force) {
ssize_t nwritten;
...
// 調用 write 寫入檔案
nwritten = write(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));
...
// 成功寫入後
server.aof_current_size += nwritten;
...
// 根據 appndfsync 條件判斷是否同步到 AOF 檔案
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
...
// 這裡強制執行同步用的是 aof_fsync,是因為 aof_fsync 已經被定義成了 fsync
// 具體位置在 config.h:https://github.com/redis/redis/blob/48e24d54b736b162617112ce27ec724b9290592e/src/config.h#L89
aof_fsync(server.aof_fd);
...
// 成功後記錄下時間,用于下一次同步條件檢查
server.aof_last_fsync = server.unixtime;
} else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC &&
server.unixtime > server.aof_last_fsync)) {
// 在另一個線程中背景執行
if (!sync_in_progress) aof_background_fsync(server.aof_fd);
server.aof_last_fsync = server.unixtime;
}
}
AOF 檔案載入
- Redis 建立一個不帶網絡連接配接的僞用戶端
- 從 AOF 檔案中依次讀出指令并交給僞用戶端執行。這個過程和正常的 Redis 用戶端從網絡中依次讀取指令然後執行效果一緻
AOF 重寫
由于 AOF 檔案是依次記錄用戶端發來的寫入指令,在寫入較多的情況下,AOF 檔案會快速膨脹,是以需要 AOF 重寫精簡其中的指令。
AOF 重寫的過程中并不會讀取原有的 AOF 檔案,而是直接根據資料庫目前的狀态生成一份新的 AOF 檔案,類似于 SQL 導出資料時直接生成 INSERT 語句。
對于有多個元素的 key,例如大清單、大集合,簡單的将所有元素的寫入合并到一條語句中可能會形成一條過大的寫入語句,在後續執行指令時導緻用戶端輸入緩沖區溢出。是以 Redis 配置了一個
REDIS_AOF_REWRITE_ITEMS_PER_CMD
常量,當一條指令中的元素超過這個數量時,會被拆分成多條語句
AOF 緩沖
AOF 重寫過程中,Redis 伺服器仍然要接收用戶端的寫入請求,為了保證資料安全,使用了子程序執行 AOF 重寫,此時如果執行寫入指令,子程序并不知道父程序所做的修改,AOF 完成之後會出現 AOF 檔案中的資料與實際資料庫中的資料不一緻的情況。是以在 AOF 重寫期間,用戶端接收到的指令除了寫入 AOF 緩沖區,還要寫入 AOF 重寫緩沖區
AOF 重寫完成後,子程序會向父程序發送一個完成信号。父程序收到後将 AOF 重寫區的内容追加到新 AOF 檔案中,然後将 AOF 改名,覆寫原來的 AOF 檔案
RDB
手動執行持久化
Redis 的 RDB 持久化功能通過
SAVE
和
BGSAVE
兩個指令可以生成壓縮的二進制 RDB 檔案,通過這個檔案可以還原生成檔案時資料庫的狀态。
其中
SAVE
阻塞主線程,在 RDB 檔案生成完之前不能處理任何請求。而
BGSAVE
則會 fork 一個子程序,在子程序中建立 RDB 檔案,父程序仍然能夠處理用戶端的指令。但是
BGSAVE
執行過程中,新的
SAVE
和
BGSAVE
指令會被拒絕,因為會産生競争條件,
BGWRITEAOF
指令會被延遲到
BGSAVE
結束之後。作為對比,
BGWRITEAOF
執行過程中,
BGSAVE
指令會被拒絕,這裡拒絕
BGSAVE
是出于性能考慮,兩者實際上不存在競争沖突
在 Redis 6.0 以前,雖然 Redis 處理處理請求是單線程的,但 Redis Server 還有其他線程在背景工作,例如 AOF 每秒刷盤、異步關閉檔案描述符這些操作
SAVE
和
BGSAVE
都會調用
rdb.c/rdbSave
執行真正的持久化過程。
Redis 啟動時,會根據
/etc/redis/redis.conf
配置檔案中的
dir
和
dbfilename
加載 RDB 檔案。如果已經開啟了 AOF 持久化,Redis 會優先使用 AOF 來恢複資料庫,配置檔案例如:
# RDB 配置示例
# https://github.com/redis/redis/blob/48e24d54b736b162617112ce27ec724b9290592e/redis.conf#L125
# 重要參數:
dbfilename dump.rdb
dir /var/lib/redis
載入 RDB 檔案時實際工作由
rdb.c/rdbLoad
完成,載入期間主線程處于阻塞狀态。
自動執行持久化
Redis 啟動式根據使用者設定的儲存條件開啟自動儲存。在
/etc/redis/redis.conf
配置檔案中加上
save <seconds> <changes>
表示在 seconds 秒内對資料庫進行了 changes 次修改,
BGSAVE
指令就會執行。這個配置會被加載到
struct redisServer
的
struct saveparam
參數中。
saveparam
是一個連結清單,當配置多個
save
條件時,這個條件都會被加傳入連結表中。
如何判斷是否滿足自動儲存的條件?
struct redisServer
中
long long dirty
用來儲存從上一次 RDB 持久化之後資料庫修改的次數,
set <key> <value>
會對 dirty 加一,而
sadd <set-name> <value1> <value2> <value3>
會對 dirty 加 3。
time_t lastsave
記錄了上一次完成 RDB 持久化的時間
Redis 使用
int serverCron
函數執行定時任務,這些任務包括自動儲存條件檢查、更新時間戳、更新 LRU 時鐘等。
serverCron
每隔 100 ms 執行一次,其中檢查自動儲存條件的代碼如下:
// https://github.com/redis/redis/blob/48e24d54b736b162617112ce27ec724b9290592e/src/redis.c#L1199
// 開始檢查自動儲存條件前會先檢查是否有正在背景執行的 RDB 和 AOF 程序
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1) {
// 已有背景的 RDB 或 AOF 程序
} else {
// 周遊 saveparams 連結清單中所有的配置條件
for (j = 0; j < server.saveparamslen; j++) {
struct saveparam *sp = server.saveparams+j;
/* 滿足自動儲存的标準:
1. 從上次完成 RDB 到現在的資料庫修改次數(dirty)已經達到了 save 配置中 changes 的值
2. 距上一次完成 RDB 的時間(lastsave)已經達到了 save 配置中 seconds 的值
3. 上一次 RDB 已經成功,或者距上一次嘗試 RDB 的時間(lastbgsave_try)已經達到了配置的逾時時間(REDIS_BGSAVE_RETRY_DELAY)
*/
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))
{
redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...",
sp->changes, (int)sp->seconds);
rdbSaveBackground(server.rdb_filename);
break;
}
}
}
RDB 檔案格式(以版本“0006”為例)
RDB 檔案主要由五個部分構成:

中存儲了所有的資料。開頭的 SELECTDB 常量(值為 376)和緊接着的編号,訓示了讀取 RDB 檔案時,後續加載的資料将會被寫入哪個資料庫中。
key_values 中儲存了所有的鍵值對,主要包括 key,value 和 value 的類型,對于設定了過期時間的 key,還有 EXPIRETIME_MS 常量(值為 374)和用 unix 時間戳表示的過期時間。其中
類型可以是下表中的值,分别對應了 Redis 資料結構的類型:
資料結構類型 | 編碼常量 ---|---|--- 字元串 | REDIS_RDB_TYPE_STRING,值為 0 清單 | REDIS_RDB_TYPE_LIST,值為 1 集合 | REDIS_RDB_TYPE_SET,值為 2 有序集和 | REDIS_RDB_TYPE_ZSET,值為 3 哈希 | REDIS_RDB_TYPE_HASH,值為 4 使用壓縮清單實作的清單 | REDIS_RDB_TYPE_LIST_ZIPLIST 使用整數集合實作的集合 | REDIS_RDB_TYPE_SET_INTSET 使用壓縮清單實作的有序集合 | REDIS_RDB_TYPE_ZSET_ZIPLIST 使用壓縮清單實作的哈希 | REDIS_RDB_TYPE_HASH_ZIPLIST
這些編碼常量所對應的值都可以在 rdb.h 中檢視
這個類型會影響讀取資料時如何解釋後面 value 代表的值,而 key 則總是被當作 REDIS_RDB_TYPE_STRING 類型
各類型對應的 value 結構如下:
value 結構 | 備注 | 示例 | 類型 ---|---|--- 編碼,值 | 表示可以用 8 位整數表示的字元串 | REDIS_RDB_ENC_INT8,123 | REDIS_RDB_TYPE_STRING | 表示字元串 | REDIS_ENCODING_RAW, 5, hello | 元素個數,清單元素 | 其中會記錄每個元素的長度 | 3, 5, "hello", 5, "world" | REDIS_RDB_TYPE_LIST 元素個數,集合元素 | 其中會記錄每個元素的長度 | 3, 5, "hello", 5, "world" | REDIS_RDB_TYPE_SET 鍵值對個數,鍵值對 | 其中會記錄每個鍵值對 key, value 的長度 | 2, 1, "a", 5, "apple", 1, "b", 6, "banana" | REDIS_RDB_TYPE_HASH 元素個數,member 和 score 對 | 其中會記錄 member 的長度,member 在 score 前面 | 2, 2, "pi", 4, "3.14", 1, "e", 3, "2.7" | REDIS_RDB_TYPE_ZSET 轉化成字元串對象的整數集合 | 讀取 RDB 時需要将字元串對象轉化回整數集合 | | REDIS_RDB_TYPE_SET_INTSET 轉化成字元串對象的壓縮清單 | 讀取時需要轉化成清單 | | REDIS_RDB_TYPE_LIST_ZIPLIST 轉化成字元串對象的壓縮清單 | 讀取時需要轉化成哈希 | | REDIS_RDB_TYPE_HASH_ZIPLIST 轉化成字元串對象的壓縮清單 | 讀取時需要轉化成有序集合 | | REDIS_RDB_TYPE_ZSET_ZIPLIST
如何保證寫操作正常執行
利用 COW 機制,fork 出子程序共享主線程的記憶體資料。在主線程修改資料時把這塊資料複制一份,此時子程序将副本寫入 rdb,主線程仍然修改原來的資料
頻繁執行全量快照的問題
- 全量資料寫入磁盤,磁盤壓力大。快照太頻繁,前一個任務還未執行完,快照任務之間競争磁盤帶寬,惡性循環
- fork 操作本身阻塞主線程,主線程記憶體越大,阻塞時間越長,因為要拷貝記憶體頁表
全量快照後隻做增量快照,但是需要記住修改的資料,下次全量快照時再寫入,但這需要在記憶體中記錄修改的資料。是以 Redis 4.0 提出了混合使用 AOF 和全量快照,用
aof-use-rdb-preamble yes
設定。這樣,兩次全量快照間的修改會記錄到 AOF 檔案
寫多讀少的場景下,使用 RDB 備份的風險
- 記憶體資源風險:Redis fork子程序做RDB持久化,如果修改指令很多,COW 機制需要重新配置設定大量記憶體副本,如果此時父程序又有大量新 key 寫入,很快機器記憶體就會被吃光,如果機器開啟了 Swap 機制 ,那麼 Redis 會有一部分資料被換到磁盤上,當Redis通路這部分在磁盤上的資料時性能很差。如果機器沒有開啟Swap,會直接觸發OOM,父子程序可能會被系統 kill。
- CPU資源風險:雖然子程序在做RDB持久化,但 生成RDB快照過程會消耗大量的CPU資源 。可能會與背景程序産生 CPU 競争,導緻父程序處理請求延遲增大,子程序生成RDB快照的時間也會變長,Redis Server 性能下降。
- 如果 Redis 程序綁定了CPU ,那麼子程序會繼承父程序的 CPU親和性 屬性,子程序必然會與父程序争奪同一個CPU資源,整個Redis Server 的性能愛将,是以如果 Redis 需要開啟定時 RDB 和 AOF 重寫,程序一定不要綁定CPU。
Ref
- Redis-RDB-Dump-File-Format
- Redis 設計與實作