1. 程式人生 > 資料庫 >快取與資料庫的奔跑原理【轉】

快取與資料庫的奔跑原理【轉】

Redis 的快照為什麼不會阻塞其他請求?

雖然我們經常將 Redis 看做一個純記憶體的鍵值儲存系統,但是我們也會用到它的持久化功能,RDB 和 AOF 就是 Redis 為我們提供的兩種持久化工具,其中 RDB 就是 Redis 的資料快照,我們在這篇文章想要分析 Redis 為什麼在對資料進行快照持久化時會需要使用子程序,而不是將記憶體中的資料結構直接匯出到磁碟上進行儲存。

概述

在具體分析今天的問題之前,我們首先需要了解 Redis 的持久化儲存機制 RDB 究竟是什麼,RDB 會每隔一段時間中對 Redis 服務中當下的資料集進行快照,除了 Redis 的配置檔案可以對快照的間隔進行設定之外,Redis 客戶端還同時提供兩個命令來生成 RDB 儲存檔案,也就是 SAVE

 和 BGSAVE,通過命令的名字我們就能猜出這兩個命令的區別。

其中 SAVE 命令在執行時會直接阻塞當前的執行緒,由於 Redis 是 單執行緒 的,所以 SAVE 命令會直接阻塞來自客戶端的所有其他請求,這在很多時候對於需要提供較強可用性保證的 Redis 服務都是無法接受的。

我們往往需要 BGSAVE 命令在後臺生成 Redis 全部資料對應的 RDB 檔案,當我們使用 BGSAVE 命令時,Redis 會立刻 fork 出一個子程序,子程序會執行『將記憶體中的資料以 RDB 格式儲存到磁碟中』這一過程,而 Redis 服務在 BGSAVE 工作期間仍然可以處理來自客戶端的請求。

rdbSaveBackground

 就是用來處理在後臺將資料儲存到磁碟上的函式:

int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
pid_t childpid;

if (hasActiveChildProcess()) return C_ERR;
...

if ((childpid = redisFork()) == 0) {
int retval;

/* Child */
redisSetProcTitle("redis-rdb-bgsave");
retval = rdbSave(filename,rsi);
if (retval == C_OK) {
sendChildCOWInfo(CHILD_INFO_TYPE_RDB, "RDB");
}
exitFromChild((retval == C_OK) ? 0 : 1);
} else {
/* Parent */
...
}
...
}

Redis 伺服器會在觸發 BGSAVE 時呼叫 redisFork 函式來建立子程序並呼叫 rdbSave 在子程序中對資料進行持久化,我們在這裡雖然省略了函式中的一些內容,但是整體的結構還是非常清晰的,感興趣的讀者可以在點選上面的連結瞭解整個函式的實現。

使用 fork 的目的最終一定是為了不阻塞主程序來提升 Redis 服務的可用性,但是到了這裡我們其實能夠發現兩個問題:

  1. 為什麼 fork 之後的子程序能夠獲取父程序記憶體中的資料?

  2. fork 函式是否會帶來額外的效能開銷,這些開銷我們怎麼樣才可以避免?

既然 Redis 選擇使用了 fork 的方式來解決快照持久化的問題,那就說明這兩個問題已經有了答案,首先 fork 之後的子程序是可以獲取父程序記憶體中的資料的,而 fork 帶來的額外效能開銷相比阻塞主執行緒也一定是可以接受的,只有同時具備這兩點,Redis 最終才會選擇這樣的方案。

設計

為了分析上一節提出的兩個問題,我們在這裡需要了解以下的這些內容,這些內容是 Redis 伺服器使用 fork 函式的前提條件,也是最終促使它選擇這種實現方式的關鍵:

  1. 通過 fork 生成的父子程序會共享包括記憶體空間在內的資源;

  2. fork 函式並不會帶來明顯的效能開銷,尤其是對記憶體進行大量的拷貝,它能通過寫時拷貝將拷貝記憶體這一工作推遲到真正需要的時候;

子程序

在計算機程式設計領域,尤其是 Unix 和類 Unix 系統中,fork 都是一個程序用於建立自己拷貝的操作,它往往都是被作業系統核心實現的系統呼叫,也是作業系統在 *nix 系統中建立新程序的主要方法。

當程式呼叫了 fork 方法之後,我們就可以通過 fork 的返回值確定父子程序,以此來執行不同的操作:

  • fork 函式返回 0 時,意味著當前程序是子程序;

  • fork 函式返回非 0 時,意味著當前程序是父程序,返回值是子程序的 pid

int main() {
if (fork() == 0) {
// child process
} else {
// parent process
}
}

在 fork 的 手冊 中,我們會發現呼叫 fork 後的父子程序會執行在不同的記憶體空間中,當 fork 發生時兩者的記憶體空間有著完全相同的內容,對記憶體的寫入和修改、檔案的對映都是獨立的,兩個程序不會相互影響。

The child process and the parent process run in separate memory spaces.  At the time of fork() both memory spaces have the same content.  Memory writes, file mappings (mmap(2)), and unmappings (munmap(2)) performed by one of the processes do not affect other.

除此之外,子程序幾乎是父程序的完整副本(Exact duplicate),然而這兩個程序在以下的一些方面會有較小的區別:

  • 子程序用於獨立且唯一的程序 ID;

  • 子程序的父程序 ID 與父程序 ID 完全相同;

  • 子程序不會繼承父程序的記憶體鎖;

  • 子程序會重新設定程序資源利用率和 CPU 計時器;

  • ...

最關鍵的點在於父子程序的記憶體在 fork 時是完全相同的,在 fork 之後進行寫入和修改也不會相互影響,這其實就完美的解決了快照這個場景的問題 —— 只需要某個時間點下記憶體中的資料,而父程序可以繼續對自己的記憶體進行修改,這既不會被阻塞,也不會影響生成的快照。

寫時拷貝

既然父程序和子程序擁有完全相同的記憶體空間並且兩者對記憶體的寫入都不會相互影響,那麼是否意味著子程序在 fork 時需要對父程序的記憶體進行全量的拷貝呢?假設子程序需要對父程序的記憶體進行拷貝,這對於 Redis 服務來說基本都是災難性的,尤其是在以下的兩個場景中:

  1. 記憶體中儲存大量的資料,fork 時拷貝記憶體空間會消耗大量的時間和資源,會導致程式一段時間的不可用;

  2. Redis 佔用了 10G 的記憶體,而物理機或者虛擬機器的資源上限只有 16G,在這時我們就無法對 Redis 中的資料進行持久化,也就是說 Redis 對機器上記憶體資源的最大利用率不能超過 50%;

如果無法解決上面的兩個問題,使用 fork 來生成記憶體映象的方式也無法真正落地,不是一個工程中真正可以使用的方法。

就算脫離了 Redis 的場景,fork 時全量拷貝記憶體也是難以接受的,假設我們需要在命令列中執行一個命令,我們需要先通過 fork 建立一個新的程序再通過 exec 來執行程式,fork 拷貝的大量記憶體空間對於子程序來說可能完全沒有任何作用的,但是卻引入了巨大的額外開銷。

寫時拷貝(Copy-on-Write)的出現就是為了解決這一問題,就像我們在這一節開頭介紹的,寫時拷貝的主要作用就是將拷貝推遲到寫操作真正發生時,這也就避免了大量無意義的拷貝操作。在一些早期的 *nix 系統上,系統呼叫 fork 確實會立刻對父程序的記憶體空間進行復制,但是在今天的多數系統中,fork 並不會立刻觸發這一過程:

在 fork 函式呼叫時,父程序和子程序會被 Kernel 分配到不同的虛擬記憶體空間中,所以在兩個程序看來它們訪問的是不同的記憶體:

  • 在真正訪問虛擬記憶體空間時,Kernel 會將虛擬記憶體對映到實體記憶體上,所以父子程序共享了物理上的記憶體空間;

  • 當父程序或者子程序對共享的記憶體進行修改時,共享的記憶體才會以頁為單位進行拷貝,父程序會保留原有的物理空間,而子程序會使用拷貝後的新物理空間;

在 Redis 服務中,子程序只會讀取共享記憶體中的資料,它並不會執行任何寫操作,只有父程序會在寫入時才會觸發這一機制,而對於大多數的 Redis 服務或者資料庫,寫請求往往都是遠小於讀請求的,所以使用 fork 加上寫時拷貝這一機制能夠帶來非常好的效能,也讓 BGSAVE 這一操作的實現變得非常簡單。

總結

Redis 實現後臺快照的方式非常巧妙,通過作業系統提供的 fork 和寫時拷貝的特性輕而易舉的就實現了這個功能,從這裡我們就能看出作者對於作業系統知識的掌握還是非常紮實的,大多人在面對類似的場景時,想到的方法可能就是手動實現類似『寫時拷貝』的特性,然而這不僅增加了工作量,還增加了程式出現問題的可能性。

到這裡,我們簡單總結一下 Redis 為什麼在使用 RDB 進行快照時會通過子程序的方式進行實現:

  1. 通過 fork 建立的子程序能夠獲得和父程序完全相同的記憶體空間,父程序對記憶體的修改對於子程序是不可見的,兩者不會相互影響;

  2. 通過 fork 建立子程序時不會立刻觸發大量記憶體的拷貝,記憶體在被修改時會以頁為單位進行拷貝,這也就避免了大量拷貝記憶體而帶來的效能問題;

上述兩個原因中,一個為子程序訪問父程序提供了支撐,另一個為減少額外開銷做了支援,這兩者缺一不可,共同成為了 Redis 使用子程序實現快照持久化的原因。