Diskless replication: a few design notes.

Salvatore Sanfilippo

無碟複製:幾個設計筆記

大約一個月前,一群關心 Redis 開發的人士在倫敦齊聚,參加了首屆 Redis 開發者會議。我們共同盤點出若干急迫的功能(目前已列在 GitHub 議題中,網址如下:https://github.com/antirez/redis/issues/2045),而在當天被多次提及的議題之一,就是 diskless replication(無碟複製)。

這個功能並非全新的點子,過去已被多次提出,尤其是 EC2 的使用者,他們深知主節點要在從節點同步期間維持良好效能並非易事。然而,有許多使用情境是你根本不想碰觸磁碟的,即使在實體伺服器上執行也是如此,尤其當 Redis 被當作快取使用時。簡言之,Redis 的複製機制迫使使用者非得使用磁碟不可,即使他們根本不需要或不想要磁碟的持久性。

回到家後,我想盡快向與會的開發者提供回饋,因此我做的第一件事,就是專注去實作那份清單中看起來最重要、也最不簡單的功能。接下來幾週,焦點也會轉向 Redis 的開發流程:議題如何處理、新點子如何向 Redis 專案提案等等。關於這些其他重要事項的延遲,先向大家致歉,至少目前你們能看到的,是一些程式碼 ;-) 

diskless replication 帶來了幾個設計上的挑戰。它看起來很簡單,實際上卻不然,因此既然我想多寫一些部落格文章,就想把這個功能的內部運作方式記錄下來。我相信一篇部落格文章能讓大家更容易理解並採用這個新功能。

複製過去是如何運作的
===

較新版本的 Redis 在與主節點的連線中斷時,能夠重新連上主節點,並以增量方式繼續複製流程,只抓取這段期間累積的差異。然而,當從節點斷線過久、或重新啟動、或是全新的從節點時,Redis 就會要求它執行所謂的「完整重新同步(full resynchronization)」。

這是個很直觀的概念,意思是:為了設定這個從節點,我們把主節點的*所有*資料集傳送給從節點。它會清空舊資料,並從頭重新載入新資料,確保它執行的是主節點資料的完整副本。一旦從節點成為主節點的完整副本,後續的變更就會像一般的 Redis 指令一樣,以增量方式串流傳送,隨著主節點資料集因客戶端送來的寫入指令而變動。

問題在於,這種完整重新同步所需的初始「大量傳輸(bulk transfer)」是以什麼方式執行的。基本上,主節點會建立一個子行程來產生 RDB 檔。當子行程完成 RDB 檔的產生後,該檔案會由父行程透過非阻塞 I/O 傳送給從節點。最後當傳輸完成,從節點就能重新載入 RDB 檔並上線,接收新寫入的增量串流。

然而,這意味著從主節點的角度來看,為了執行一次完整同步,我們需要:

1) 把 RDB 寫到磁碟上。
2) 再把 RDB 從磁碟讀回來,以便傳送給從節點。

「2」不算理想,但「1」更糟。例如,若同時啟用了 AOF,子行程以最快速度寫入磁碟時,可能會大幅延遲 AOF 的 fsync()。在錯誤的設定下,尤其使用非本機磁碟時,但有時即使只是核心參數調校不夠完善,磁碟壓力也會造成難以處理的延遲突波。Redis 2.8 引進的部分重新同步(partial resynchronizations)稍微緩解了這個問題,但你終究還是得不時重啟從節點,或是它們會離線太久,因此不可能完全避免完整重新同步。

同時,這個過程也有幾個優點。RDB 儲存的程式碼在複製時也能重複使用,讓複製程式碼更簡潔。此外,當子行程正在產生 RDB 檔時,新的從節點可以連上並進入佇列:當 RDB 準備好時,我們可以同時餵給多個從節點。

整體而言,在許多架構下它運作得很好,並能同時同步多個從節點。而且許多使用者在主節點端是啟用了 RDB 持久化、但未啟用 AOF,因此無論如何本來就會不時寫入磁碟。多數使用實體主機的使用者在 Redis 進行持久化時完全不會感受到延遲,此外磁碟、尤其是本機磁碟的效能相當容易預測:一旦子行程開始儲存,你其實不需要檢查逾時或擔心它花太久,它終究會完成,而且通常在合理的時間內就能結束。

基於這些原因,基於磁碟的複製(disk-backed replication)*至今仍是*預設的複製策略,目前也沒有計畫要將其移除,但現在我們有了另一種替代方案,以因應那些原本表現不佳的使用情境。

那麼,什麼是 diskless replication?其概念就是讓子行程直接透過 socket 把資料寫給從節點,中間不經過任何額外的步驟。

Socket 並非磁碟
===

關於 diskless replication 最顯而易見的問題在於,寫入磁碟與寫入 socket 是不同的。首先,API 就不同,因為 RDB 程式碼原本是寫入 C 的 FILE 指標,而寫入 socket 則是寫入檔案描述符(file descriptor)。此外,磁碟寫入除非遇到嚴重的 I/O 錯誤(例如磁碟已滿),否則不會失敗,所以當寫入失敗時,你可以視為整個流程已中止。但對於 socket 則不同,因為接收端速度較慢、且本機核心緩衝區已滿時,寫入可能會被延遲。另一個有趣的議題是必須處理逾時:如果接收端發生故障而停止讀取我們的資料怎麼辦?或者只是 TCP 連線已經斷掉、卻沒有收到重置封包等等。我們不能讓傳送 RDB 檔給從節點的子行程永遠處於活躍狀態,必須有辦法偵測逾時。

幸好,要將 RDB 程式碼修改為寫入檔案描述符並不困難,因為為了解決另一個完全不同的問題(Redis Cluster 的 MIGRATE/RESTORE),程式碼原本就使用了一個稱為「rio」(redis I/O)的抽象層,它把 Redis 值以 RDB 格式的序列化與反序列化抽象化,讓你可以把值寫到磁碟,或寫到記憶體緩衝區。我所做的,就是支援一種新的「rio」目標,稱為 fdset:一組檔案描述符。這是因為如後面會寫到的,我們需要同時寫入多個檔案描述符。

然而,這樣還不夠。其中一個主要的設計取捨,是要弄清楚記憶體內的 RDB 傳輸應該以下列兩種方式中的哪一種進行:

1) 方式一:先在緩衝區內於記憶體中產生完整的 RDB 檔,再進行傳輸。
2) 方式二:在產生 RDB 的同時,直接以增量方式寫入從節點的 socket。

方式一簡單許多,因為它基本上就像寫入磁碟的做法,只是換成某種 RAM disk。然而,顯而易見的風險是會使用過多記憶體。方式二則有點冒險,因為你必須在產生 RDB 檔的子行程仍在執行時就進行傳輸。然而,這個功能的核心初衷,是針對磁碟可能很慢、但*網路很快*的環境,且不希望需要太多額外記憶體,否則這個功能恐怕就失去意義了。因此選擇了方式二。

然而,若像這樣串流傳送 RDB 檔,就會產生一個新的問題要解決……從節點要如何知道已經到達 EOF?我們在開始傳輸時,並不知道傳輸會有多大。相較之下,使用磁碟的複製則是已知大小的,因此傳輸時只需使用 Redis 協定中的「bulk」字串,並加上前綴長度。像是這樣:

$92384923423\r\n
… data follows …

我懶得去實作某種複雜的分塊協定來逐段宣告區塊大小,所以採取了更為直接暴力的做法。主節點會產生一個無法猜測、且極不可能碰撞的 160 位元隨機字串,並像這樣傳送給從節點:

$EOF:796f255829a040e80168f94c9fe7eda16b35e5df\r\n
… data follows …
796f255829a040e80168f94c9fe7eda16b35e5df

所以基本上,這個字串因為機率上極微小而保證不會與檔案內的任何內容碰撞,被用作檔案結束的標記。做法很簡單,卻非常有效,而且單純。

至於逾時,由於這是一個阻塞式寫入流程(因為我們是在儲存用子行程的上下文中),我只使用了 SO_SNDTIMEO 這個 socket 選項。這樣我們就能確保必須持續取得進展,否則就會中止複製流程。因此目前還沒有辦法對子行程的生命週期設定硬性的時間上限,理論上也存在病態情況:從節點每隔「逾時時間減一秒」只接收一個位元組,藉此製造非常緩慢的傳輸情境。或許未來子行程會監控傳輸速率,若速率降到合理水準以下,就會以錯誤結束。

同時服務多個從節點
===

這個實作的另一個目標,是能夠同時服務多個從節點。乍看之下這似乎不可能,因為一旦 RDB 傳輸開始,新的從節點就無法加入,而必須等待目前的子行程結束、再啟動一個新的子行程。

然而,有一個非常簡單的技巧可以涵蓋許多使用情境,那就是當第一個從節點想要進行複製時,我們會等待幾秒鐘,讓其他從節點也有機會加入。這就涵蓋了例如多個從節點同時大量重新同步的明顯情境。

基於這個原因,I/O 程式碼被設計為能同時寫入多個檔案描述符。此外,為了即使使用阻塞式 I/O 也能平行化傳輸,程式碼會在迴圈中嘗試每次對每個 fd 寫入少量資料,讓核心能在背景同時將封包傳送給多個從節點。

或許程式碼本身就相當容易理解:

    while(len) {
        size_t count = len < 1024 ? len : 1024;
        int broken = 0;
        for (j = 0; j < r->io.fdset.numfds; j++) {
            … error checking removed …

            /* Make sure to write 'count' bytes to the socket regardless
             * of short writes. */
            size_t nwritten = 0;
            while(nwritten != count) {
                retval = write(r->io.fdset.fds[j],p+nwritten,count-nwritten);
                if (retval <= 0) {
                     … error checkign removed …
                }
                nwritten += retval;
            }
        }
        p += count;
        len -= count;
        r->io.fdset.pos += count;
        … more error checking removed …
    }

請注意,寫入是由 rio.c 的寫入目標進行緩衝的,因為我們希望只有在有一定量資料可用時才寫入,否則可能會發出內含僅 5 位元組資料的 TCP 封包。

處理部分失敗
===

處理多個從節點不僅僅是寫入多個 FD,這部分其實相當簡單。故事中很大一部分,其實是如何在少數幾個從節點失敗時,不至於讓所有其他從節點的流程跟著卡住。發生錯誤的檔案描述符會被標記為對應的錯誤碼,且不會再嘗試向它們寫入。此外,程式碼也會偵測是否所有 FD 都已出錯,並在這種情況下完全中止流程。

然而,當 RDB 寫入結束時,子行程需要回報哪些從節點已成功接收 RDB、可以繼續複製流程。為此,行程之間使用了 unix pipe。子行程會回傳一個由從節點 ID 與相關錯誤狀態組成的陣列,讓父行程也能妥善記錄錯誤。

這件事如何以比我想像中更深入的方式改變 Redis
===

diskless replication 終於讓 Redis 主從架構能夠實現完全無需磁碟的體驗。這意味著我們需要更好地支援這種使用情境。目前在關閉持久化的情況下執行複製是危險的,因為我原本以為既然複製無論如何都會觸發持久化,就沒有關閉持久化的必要。但現在情況改變了……因此,已經有計畫要在無磁碟的環境中更好地支援複製。相同的做法也會套用到 Redis Cluster 上……它同樣非常適合無碟操作,尤其是快取使用情境,在這類情境中,複本可以很好地提供資料備援,但即使多個執行個體同時當機重啟、導致叢集中一部分雜湊槽的資料遺失,或許也不是那麼關鍵。

預計時程
===

程式碼的測試版已經可以在此取得:https://github.com/antirez/redis/commits/memsync
它將在未來幾天內合併到 unstable 分支,但計畫是先等待一下回饋與錯誤報告,之後再合併到 3.0 與 2.8。這個功能非常實用,而且在關閉時與 Redis 核心其餘部分的互動極少。計畫就是將它到處 back port,並以「實驗性」功能的名義發布一段時間。

原文由 Salvatore Sanfilippo 發布

本文章由 muse-spark-1.2-contributor 進行翻譯