Categories
程式開發

Nextdoor零停機Redis遷移實踐


背景介紹

在 Nextdoor,我們大量使用了 Redis 這個高性能的內存數據存儲,為我們的許多服務提供低延遲的數據訪問。現在,我們通過 ElastiCache 使用著十幾個不同的 Redis 實例;其中有許多對應於我們的微服務或我們的主要應用 Django 中的特定用例,比如 AB 測試跟踪。它們可以作為數據的真實來源,也可以純粹作為緩存來提升性能。

除了少數例外,我們大多數的Redis 部署最初創建時都是主副本節點的單一實例,這樣,我們可以保證單個節點的故障不會造成數據的永久損失,同時,我們可以在故障轉移時繼續提供服務。此外,通過將特定的用例隔離到它們自己的 Redis 實例,我們還可以實現不同的故障模式,限制單個問題的影響。雖然這聽起來很像高可用性數據存儲的要素,但我們意識到,我們還沒有完全做到這一點,特別是當我們的一些實例達到了其負載能力的極限時。在這篇文章中,我們將解釋可用性方面的一些缺陷,這些缺陷最終導致我們重新構建我們的整個實例集,以及我們用來監督這個遷移過程使其不影響任何核心服務的方法。

Nextdoor零停機Redis遷移實踐 1

單一主副本配置的不足之處

例如,讓我們考慮這樣一個場景:一個 Redis 實例由一個主節點和一個副本節點組成,在接近 CPU 最大處理能力時觸發了故障轉移(可能是副本節點遇到了硬件問題,需要準備一個新節點)。主節點現在開始獲取其自身的快照,並將其發送給新的副本節點,這將導致主服務器達到最大處理能力。此時,到這個 Redis 實例的請求開始超時,可能一些用戶開始看到功能受限。我們甚至可以更進一步;如果我們的 Redis 客戶端不夠智能,無法恰當地處理這些超時,可能會引起連鎖反應,我們可能會備份關鍵服務的所有流量。

雖然這只是一個特殊的例子,但我們已經確定了一組使我們的 Redis 配置變得脆弱的關鍵的底層問題。一方面,CPU 利用率成為保證可用性的一個重大瓶頸,這不適合那些一天中經常出現突發事件和流量峰值的服務。然而,更重要的是,沒有一種簡潔的方法可以用來擴展我們的 Redis 實例以滿足我們的服務需求(只能購買更大的實例類型,但這不是一個很有遠見的解決方案)。

幸運的是,這類問題相對比較常見,而 Redis 本身提供了一種可以解決這個問題的集群模式。使用集群,我們的數據分佈在多個分片上,每個分片擁有整個鍵空間的一部分。從可用性的角度來看,這是有好處的,因為它允許我們在必要時水平地擴展我們的 Redis 實例(只需向集群添加新節點),而不需很長的停機時間。此外,上述重大問題的影響也有所減輕;如果一個分片達到了 CPU 處理能力,那麼剩下的分片可以繼續正常運行,因此,問題僅會影響一小部分請求。

制定遷移策略

隨著規模的不斷擴大,我們開始著手升級我們所有的 Redis 部署,以下是幾個需要優先考慮的事項:

  • 切換到集群化 Redis:這可以滿足我們的水平擴展需求,並從根本上解決上述所有問題。
  • 升級舊的實例類型:我們的大多數 Redis 實例都錯過了與新硬件結合的性能提升,因此升級可以進一步緩解我們對高 CPU 利用率的擔憂。
  • 盡量減少遷移過程的停機時間:由於我們的許多 Redis 實例都是我們的核心服務的組成部分,所以遷移到新實例的過程中不影響會員的體驗很重要。

在這裡,我們遇到了最大的挑戰:弄清楚遷移的過程。雖然我們非常清楚我們的最終狀態應該是什麼,但是,最後一個要求——最小化停機時間——排除了許多我們可能最終用來切換到新實例的方法。值得注意的是,我們遷移到集群Redis 的目標意味著我們可能無法直接通過AWS,因為AWS 只支持在不更改集群模式的情況下升級引擎版本(例如,從非集群到非集群或從集群到集群)。為此,我們最好的選擇是手動創建一個新的單分片的Redis 集群,從舊實例的備份中恢復它,然後在稍後擴展分片的數量——在幾個小時的遷移期間裡不能接受寫流量。為了實現這三個目標,我們需要一點創造力來幫助我們實現遷移。

大約在這個時候,我們剛剛開始研究 Envoy 的用法,這是 Lyft 構建的一個分佈式代理,它為基於微服務的大型架構提供了更好的可觀測性。 Envoy 使我們能夠在服務中添加故障注入和健康檢查等功能,從而可以測試推送的穩定性,只需將其與這些服務一起部署即可。它還可以作為一個 Redis 代理,更具體地說,它可以跨不同的上游集群處理 Redis 請求的路由。在這裡,我們找到了大規模Redis 遷移所需的完美抽象;通過將Envoy 作為中介添加到所有的Redis 實例,我們可以更智能地協調將數據遷移到新的Redis 集群的過程,同時繼續為用戶提供服務。為了說明這一點,讓我們按順序看下從舊實例切換到新實例所需的全部事件。

第一步:將所有流量代理到舊實例

Nextdoor零停機Redis遷移實踐 2

Envoy 作為我們服務的跨鬥(sidecar)Docker 容器運行,我們只需修改服務,將 Envoy 視為其上游 Redis 主機。 Envoy 負責偵聽 Redis 請求的特定端口,它直接將請求(讀和寫)轉發給實際的舊 Redis 實例。重要的是,除了修改Redis 的地址外,不需要在客戶端進行任何更改,客戶端基本上不知道它的請求不會直接發送到Redis(一個例外是原來的Redis 實例已經集群化;在這種情況下,客戶端可能需要改成使用普通的Redis 客戶端庫,而不是集群化Redis 庫,因為Envoy 的行為類似於“非集群化Redis”)。

第二步:將寫入鏡像到新集群

Nextdoor零停機Redis遷移實踐 3

在根據必要的規範配置好新的 Redis 集群之後,我們修改了 Envoy 的 Redis 偵聽器定義,將其鏡像或雙寫到新的集群化 Redis。這樣,任何新的寫入都可以同時寫到舊的和新的 Redis 實例。請注意,Envoy 採用了雙重寫入的即發即棄模型,因此,它不會等著確認新集群的寫入是否成功,但通常這裡很少出現任何問題。這時,新的 Redis 集群仍然遠遠落後於舊的集群,但是我們已經比較確定,在實際同步之後,這兩個集群不會再次出現差異。

第三步:將內容完整地回填到新集群

Nextdoor零停機Redis遷移實踐 4

這一步是計算最密集的,需要掃描舊的 Redis 實例並將其所有內容複製到新實例。為了更有效率,我們使用了 RedisShake(一個來自阿里巴巴的優秀開源工具),並圍繞它封裝了我們自己的 ECS 服務來自動同步。請注意,與捕獲快照非常相似,運行此腳本也有掛載CPU 的能力,因此必須特別注意,使用合理的QPS(即每秒查詢次數)和設置來運行腳本,並且最好在低流量期間運行腳本。現在,這兩個實例應該完全同步了,任何錯過的雙寫都應該被捕獲了。

第四步:驗證並切換

Nextdoor零停機Redis遷移實踐 5

為了確保這兩個實例是同步的,我們運行 RedisFullCheck 工具,它經過幾輪比較後可以區分舊實例和新實例之間存在差異的鍵,並將它們存儲到 SQLite 數據庫中。在我們的例子中,鍵的差異更可能是因為某個地方某個特定的Redis 用法與Envoy 不兼容(下面將描述這些不兼容),而不是因為回填腳本遺漏了寫操作;這一步可能需要一些調查工作。一旦檢查腳本結果沒有問題,我們最後會再次更新 Envoy 偵聽器配置,將所有的流量全部路由到新集群,並刪除舊實例。

一些注意事項

使用上面的策略,我們成功地將所有的 Redis 實例遷移到一個最終狀態,其中每個實例都是集群化的,並運行最新版本的 Redis。然而,在實踐中,我們發現這種方法也有其局限性,特別是在 Envoy 的使用上。最重要的是,我們發現,Envoy 只支持散列到一個分片的Redis 請求,它排除了所有與集群相關的命令(由於我們的大多數實例以前都不是集群化的,所以我們很幸運,沒有很多這樣的命令)。因而使用 Redis MULTI 命令的任何事務邏輯也不受支持。為了解決這個問題,我們修改了基於事務的代碼,改為使用 Lua 腳本,它以與 Envoy 兼容的方式提供了相同的原子性優勢。

英文原文

Data Migrations Don’t Have to Come with Downtime