Redis寫時拷貝(COW)總結


雖然我們經常將 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 時調用 redis Fork 函數來創建子進程並調用 rdbSave 在子進程中對數據進行持久化,我們在這里雖然省略了函數中的一些內容,但是整體的結構還是非常清晰的,感興趣的讀者可以在點擊上面的鏈接了解整個函數的實現。使用 fork 的目的最終一定是為了不阻塞主進程來提升 Redis 服務的可用性,但是到了這里我們其實能夠發現兩個問題:

  • 為什么 fork 之后的子進程能夠獲取父進程內存中的數據?
  • fork 函數是否會帶來額外的性能開銷,這些開銷我們怎么樣才可以避免?

既然 Redis 選擇使用了 fork 的方式來解決快照持久化的問題,那就說明這兩個問題已經有了答案,首先 fork 之后的子進程是可以獲取父進程內存中的數據的,而 fork 帶來的額外性能開銷相比阻塞主線程也一定是可以接受的,只有同時具備這兩點,Redis 最終才會選擇這樣的方案。
設計
為了分析上一節提出的兩個問題,我們在這里需要了解以下的這些內容,這些內容是 Redis 服務器使用 fork 函數的前提條件,也是最終促使它選擇這種實現方式的關鍵:

  • 通過 fork 生成的父子進程會共享包括內存空間在內的資源;
  • fork 函數並不會帶來明顯的性能開銷,尤其是對內存進行大量的拷貝,它能通過寫時拷貝將拷貝內存這一工作推遲到真正需要的時候;

子進程
在計算機編程領域,尤其是 Unix 和類 Unix 系統中,fork 都是一個進程用於創建自己拷貝的操作,它往往都是被操作系統內核實現的系統調用,也是操作系統在 Linux 系統中創建新進程的主要方法。

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

  • fork 函數返回 0 時,意味着當前進程是子進程;
  • fork 函數返回非 0 時,意味着當前進程是父進程,返回值是子進程的 pid;
int main() {
    if (fork() == 0) {
        // child process
    } else {
        // parent process
    }
}

在 fork 的 手冊 中,我們會發現調用 fork 后的父子進程會運行在不同的內存空間中,當 fork 發生時兩者的內存空間有着完全相同的內容,對內存的寫入和修改、文件的映射都是獨立的,兩個進程不會相互影響。

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

  • 子進程用於獨立且唯一的進程 ID;
  • 子進程的父進程 ID 與父進程 ID 完全相同;
  • 子進程不會繼承父進程的內存鎖;
  • 子進程會重新設置進程資源利用率和 CPU 計時器;

最關鍵的點在於父子進程的內存在 fork 時是完全相同的,在 fork 之后進行寫入和修改也不會相互影響,這其實就完美的解決了快照這個場景的問題 —— 只需要某個時間點下內存中的數據,而父進程可以繼續對自己的內存進行修改,這既不會被阻塞,也不會影響生成的快照。
寫時拷貝
既然父進程和子進程擁有完全相同的內存空間並且兩者對內存的寫入都不會相互影響,那么是否意味着子進程在 fork 時需要對父進程的內存進行全量的拷貝呢?假設子進程需要對父進程的內存進行拷貝,這對於 Redis 服務來說基本都是災難性的,尤其是在以下的兩個場景中:

  • 內存中存儲大量的數據,fork 時拷貝內存空間會消耗大量的時間和資源,會導致程序一段時間的不可用;
  • 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的RDB備份不用多線程實現CopyOnWrite?

快照持久化是個很耗時間的操作,而Redis采用fork一個子進程出來進行持久化。理論而言,fork出來的子進程會拷貝父進程所有的數據,這樣當Redis要持久化2G的內存數據的時候,子進程也會占據幾乎2G的內存。那么此時Redis相關的進程內存占用就會達到4G左右。這在數據體量比較小的時候還不嚴重,但是比如你的電腦內存是8G,目前備份快照數據本身體積是5G,那么按照上面的計算備份一定是無法進行的。所幸在Unix類操作系統上面做了如下的優化:在剛開始的時候父子進程共享相同的內存,直到父進程或者子進程進行內存的寫入后,對被寫入的內存共享才結束。這樣就會減少快照持久化時對內存的消耗。這就是COW技術,減少了快照生成時候的內存使用的同時節省了不少時間。而備份期間多用的內存正比於在此期間接收到的數據更改請求數目。
更具體地講,我們知道每個進程的虛擬空間是被划分成正文段,數據段,堆,棧這四個部分,同時對應於每一個部分,操作系統會為之分配真實物理塊。當我們從父進程P1中fork出一個子進程P2時:

  • 在沒有CopyOnWrite之前,我們要給子進程生成虛擬空間,並為虛擬空間地每一個部分分配對應地物理空間,接着要把父進程對應部分地物理空間地內容復制到子進程的空間中。這實際上是個既耗時又耗費空間地操作。
  • 有了COW之后, fork子進程時,我們只為其生成虛擬空間,但是並不先為每個部分分配真實的物理空間,而是讓每個虛擬空間部分仍然指向父進程的物理空間。只有當父進程或子進程修改相應的共享內存空間時,才會為子進程分配物理空間並把父進程的物理空間內容進行復制。這就是所謂的寫時復制,即把內存的復制延遲到了內存寫入的時刻。

同時需要注意地是,父子進程共享的空間粒度是頁(在Linux中,頁的大小為4KB),父/子進程修改某個頁時,該頁的共享才結束,同時子進程分配該頁大小的物理空間復制父進程對應頁的內容。這樣,如果當子進程運行期間,父子進程都沒有修改數據,那么操作系統就節省了大量的內存復制時間和占用空間。
上面講的CopyOnWrite是操作系統在fork子進程時實現的。而題主問的是,我們能不能用多線程來實現COW進而來實現RDB生成呢?在回答這個問題之前,為了讓大家更明白多線程實現COW的事情,我們先以Java中的CopyOnWriteArrayList為例進行來看多線程實現COW是個什么操作。
首先我們看這么一段代碼。這段代碼在多線程下肯定是不安全的,為了讓它變得更安全,一個簡單的方法就是讀取和寫入時都加鎖,即同時要有讀鎖和寫鎖。但是我們都知道鎖是非常影響性能的,為了減少鎖的消耗,Java便推出了CopyOnWriteArrayList。

public static Object getLast(Vector list){
    int lastIndex = list.size() - 1;
    return list.get(lastIndex);
}

public static void deleteLast(Vector list){
    int lastIndex = list.size() - 1;
    return list.remove(lastIndex);
}

CopyOnWriteArrayList 相對於 ArrayList 線程安全,底層通過復制數組的方式來實現,其核心概念就是: 數據讀取時直接讀取,不需要鎖,數據寫入時,需要鎖,且對副本進行操作。那么當數據的操作以讀取為主時,我們便可以省去大量的讀鎖帶來的消耗。同時為了能讓多線程操作List時,一個線程的修改能被另一個線程立馬發現,CopyOnWriteList采用了Volatile關鍵詞來進行修飾,即每次數據讀取不從緩存里面讀取,而是直接從數據的內存地址中讀取。
我們以CopyOnWriteArrayList 的add()操作為例來看。

 // 這個數組是核心的,因為用volatile修飾了
  // 只要把最新的數組對他賦值,其他線程立馬可以看到最新的數組
  private transient volatile Object[] array;

  public boolean add(E e) {

      final ReentrantLock lock = this.lock;
      lock.lock();

      try {
          Object[] elements = getArray();
          int len = elements.length;

          // 對數組拷貝一個副本出來
          Object[] newElements = Arrays.copyOf(elements, len + 1);

          // 對副本數組進行修改,比如在里面加入一個元素
          newElements[len] = e;

          // 然后把副本數組賦值給volatile修飾的變量
          setArray(newElements);
          return true;


      } finally {
          lock.unlock();
      }
  }
 

總結而言,多線程實現COW實際上就是以空間換取時間使得數據讀取時不需要鎖。只是減少了讀鎖的開銷,但與常規的多線程操作共享數據的本質沒有什么區別。
好,最后我們回到題主的問題,使用多線程實現COW來實現RDB生成這個問題可以規約成使用多線程實現RDB生成問題。所以我們的問題核心在於解決能不能使用多線程來實現RDB生成。如果要這么做我們需要做出哪些額外的操作?
大家肯定會想RDB的生成過程本質不就是把內存中的數據序列化到硬盤文件中么?RDB生成時,子線程只需要進行數據讀取,主線程修改時加鎖修改。並且為了避免常規操作時鎖的過多開銷,我們可以只需要在RDB生成期間再加鎖,常規期間寫操作不需要加鎖。這樣總體而言帶來的開銷不會多很多,因為畢竟RDB生成是個低頻的操作。
但這里面其實有個很重要的概念就是”SnapShot“, 即RDB是Redis內存的某一個時刻的快照。比如,我6:15分開始生成RDB, 那么這個RDB保存的數據就是當時那一刻整個Redis內存中的數據狀態。使用多進程我們是很容易保證這一點的,但是使用多線程,我們是很難保證這個性質的。因為你可能在DUMP的過程中,主線程又修改了你還沒讀取的數據,又或者主線程修改了你剛剛已經序列化到文件中的某個數據。也就是說使用多線程進行生成RDB的時候,你並不知道自己生成的數據是到底哪個時刻的數據。你也並不知道修改期間哪些主線程的命令已經體現在了RDB文件中。
這個會產生大的影響么?單機版的Redis也許不大會,但是Redis集群中涉及到主從復制的時候就會產生很大的影響。
單機版Redis生成RDB無非就是想留個檔,那么具體RDB是哪一個時刻的,可能沒那么重要。更重要的是要生成RDB。而且這個RDB顯然越新越好,因為越新,Redis重啟后丟失的數據就越少。那么從這個角度而言,甚至說用多線程反而可能更好,因為多線程時可以讓一些生成RDB期間被修改的數據也體現在RDB中。
但是涉及到主從復制時就不可以了。主從復制時,Redis主節點會生成當時時刻的內存快照RDB文件,同時把RDB期間的所有的命令寫到緩存repl_backlog中,等從節點從主節點的RDB文件恢復數據之后,便從主節點的命令緩存中讀取所有的命令再進行執行一遍,以達到和主節點相同的狀態。那么用多線程生成RDB時,如果當主線程執行某個寫入命令時,從線程還未DUMP該數據,那么從線程生成的RDB就包含了該命令的執行結果。而子節點又恢復了數據之后,相當於子節點已經執行過了這個命令。那么當子節點從主節點的命令緩存中拉取命令來再執行一遍后,有些命令就會被重復執行。

總結
Redis 實現后台快照的方式非常巧妙,通過操作系統提供的 fork 和寫時拷貝的特性輕而易舉的就實現了這個功能,從這里我們就能看出作者對於操作系統知識的掌握還是非常扎實的,大多人在面對類似的場景時,想到的方法可能就是手動實現類似『寫時拷貝』的特性,然而這不僅增加了工作量,還增加了程序出現問題的可能性。
到這里,我們簡單總結一下 Redis 為什么在使用 RDB 進行快照時會通過子進程的方式進行實現:

  • 通過 fork 創建的子進程能夠獲得和父進程完全相同的內存空間,父進程對內存的修改對於子進程是不可見的,兩者不會相互影響;
  • 通過 fork 創建子進程時不會立刻觸發大量內存的拷貝,內存在被修改時會以頁為單位進行拷貝,這也就避免了大量拷貝內存而帶來的性能問題;

上述兩個原因中,一個為子進程訪問父進程提供了支撐,另一個為減少額外開銷做了支持,這兩者缺一不可,共同成為了 Redis 使用子進程實現快照持久化的原因。到最后,我們還是來看一些比較開放的相關問題,有興趣的讀者可以仔細思考一下下面的問題:

  • Nginx 的主進程會在運行時 fork 一組子進程,這些子進程可以分別處理請求,還有哪些服務會使用這一特性?
  • 寫時拷貝其實是一個比較常見的機制,在 Redis 之外還有哪里會用到它?

cow分為進程級別的和線程級別的,兩者是有區別的,進程級別的主要是主進程和子進程共用內存數據,而主進程寫的時候資金子進程才進行復制數據,也就是寫時復制,子進程使用的是老的數據,而不是最新的數據,所以進程級別的cow用了最少的時間和最小的空間;線程級別的cow主要是使用了volatile這個關鍵字加鎖來進行實現的。寫時復制思想很重要,主要是在Linux系統級別來進行實現的;


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM