Redis 是一種內存數據庫,將數據保存在內存中,讀寫效率要比傳統的將數據保存在磁盤上的數據庫要快不少。可是一旦進程退出,Redis 的數據就會丟失。redis
爲了解決這個問題,Redis 提供了 RDB 和 AOF 兩種持久化方案,將內存中的數據保存到磁盤中,避免數據丟失。sql
antirez 在《Redis 持久化解密》一文中說,通常來講有三種常見的策略來進行持久化操做,防止數據損壞:數據庫
方法1 是數據庫不關心發生故障,在數據文件損壞後經過數據備份或者快照來進行恢復。Redis 的 RDB 持久化就是這種方式。數組
方法2 是數據庫使用操做日誌,每次操做時記錄操做行爲,以便在故障後經過日誌恢復到一致性的狀態。由於操做日誌是順序追加的方式寫的,因此不會出現操做日誌也沒法恢復的狀況。相似於 Mysql 的 redo 和 undo 日誌,具體能夠看這篇《InnoDB的磁盤文件及落盤機制》文章。緩存
方法3 是數據庫不進行老數據的修改,只是以追加方式去完成寫操做,這樣數據自己就是一份日誌,這樣就永遠不會出現數據沒法恢復的狀況了。CouchDB就是此作法的優秀範例。安全
RDB 就是第一種方法,它就是把當前 Redis 進程的數據生成時間點快照( point-in-time snapshot ) 保存到存儲設備的過程。bash
RDB 觸發機制分爲使用指令手動觸發和 redis.conf 配置自動觸發。服務器
手動觸發 Redis 進行 RDB 持久化的指令的爲:數據結構
fork
操做建立子進程,RDB 持久化過程由子進程負責,完成後自動結束。Redis 只會在 fork
期間發生阻塞,可是通常時間都很短。可是若是 Redis 數據量特別大,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持久化的學習。在此以前,咱們能夠先思考一下如何實現一個持久化機制,畢竟這是不少中間件所需的一個模塊。
首先,持久化保存的文件內容結構必須是緊湊的,特別對於數據庫來講,須要持久化的數據量十分大,須要保證持久化文件不至於佔用太多存儲。 其次,進行持久化時,中間件應該還能夠快速地響應用戶請求,持久化的操做應該儘可能少影響中間件的其餘功能。 最後,畢竟持久化會消耗性能,如何在性能和數據安全性之間作出平衡,如何靈活配置觸發持久化操做。
接下來咱們將帶着這些問題,到源碼中尋求答案。
本文中的源碼來自 Redis 4.0 ,RDB持久化過程的相關源碼都在 rdb.c 文件中。其中大概的流程以下圖所示。
上圖代表了三種觸發 RDB 持久化的手段之間的總體關係。經過 serverCron
自動觸發的 RDB 至關於直接調用了 bgsave 指令的流程進行處理。而 bgsave 的處理流程啓動子進程後,調用了 save 指令的處理流程。
下面咱們從 serverCron
自動觸發邏輯開始研究。
如上圖所示,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 指令會觸發的函數。
執行 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 佔據的內存空間會翻倍。
Redis 的 rdbSave
函數是真正進行 RDB 持久化的函數,它的大體流程以下:
rdbSaveRio
函數,將當前 Redis 的內存信息寫入到這個臨時文件中,fflush
、fsync
和 fclose
接口將文件寫入磁盤中,rename
將臨時文件更名爲 正式的 RDB 文件,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》
rdbSaveRio
會將 Redis 內存中的數據以相對緊湊的格式寫入到文件中,其文件格式的示意圖以下所示。
rdbSaveRio
函數的寫入大體流程以下:
先寫入 REDIS 魔法值,而後是 RDB 文件的版本( rdb_version ),額外輔助信息 ( aux )。輔助信息中包含了 Redis 的版本,內存佔用和複製庫( repl-id )和偏移量( repl-offset )等。
而後 rdbSaveRio
會遍歷當前 Redis 的全部數據庫,將數據庫的信息依次寫入。先寫入 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;
}
複製代碼