關於 Redis Sentinel 特性與失效情境的幾點論述
昨天,分散式系統專家 Aphyr(艾弗)在推特上發文,提到一家不願具名的公司所遭遇的 Redis Sentinel 問題:
「噢,關於 Redis Sentinel——『他們對 master 執行了 kill -9,結果造成了 split brain(腦裂)……』」
「『接著舊的 master 在沒有任何資料的狀態下重新冒出來,並把這種空無一物的狀態複寫到所有其他節點。真的是只能從備份還原。』」
我想,天啊,我們有個嚴重的錯誤。然而我試著向 Kyle(凱爾)進一步了解詳情,他回覆說,使用者其實完全停用了 master 行程的磁碟持久化。沒錯:該 master 是被刻意設定成重新啟動時會以清空的資料集來啟動。
你猜怎麼著?推特上立刻掀起了一場論戰。大家都為 Redis 使用者深感憂心。可憐的 Redis 使用者!永遠處於險境。
然而,雖然深感憂慮確實是審慎智慧的表現,我想選擇另一條路:提供更多資訊。此外,撰寫這篇部落格文章也饒富趣味,因為其實 凱爾 雖然在通報問題時提供的背景脈絡不多,但在幾則後續推文中,卻能夠——依我之見——精準點出我認為 Redis Sentinel 真正可以改進之處,這與前述事件無關,且已經在我的待辦清單上很長一段時間了。
但在此之前,讓我們更仔細地檢視一下 Redis / Sentinel 在這起論戰事件中的行為。
歡迎來到當機復原系統模型
現實世界中大多數的分散式系統,都必須被設計成能夠承受行程隨機重新啟動的事實。請注意,這與被分割隔離的問題非常不同;後者指的是無法與其他行程交換訊息。而這裡談的,則是狀態遺失的問題。
更精確地說,如果一個分散式演算法的設計要求行程在重新啟動後必須保證保留狀態,卻未能做到,那麼從技術上來說,它正經歷 Byzantine failure(拜占庭失效):狀態已損毀,該行程不再可靠。
而在由 Redis 實例與 Redis Sentinel 實例所組成的分散式系統中,重新啟動的實例能夠帶著舊有的資料集恢復運作,是至關重要的。以被清空的資料集啟動,屬於 Byzantine failure,Redis Sentinel 無法從這類問題中復原。
但讓我們先退一步。實際上,Redis Sentinel 可能根本沒有直接牽涉到這類事件。典型的例子是,如果一個設定錯誤的 master 重啟得夠快,以至於 Sentinels 完全偵測不到任何失效,會發生什麼事。
- 節點 A 是 master。
- 節點 A 重新啟動,且已停用持久化。
- Sentinels(可能)會發現節點 A 無法連線……但時間未長到足以達到設定的逾時門檻。
- 節點 A 再次上線,但卻是以完全空白的資料集重新啟動。
- 所有的 slave 節點 B、C、D……都會欣然地從它同步一份空白的資料集。
畢竟,一切都已依設定從 master 上抹除。而所有從被視為資料集當前真相來源的節點進行複寫的 slave,其資料也隨之全部抹除。
讓我們把 Sentinel 從這個等式中移除,也就是上述時間軸中的第「3」點,因為在這個範例情境中 Sentinel 根本沒有採取任何行動。
結果就是這樣。你有一個 Redis master 正與 N 個 slave 進行複寫。master 重新啟動,並被設定為以全新的(空白的)資料集啟動。Slave 們再次從它進行複寫(一份空白的資料集)。
我想這對 Redis 使用者來說並不是什麼新聞,這就是 Redis 複寫的運作方式:slave 永遠會嘗試成為其 master 的完整複本。然而,讓我們來考慮其他替代模型。
舉例來說,Redis 實例可以擁有一個持久化保存在 RDB / AOF 檔案中的 Node ID。每次節點重新啟動時,都會載入其 Node ID。如果 Node ID 不正確,slave 就完全不會從 master 進行複寫。安全多了,對吧?其實只有些微改善。master 可能有不同的設定錯誤,因此在重新啟動後,可能會載入一份由於快照失敗而已經過時數週的資料集。
因此,在一次不良的重啟之後,我們雖然仍擁有正確的 Node ID,但資料集卻老舊到基本上等同於被抹除,只是更難以察覺而已。
然而,以僅僅讓安全性些微提升為代價,我們現在得到的是操作上可能更複雜的系統,以及由於 ID 不符而面臨無法從 master 複寫風險的 slave——其成因與停用持久化類似的操作錯誤有關,只是遠比那更不明顯。
那麼,讓我們換個主題,來看看一個 Sentinel 真正有牽涉其中、且可以改進的失效模式。
並非所有複本都相同
嚴格來說,Redis Sentinel 提供了一組非常有限、簡單易懂的保證。
- 所有的 Sentinel 只要能夠彼此通訊,就會對設定達成一致。實際上,每個子分割區內部永遠會達成一致。
- Sentinels 若未取得多數 Sentinel 行程的授權,就無法啟動 failover(容錯移轉)。
- Failover 具有嚴格的順序性:若某個 failover 在時間上較晚發生,它就會擁有更大的設定「編號」(在 Sentinel 的術語中稱為 config epoch(設定紀元)),該編號永遠會勝過較舊的設定。
- 最終,Redis 實例會被設定為對應到勝出的邏輯設定(即擁有較大 config epoch 的那一個)。
這意味著資料集的語意是「最後一次 failover 勝出」。然而,這裡所缺少的資訊是,在 failover 期間,會挑選哪一個 slave 來取代 master?總體而言,這是一個根本性的特質。舉例來說,如果 Redis Sentinel 因為挑選了一個被清空的 slave(剛以錯誤設定重新啟動)而失敗,那就是 Sentinel 本身的問題。Sentinel 應該確保,即使在 Redis 屬於非同步複寫系統的限制之下,也會盡力以使用者的最佳利益為依歸,挑選周圍最好的 slave,並在沒有任何可行的 slave 可連線時完全拒絕執行 failover。
這是一個可以改進之處,而目前在 master 失效時挑選 slave 的流程如下:
- 若某個 slave 曾重新啟動,且在重啟後從未與 master 連線並完成一次成功的同步(資料傳輸),則會被跳過。
- 若 slave 與其 master 斷線的時間超過設定逾時的 10 倍(即 master 必須無法連線、才會被 Sentinel 集合判定為失效的時間),則會被視為不具備候選資格。
- 在剩餘的 slave 中,Sentinel 會挑選具有最佳「replication offset(複寫偏移量)」的那一個。
replication offset 是一個由 Redis master-slave 複寫所使用的數字,用來計算經由複寫通道發送的位元組數量。它在許多方面都很有用,不僅限於 failover。例如,在網路分割後的部份重新同步中,slave 會向 master 請求:「請從偏移量 X 開始給我資料」,而 X 就是它最後收到的位元組位置,依此類推。
然而,這個複寫數字在用於挑選最佳待提升 slave 的情境中,存在兩個問題。
- 它會在重新啟動後被重置。這乍聽之下似乎無害,因為我們想挑選數字較高的 slave,而且無論如何,重新啟動後若 slave 無法連線,就會被跳過。然而,它根本並非無害,請繼續閱讀。
- 它僅僅是一個數字:它並不意味著某個 Redis slave 是從特定的 master 複寫而來。
另請注意,當某個 slave 被提升為 master 時,它會繼承該 master 的 replication offset。因此,除了重新啟動的情況外,這個數字會持續遞增。
為什麼「1」與/或「2」是不理想的選擇,且可以被改進?
試想以下架構。我們有節點 A、B、C、D、E。D 是當前的 master,並與 E 一同被隔離在少數分割區中。E 仍持續從 D 複寫,從它們的角度來看,一切正常。
然而,在多數分割區中,A、B、C 可以彼此交換訊息,且 A 被選為 master。
稍後,A 重新啟動,並重置其偏移量。B 與 C 從它進行複寫,並以較低的偏移量重新開始。
過了一段時間後,A 失效,同時,E 重新加入多數分割區。
E 所擁有的資料集相較於 B 與 C 的資料集較為過時,然而它的 replication offset 卻更高。不僅如此,E 實際上還能聲稱自己最近曾與其 master 保持連線。
要改進這點並不難。每個 Redis 實例都有一個「runid(執行識別碼)」,這是一個在每次新的 Redis 執行時都會改變的唯一識別碼。這在部份重新同步時很有用,可避免從錯誤的 master 取得增量資料流。Slave 應該公布它們最後成功複寫來源的 master runid,而 Sentinel 的 failover 應確保只挑選那些確實是從當前正要進行容錯移轉的 master 複寫而來的 slave。
一旦你將 replication offset 與特定的 runid 綁定,你所得到的便是一個衡量 slave 有多新的絕對指標。如果有兩個 slave 可用,且兩者都能聲稱與舊 master 具有連續性,那麼擁有較高 replication offset 的那一個,必定是最佳選擇。
然而,這在所有資料不是非常重要、但可用性至上的情境中,也會造成可用性方面的疑慮。舉例來說,若當 A 當機時,只有 E 變為可用,即使它過去是從 D 複寫而來,有總比沒有好。我會說,當你需要的是高可用性的快取,且一致性不是大問題時,使用類似 memcached 的 Redis 叢集(在 N 個 master 之間進行客戶端一致性雜湊)才是正確的做法。
請注意,即使不檢查 runid,僅僅是讓 replication offset 在重新啟動後仍保持持久,就已經能大幅改善行為。在上述範例中,E 只有在其與 slave 一同被隔離於少數分割區期間,所接收到的寫入多於多數方其他 slave 時,才會被選中。
TLDR:我們必須修正這個問題。這與在沒有資料集的情況下重新啟動 master 無關,但擁有更正確的實作仍是有益的。不過,這僅能限制一類非常難以觸發的問題。
這已經在我的待辦清單上有一段時間了,也恭喜 艾弗 僅透過幾則推文的交流就找出了一個真正的實作問題。關於 艾弗 所回報來自那家未知公司的失效事件,我認為目前嘗試去防範嚴重的設定錯誤並不可行,然而,這清楚地顯示我們需要更好的 Sentinel 文件——相較於現有試圖描述系統運作方式的文件,應該更具漸進性。更明智的做法或許是,從一個常見且健全的設定出發,並提供一份「不要做」清單,例如:除非你能接受實例被清空,否則不要關閉持久化。
隨機一篇部落格