無碟複製:幾點設計筆記
原文由 Salvatore Sanfilippo 于 發布,訂閱此部落格
大約一個月前,一群關心 Redis 開發的人在倫敦齊聚,參加了第一屆 Redis 開發者會議。我們一起盤點出一些迫切需要的功能(現在已經整理在 GitHub 上的議題中:https://github.com/antirez/redis/issues/2045),而在當天被多次提及的議題之一,就是無碟複製。 這個功能其實不是什麼全新點子,已經被提出過好幾次,尤其是 EC2 的使用者感受特別深,他們知道有時要讓 master 在同步 slave 的同時還維持良好效能,並不是件容易的事。不過,有不少使用情境是根本不想碰磁碟的,就算在實體伺服器上執行也一樣,特別是當 Redis 只是被當作快取來用時。簡而言之,過去 Redis 的複製機制強迫使用者一定要使用磁碟,即使他們根本不需要、也不想要磁碟持久化。 回家之後,我想盡快給與會的開發者一些回饋,所以第一件事就是著手實作那份清單中看起來最重要、也最不單純的功能。接下來幾週,焦點也會轉向 Redis 的開發流程:像是議題如何處理、新想法如何提案給 Redis 專案等等。關於這些其他重要事項的延遲先說聲抱歉,至少現在,你們可以先看到一些程式碼了 ;-) 無碟複製帶來了幾個設計上的挑戰。看起來很單純,其實不然,所以既然我想多寫點部落格文章,就來記錄一下這個功能的內部運作方式。我相信,一篇文章應該能讓大家更容易理解與採用這個新功能。 過去的複製機制如何運作 === 較新版本的 Redis 已經能在與 master 的連線中斷後,重新連上 master,並以增量方式繼續複製,只補上這段期間累積的差異。不過,當 slave 斷線太久、重啟,或是全新加入的 slave,Redis 就會要求它執行所謂的「完整重新同步」。 這是個很直觀的概念,意思是:為了建立這個 slave,就把 master 上的整份資料集傳給 slave。它會清掉舊資料,從頭重新載入新資料,確保自己是 master 資料的精確副本。一旦 slave 成為 master 的精確副本,後續的變更就會像一般的 Redis 指令一樣,以增量方式串流傳送,隨著客戶端送來的寫入指令持續修改 master 的資料集。 問題在於,這個為了完整重新同步所需的最初「大量傳輸」是如何執行的。基本上,master 會建立一個子行程來產生 RDB 檔案。當子行程完成 RDB 檔案的產生後,再由父行程以非阻塞 I/O 把檔案傳給 slave。最後當傳輸完成,slave 就能重新載入 RDB 檔案並上線,開始接收後續寫入的增量串流。 然而這代表,從 master 的角度來看,要完成一次完整同步,我們需要: 1) 把 RDB 寫到磁碟上。 2) 再把 RDB 從磁碟讀回來,才能傳給 slave。 「2」不太理想,但「1」更糟。舉例來說,如果同時啟用了 AOF,子行程以最快速度寫入磁碟時,可能會讓 AOF 的 fsync() 被大幅延遲。在設定不當的情況下,特別是使用非本機磁碟時,但有時甚至只是核心參數調校得不夠完美,磁碟壓力就會造成難以處理的延遲突波。Redis 2.8 引進的部分重新同步稍微緩解了這個問題,但你不時還是得重啟 slave,或是它們離線太久,所以完整重新同步是無法完全避免的。 同時,這個流程也有幾個好處。RDB 儲存的程式碼可以同時套用在複製上,讓複製的程式碼更簡單。而且在子行程產生 RDB 檔案的同時,新的 slave 還可以連進來並排入佇列:等 RDB 準備好,就可以同時餵給多個 slave。 整體而言,在許多環境下它運作得很好,也能同時同步多個 slave。而且很多使用者在 master 端本來就開啟了 RDB 持久化,只是沒開 AOF,所以本來就會不時寫入磁碟。再者,很多裸機使用者在 Redis 做持久化時根本感覺不到延遲,而且磁碟、尤其是本機磁碟的效能很好預測:一旦子行程開始存檔,你不太需要去檢查逾時或擔心它花太久,最終一定會完成,而且通常在合理的時間內就能結束。 基於這些原因,以磁碟為基礎的複製至今仍是預設的複製策略,目前也沒有計畫要移除它,但現在我們多了一個替代方案,來服務那些原本表現不佳的使用情境。 那麼,什麼是無碟複製呢?概念就是讓子行程直接透過 socket 把資料寫給 slave,中間不需要任何暫存步驟。 Socket 不是磁碟 === 無碟複製最顯而易見的問題在於,寫入磁碟和寫入 socket 是不同的。首先是 API 就不一樣,因為 RDB 的程式碼原本是寫入 C 的 FILE 指標,而寫入 socket 則是要寫入 file descriptor。再者,寫入磁碟除非遇到嚴重的 I/O 錯誤(例如磁碟滿了),否則不會失敗,所以一旦寫入失敗,就可以視為整個流程中止。但 socket 不同,因為接收端速度慢、本地核心緩衝區滿了,寫入可能會被延遲。另一個有趣的問題是逾時的處理:如果接收端故障而不再讀取我們的資料怎麼辦?或是 TCP 連線其實已經斷了卻沒收到 reset 呢?我們不能讓負責傳送 RDB 給 slave 的子行程永遠卡在那裡,必須要有偵測逾時的方法。 幸好,要把 RDB 程式碼改成寫入 file descriptor 並不難,因為為了解決另一個完全不同的問題(給 Redis Cluster 用的 MIGRATE/RESTORE),程式碼早已使用了一個叫做「rio」(Redis I/O)的抽象層,它把 Redis 值的 RDB 格式序列化與反序列化抽象化,讓你可以把值寫到磁碟,或寫到記憶體緩衝區。我做的就是為「rio」支援一個新的目標,叫做 fdset:一組 file descriptor。這是因為如後面會提到的,我們需要同時寫入多個 file descriptor。 不過光這樣還不夠。主要的設計取捨之一,在於要釐清在記憶體中傳輸 RDB 會採用以下哪一種方式: 1) 方式一:先在記憶體緩衝區內產生完整的 RDB 檔案,再傳送出去。 2) 方式二:在產生 RDB 的同時,直接增量地寫入 slave 的 socket。 方式一簡單得多,基本上就像寫到磁碟一樣,只是把磁碟換成某種 RAM 碟。然而顯而易見的風險是會用掉太多記憶體。方式二則有點冒險,因為你必須在產生 RDB 的子行程還在運作時就開始傳輸。但這個功能的核心,本來就是針對磁碟很慢、但*網路很快*的環境,又不希望額外耗費太多記憶體,否則這個功能就失去意義了。所以最後選擇了方式二。 不過如果你像這樣串流 RDB 檔案,就會遇到一個新問題要解決……slave 要怎麼知道已經到 EOF 了?我們在開始傳輸時,並不知道傳輸會有多大。相反地,使用磁碟的複製因為大小已知,所以傳輸時只要用 Redis 協定的「bulk」字串,前面加上長度即可。像是: $92384923423\r\n … data follows … 我懶得去實作什麼複雜的分塊協定來逐段宣告區塊大小,所以採取了更直接的做法。master 會產生一個無法猜測、幾乎不可能碰撞的 160 位元隨機字串,並像這樣傳給 slave: $EOF:796f255829a040e80168f94c9fe7eda16b35e5df\r\n … data follows … 796f255829a040e80168f94c9fe7eda16b35e5df 所以基本上,這個字串因為碰撞機率極低,保證不會跟檔案內的任何內容撞到,就被當作檔案結束的標記。做法很土,但非常有效,也很簡單。 至於逾時,因為這是在儲存子行程的情境下進行的阻塞式寫入,我就直接用了 SO_SNDTIMEO 這個 socket 選項。這樣就能確保我們必須持續有進度,否則複製流程就會中止。所以目前還沒有辦法對子行程的存活時間設定一個硬性的上限,理論上也存在一種極端情況:slave 每隔「逾時時間減一秒」才接受一個位元組,藉此製造出非常緩慢的傳輸。或許未來子行程會去監控傳輸速率,如果速率掉到某個合理值以下,就直接報錯退出。 同時服務多個 slave === 這個實作的另一個目標,是能夠同時服務多個 slave。乍看之下這似乎不可能,因為一旦 RDB 傳輸開始,新的 slave 就無法加入,只能等目前的子行程結束、再啟動一個新的子行程。 不過有一個非常簡單的技巧可以涵蓋很多使用情境,那就是:當第一個 slave 想要複製時,我們先等個幾秒,讓其他 slave 也有機會一起進來。這就能涵蓋多個 slave 同時大量重新同步的典型情況。 因為這樣,I/O 程式碼被設計成可以同時寫入多個 file descriptor。而且,為了即使使用阻塞式 I/O 也能平行傳輸,程式碼會在迴圈中輪流對每個 fd 嘗試寫入少量資料,這樣核心就能在背景同時把封包送給多個 slave。 或許這段程式碼本身就相當容易理解: 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 封包。 處理部分失敗 === 處理多個 slave 不只是寫入多個 FD,這部分其實相當簡單。更大的一部分在於,要能在幾個 slave 失敗時,不讓整個流程卡住其他 slave。 發生錯誤的 file descriptor 會被標記上對應的錯誤碼,之後就不會再嘗試寫入它們。 同時程式碼也會偵測是否所有 FD 都已出錯,若是,就直接中止整個流程。 不過當 RDB 寫入結束後,子行程需要回報哪些 slave 已成功收到 RDB、可以繼續複製流程。為了這個任務,兩個行程之間使用了一個 Unix pipe。子行程會回傳一個 slave ID 與對應錯誤狀態的陣列,讓父行程也能好好記錄錯誤。 這件事如何更深層地改變了我對 Redis 的想法 === 無碟複製終於讓 Redis 的 master-slave 架構可以完全不需要碰磁碟。 這代表我們需要更好地支援這種使用情境。目前在關閉持久化的情況下跑複製其實有點危險,因為我原本認為,既然複製無論如何都會觸發持久化,那就沒有關掉持久化的必要。但現在情況改變了……結果,已經有計畫要在無碟環境下提供更完善的複製支援。同樣的想法也會套用到 Redis Cluster 上……它同樣很適合無碟運作,尤其是在快取的使用情境中,備援節點可以很好地提供資料備援,但就算多個節點同時當機重啟、導致叢集中一部分 hash slot 的資料遺失,似乎也不是那麼嚴重。 預計時程 === 程式碼已經以 beta 形式放在這裡:https://github.com/antirez/redis/commits/memsync 接下來幾天會合併到 unstable 分支,但計畫是先等一下,收集回饋與錯誤回報,之後再合併到 3.0 與 2.8。這個功能非常有用,而且在關閉時幾乎不會跟 Redis 核心的其他部分產生交互。計畫就是把它 backport 到各個版本,並先以「實驗性」功能釋出一段時間。
隨機一篇部落格
留言
登入後參與討論