GitHub 的 MySQL 高可用性實踐分享
GitHub 使用 MySQL 作為所有非 git 倉庫資料的主要儲存, 它的可用性對 GitHub 的訪問操作至關重要。GitHub 站點本身、GitHub 的 API、身份驗證等等都需要進行資料庫訪問。我們執行著多個 MySQL 叢集來為不同的服務和任務提供支援。我們的叢集使用經典的主從配置, 主叢集中的某個節點能夠接受寫入。其餘的從叢集節點非同步同步來自主伺服器的更改, 並提供資料的讀取服務。
主節點的可用性尤為重要。沒有主伺服器, 叢集無法接受寫入:任何需要保留的寫入資料都不能持久化儲存,任何傳入的更改(如提交、問題、使用者建立、審閱、新儲存庫等)都將失敗。
為了支援寫操作,我們顯然需要有一個可用的資料寫入節點,一個主叢集。但同樣重要的是,我們需要能夠識別或找到該節點。
在一個寫入失敗,提示說主節點崩潰的場景中,我們必須確保能啟用一個新的主節點,並快速表明其身份。檢測故障所需的時間、進行故障轉移並公佈新的主節點所花費的時間,構成了總的停機時間。
本文將介紹 GitHub 的 MySQL 高可用性和主服務發現解決方案,它使我們能夠可靠地執行跨資料中心操作,容忍資料中心隔離,並使得出現故障時耗費的停機時間變得更短。
高可用目標
本文描述的解決方案,迭代並改進了之前在 GitHub 實現的高可用(HA)解決方案。隨著規模的擴大,MySQL 的高可用策略必須適應變化。我們希望為 GitHub 中的 MySQL 和其他服務,提供類似的高可用策略。
在考慮高可用和服務發現時,有些問題可以引導你找到合適的解決方案。包含但不限於:
- 你能容忍多長的中斷時間?
- 崩潰檢測的可靠性如何?你能容忍錯誤報告(過早的故障轉移)嗎?
- 故障轉移的可靠性如何?什麼情況下可以失敗?
- 解決方案在跨資料中心的場景下效果如何?在低延遲和高延遲的網路情況下如何?
- 解決方案是否允許一個完整的資料中心故障或者出現網路隔離?
- 有沒有防止或緩解腦裂(兩臺伺服器都宣稱是某個叢集的主節點,不知情對方的存在,並且都能接受寫操作)的機制。
- 你能允許資料丟失嗎?在多大程度上?
為了說明上面的一些情況,首先讓我們討論一下之前的高可用方案,並說說我們為什麼要修改它。
移除基於 VIP 和 DNS 的服務發現
在之前的迭代版本中,我們:
- 使用 VIP 和 DNS 做主節點的發現
在這個迭代版本中,客戶端使用名字服務(比如 mysql-writer-1.github.net)來發現寫節點。名字可以解析為一個虛擬 IP(VIP),這個 VIP 指向主節點。
因此,在正常情況下,客戶端只需要解析名稱,連線到解析後的 IP上,然後發現主節點也正在另一邊監聽連結(也就是客戶端連上了主節點)。
考慮這個跨越三個不同資料中心的複製拓撲:
當主節點發生故障時,必須在副本集中選出一個伺服器,提升為新的主節點。
orchestrator 將會檢測到故障,選舉出一個新的主節點,然後重新分配 name(名稱)和 VIP(虛擬 IP)。客戶端實際上並不知道主節點的真實身份:它們只知道 name(名字),而這個名字現在必須解析給新的主節點。不過,需要考慮:
VIP 是需要協作的:它們由資料庫伺服器自己宣告和擁有。為了獲得或釋放 VIP,伺服器必須傳送 ARP 請求。擁有 VIP 的伺服器必須在新提升的主節點獲得 VIP 之前先釋放掉。這還有一些額外的影響:
- 有秩序的故障轉移操作會首先通知故障主節點並要求它釋放 VIP,然後再通知新提升的主節點並要求它獲取 VIP。如果無法通知到原主節點或者拒絕釋放 VIP 怎麼辦?首先要考慮到,該伺服器上存在故障場景,它不可能會不及時響應,或根本不響應。
- 我們最終可能會出現腦裂情況:兩個註解同時聲稱擁有同一個 VIP。根據最短的網路路徑,不同的客戶端可能會連線到不同的伺服器。
- 事實源於兩個獨立伺服器間的協作,並且這個設定是不可靠的。
- 即使原主節點確實配合,工作流程也浪費了寶貴的時間:當我們通知原主節點時,切換到新主節點的操作一直在等待。
- 即使 VIP 發生變化,現有的客戶端連線也不能保證與原伺服器斷開連線,而且我們可能仍然會經歷腦裂。
VIP 受限於物理位置。它們屬於交換機或者路由器。所以,我們只能將 VIP 重新分配到位於同一位置的伺服器上。特別是,當新提升的伺服器位於不同的資料中心時,我們無法分配 VIP,只能修改 DNS。
- 修改 DNS 需要較長的傳播時間。根據配置,客戶端會快取 DNS 一段時間。跨資料中心(cross-DC)故障轉移則意味著更多的中斷時間:為了讓所有客戶端知曉新主節點的身份,需要花費更長的時間。
僅這些限制,就足以促使我們尋求新的解決方案,但考慮更多的是:
- 主節點通過 pt-heartbeat 心跳服務進行自行注入,目的是測量延遲和節流。這項服務必須從新提升的主節點開始。如果有可能的話,原主節點的服務將被關閉。
- 同樣的,Pseudo-GTID 注入也是主節點自己管理的。它將從新的主節點開始,並在原主節點結束。
- 新的主節點被設為可寫。如果可能的話,原主節點被設為只讀。
這些額外的步驟是導致中斷總時間的一個因素,並且引入了它們自己的故障和摩擦。
該解決方案生效了,GitHub 已經成功完成 MySQL 的故障遷移,但我們希望我們的 HA 在以下方面有所改進:
- 資料中心不可知
- 允許資料中心出現故障
- 刪除不可靠的協作工作流
- 減少總的中斷時間
- 儘可能地進行無損故障轉移
GitHub 的高可用解決方案:orchestrator, Consul, GLB
我們的新策略,除了附帶的改進外,還解決或減輕了上面的許多問題。在今天的高可用設定中,我們有:
- 使用 Hashicorp 的 Consul 來做服務發現。
- 使用選播(anycast)做網路路由。
新的設定將完全刪除 VIP 和 DNS 的修改。在我們引入更多元件的同時,我們能夠將元件解耦並簡化任務,並且能夠使用可靠、穩定的解決方案。下面逐一分析。
正常流程
正常情況下,應用程式通過 GLB/HAProxy 連線到寫節點。
應用程式永遠不知道主節點的身份。和之前一樣,它們使用名字。例如,cluster1 的主節點命名為 mysql-writer-1.github.net。在我們當前的設定中,名字被解析為一個選播(anycast) IP。
使用選播時,名字在任何地方都被解析為相同的 IP,但流量會根據客戶端位置的不同進行路由。需要指出的是,在我們的每個資料中心,都有 GLB(我們的高可用負載均衡)被部署在不同的容器中。指向 mysql-writer-1.github.net 的流量總是路由到本地資料中心的 GLB 叢集。因此,所有客戶端都由本地代理提供服務。
我們在 HAProxy 上執行 GLB。我們的 HAProxy 維護了一個寫連線池:每個 MySQL 叢集一個連線池,其中每個連線池只有一個後端伺服器:叢集的主節點。DC 中的所有 GLB/HAProxy 容器都具有相同的連線池,並且它們都指向相同的後端伺服器。這樣,如果一個應用程式想要寫入 mysql-writer-1.github.net,它連線到哪個 GLB 伺服器並不重要。它總會被路由到實際的 cluster1 主節點上。
對於應用程式而言,服務發現結束於 GLB,並且不再需要重新發現。就這樣,通過 GLB 將流量路由到正確地址。
GLB 如何知道哪些伺服器可以作為後端伺服器,以及如何將更改傳播到 GBL 呢?
Consul 的服務發現
Consul 是著名的服務發現解決方案,它也提供 DNS 服務。然而,在我們的解決方案中,我們用它作為高效的鍵值儲存系統。
在 Consul 的鍵值儲存中,我們寫入了叢集主控的標識。對於每一個叢集,都有一個鍵值對記錄標識叢集的主 FQDN,埠,IPV4,IPV6。
每一個 GLB/HAProxy 節點都執行 consul 模板:每一個服務都在監聽 consul 資料的變更(這裡主要是對叢集主控的資料變更)。consul 模板會生成一個有效的配置檔案並且當配置變更時,能夠自動過載 HAProxy。
因此,Consul 中主控標識的變更會被每一個 GLB/HAProxy 觀察到,然後它們立即重新配置它們自己,在集群后端池中設定新的主控作為單一物件,並且進行過載以反映這些變更。
在 GitHub 中,每一個數據中心都有一個 Consul 設定,並且每一個設定都具有高可用性。然而,這些設定又互相獨立,它們之間不進行互相複製或資料共享。
那麼 Consul 是如何獲得變更通知,在交叉資料中心中,資訊又是如何分佈的呢?
orchestrator/raft
執行一個 orchestrator/raft 設定:orchestrator 節點之間通過 raft 一致性演算法進行通訊。每一個數據中心有 1~2 個 orchestrator 節點。
orchestrator 負責失敗檢測,MySQL 故障轉移,以及 Consul 主控的變更通知。故障轉移通過單個 orchestrator/raft 主導節點進行操作,但是對於主控變更,產生新主控的訊息會通過 raft 機制被傳播到所有 orchestrator 節點。
一旦 orchestrator 節點接收到主控變更的訊息,它們會與自己對應的本地 Consul 設定通訊:它們都執行 KV 寫操作。具有多個 orchestrator 節點的資料中心會有多個完全相同的 Consul 寫操作。
整體流程
在主節點故障的場景中:
- orchestrator 節點檢測到故障。
- orchestrator/raft 主導節點開始恢復。一個新的主節點被設定為 promoted 狀態。
- orchestrator/raft 向所有 raft 叢集節點通知主節點變更。
- 所有 orchestrator/raft 成員接收到主節點變更的通知。每個成員都向本地 Consul 寫入包含新主節點身份的 KV 記錄。
- 每個 GLB/HAProxy 都執行一個 consul 模版,用於監視 Consul KV 儲存的變化,並重新配置和重新載入 HAProxy。
- 客戶端流量被重定向到新的主節點上。
每個元件都有明確的責任歸屬,而且整個設計簡單並且解耦。orchestrator 不需要知道負載均衡。Consul 不需要知道這些資訊是從哪裡來的。代理只關心 Consul,客戶端只關心代理。
而且:
- 沒有 DNS 的變更需要傳播。
- 沒有 TTL。
- 整個流程不需要原故障主節點的配合,它在很大程度上已被忽略。
其他細節
為了進一步確保流程的安全,我們還提供了以下內容:
- 將 HAProxy 的配置項 hard-stop-after 設定為一個非常短的時間。當在寫連線池中使用新的後端伺服器重新載入時,它會自動終止所有到原主節點的連線。
- 通過使用 hard-stop-after 配置項,我們甚至不需要客戶端的配合,這也就緩解了腦裂的情況。值得注意的是,這並不是絕對的,我們還是需要一些時間來消滅舊連線。但是,在某個時間點之後,我們就會感到舒服,因為不會出現令人厭惡的意外。
- 我們並不嚴格要求 Consul 隨時可用。實際上,我們只需要它在故障轉移期間可用。如果 Consul 恰好這時不可用,GLB 將繼續使用已知的資訊運作,不採用任何極端的行動。
- GLB 被用於驗證新提升的主節點的身份。類似於我們的 context-aware MySQL pools(上下文感知的 MySQL 執行緒池),在後端伺服器上進行檢查,以確保它確實是一個可寫的節點。如果我們恰好在 Consul 中刪除了主節點的身份,沒有問題;空的條目會被忽略。如果我們在 Consul 中錯誤的寫入了一個非主節點的名稱,沒有問題;GLB 將拒絕更新它並以最後已知的狀態繼續執行。
我們會在以下章節進一步完成備受關注和期望的高可用目標。
orchestrator/raft 失敗檢測
orchestrator使用全面方法來檢測失敗,因此這種方法非常可靠。我們不會觀察到誤報 —— 因為我們沒有進行過早的故障轉移,所以也不會產生不必要的中斷時間。
通過完全的 DC 網路隔離(又稱 DC 柵欄),orchestrator/raft 進一步處理這個問題。一個 DC 網路隔離會引起一些混淆:這個 DC 中的伺服器是可以互相通訊的。他們是與其他 DC 網路隔離,還是其他 DC 被網路隔離?
在一個 orchestrator/raft 設定中,raft 的 leader 節點就是執行故障轉移的那個節點。leader 是取得了大多數節點支援的節點(特定數量)。我們的 orchestrator 節點部署就是這樣,沒有單一資料中心可以佔大多數,任何 n-1 的 DC 也是如此。
在一個完全 DC 網路隔離的事件中,這個 DC 的 orchestrator 節點與其它 DC 中的對應節點失去連線。最終,隔離 DC 中的 orchestrator 節點不能作為 raft 叢集的 leader 節點。如果任何這種節點碰巧成為了 leader 節點,它就會退出。一個新的 leader 節點可以從任何一個其他 DC 分配。leader 節點會得到其他所有 DC 的支援,這些 DC 彼此之間可以進行通訊。
因此,呼叫 shots 的 orchestrator 節點將位於網路隔離資料中心之外。一個隔離 DC 應該有一個主伺服器,orchestrator 會使用可用 DC 中的其中一個伺服器將它替換來初始化故障轉移。我們委託非隔離 DC 中的那些節點來做這個決定,以此來緩解 DC 隔離。
更快的公告
通過發出公告說主分支即將修改,可以進一步減少執行停機的總時間。如何實現這個想法?
當 orchestrator 開始進行故障遷移的時候,它會觀察可用於升級的伺服器佇列。在瞭解自我複製的規則,以及接受提示和限制的情況下,在最好的行動方針中,它能做出基於一定訓練的決策。
它可能意識到一個可以升級的伺服器也是一個理想的候選策略,例如:
- 沒有什麼可以阻止伺服器的升級(潛在使用者已經暗示伺服器優先升級),而且
- 認為伺服器可以將它所有的版本視為複本。
在這個例子中 orchestrator 首先將伺服器設定為可寫,然後立即公告伺服器的升級(我們的例子中是寫到了 Consul KV),即使非同步開始修復複製樹,這種操作通暢會花費更多幾秒的運算。
有可能當我們的 GLB 伺服器完全過載時,複製樹已經完好無損,但是這不是嚴格要求的。伺服器可以接收到寫操作!
半同步複製
在 MySQL 的半同步複製中,在獲知更改已傳送到一個或多個副本之前,主伺服器不會確認事務已提交。它提供了一種實現無損故障轉移的方法:應用於主伺服器的任何更改都將應用於或等待應用於其中一個副本。
一致性帶來的成本是:可用性風險。如果沒有副本確認收到更改,主伺服器將被阻塞並且寫入操作將停止。幸運的是,這裡有一個超時設定,在這之後主伺服器可以恢復到非同步複製模式,使寫入操作再次可用。
我們已經把我們的超時設定在一個合理的低值:500ms。將更改從主伺服器傳送到本地 DC 副本,通常也傳送到遠端 DC,這個閾值是綽綽有餘的。設定這個超時時間之後,我們可以觀察到完美的半同步行為(無需回退到非同步複製),並且在確認失敗的情況下,可以在非常短的阻塞週期內獲得讓人滿意的表現。
我們在本地 DC 副本上啟用半同步,並且在主伺服器宕機的情況下,我們期望(儘管不嚴格地執行)無損故障轉移。對完整的 DC 故障進行無損故障轉移的代價很高昂,我們並不期待這麼做。
在試驗半同步超時的同時,我們還觀察到一種對我們有利的行為:主伺服器在發生故障時,我們能夠影響最佳候選人的標識。通過在指定的伺服器上啟用半同步,並將它們標記為候選伺服器,我們可以通過影響故障的結果來減少總的停機時間。在我們的試驗中,我們觀察到,我們通常最終會得到最佳候選伺服器,並因此釋出快速公告。
心跳注入
我們沒有在已提升/已降級的主裝置上管理 pt-heartbeat 服務的啟動/關閉,作為替代,我們選擇隨時隨地執行它。這需要進行打一些補丁,以便使 pt-heartbeat 可以支援伺服器端來回更改它們的 read_only 狀態或其完全崩潰。
在我們目前的設定中,在主伺服器及其副本上執行 pt-heartbeat 服務。在主伺服器上,他們產生心跳事件。在副本伺服器上,他們識別到伺服器是隻讀的,並定期重新檢查其狀態。只要伺服器升級為主伺服器,該伺服器上的 pt-heartbeat 會將伺服器標識為可寫,並開始注入心跳事件。
orchestrator 所有權委託
我們進一步委託到 orchestrator:
- 偽-GTID注入
- 設定被提升的主控作為可寫的,清除它的複寫狀態
- 如果可能,設定老的主控為只讀狀態
對於新主控,以上所有這些操作減少了衝突的可能性。一個剛剛被提升的主控應該是線上的並且可接入,否則我們就不應該提升它。然後讓 orchestrator 直接應用變更到被晉升的主控上也應該是合理的。
限制和缺點
代理層使得應用程式不知道主伺服器的身份,但是對於主伺服器它也掩蓋了應用程式的身份。所有主伺服器看到的連線都來自代理層,我們丟失了關於連線實際來源的資訊。
隨著分散式系統的發展,我們仍然遺留了未處理的場景。
值得注意的是,在資料中心隔離場景中,假設主伺服器位於 DC 中,DC 中的應用程式仍然能寫入主伺服器。一旦網路恢復正常,可能會導致狀態不一致。我們正努力在非常獨立的 DC 中,通過實現一個可靠的 STONITH 來緩解這種腦裂。和以前一樣,在將主伺服器之前需要花費一些時間,可能出現短暫的腦裂。而避免腦裂的操作成本非常高。
更多的場景存在:故障轉移時 Consul 的終端;部分 DC 隔離;其他的。我們知道,使用這種性質的分散式系統不可能消除所有的漏洞,因此,我們將焦點放在最重要的案例上。
結果
orchestrator/GLB/Consul 設定給我們提供以下功能:
- 可靠的故障檢測
- 資料中心不可知的故障遷移
- 典型的無損故障遷移
- 對資料中心網路隔離的支援
- 緩解腦裂的問題(仍在實現中)
- 無合作相關的依賴
- 多數場景下大約 10~13 秒的斷電恢復能力。(我們觀察到一些場景下最長 20 秒的斷電恢復和極端場景下最長 25 秒的情況)
結語
編排/代理/服務發現範例在解耦架構中使用眾所周知的可信元件,這使得部署、運維和觀察變得更加容易,並且每個元件可以獨立擴充套件或縮減。我們將不斷測試我們的設定,以繼續尋求改進。