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 程序執行
操作建立子程序,RDB 持久化過程由子程序負責,完成後自動結束。Redis 隻會在fork
期間發生阻塞,但是一般時間都很短。但是如果 Redis 資料量特别大,fork
時間就會變長,而且占用記憶體會加倍,這一點需要特别注意。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 持久化的函數,它的大緻流程如下:
- 首先打開一個臨時檔案,
- 調用
函數,将目前 Redis 的記憶體資訊寫入到這個臨時檔案中,rdbSaveRio
- 接着調用
fflush
fsync
接口将檔案寫入磁盤中,fclose
- 使用
将臨時檔案改名為 正式的 RDB 檔案,rename
- 最後記錄
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 )等。
- 然後
會周遊目前 Redis 的所有資料庫,将資料庫的資訊依次寫入。先寫入rdbSaveRio
識别碼和資料庫編号,接着寫入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;
}