【小資說庫】第13期 應用程序開發人員、DBA和DBMS開發人員的分工是怎樣的?
997
2022-05-28
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 進程執行 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
1
2
3
如果不需要 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; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
如果符合觸發 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 */ }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
為什么 Redis 使用子進程而不是線程來進行后臺 RDB 持久化呢?主要是出于Redis性能的考慮,我們知道Redis對客戶端響應請求的工作模型是單進程和單線程的,如果在主進程內啟動一個線程,這樣會造成對數據的競爭條件。所以為了避免使用鎖降低性能,Redis選擇啟動新的子進程,獨立擁有一份父進程的內存拷貝,以此為基礎執行RDB持久化。
但是需要注意的是,fork 會消耗一定時間,并且父子進程所占據的內存是相同的,當 Redis 鍵值較大時,fork 的時間會很長,這段時間內 Redis 是無法響應其他命令的。除此之外,Redis 占據的內存空間會翻倍。
生成 RDB 文件,并且持久化到硬盤
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; .... }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
這里要簡單說一下 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; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
根據鍵的不同類型寫入不同格式,各種鍵值的類型和格式如下所示。
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; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
后記
歡迎大家留言或者。
Redis 數據庫
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。