GitHub 關係型資料庫垂直分庫實踐

語言: CN / TW / HK

十多年前,與當時的大多數 Web 應用程式一樣,GitHub 也是一個使用 Ruby on Rails 開發的網站,它的大部分資料都儲存在 MySQL 資料庫中。

多年來,這個架構經歷了多次迭代,以滿足 GitHub 的增長和不斷變化的彈性需求。例如,我們單獨將某些功能的資料儲存在獨立的 MySQL 資料庫中;我們增加了讀副本數量,將讀負載分攤到多臺機器上;我們還使用了 ProxySQL,減少主 MySQL 例項開啟的連線數。

但不管怎樣,GitHub 仍然只有一個主資料庫叢集(我們稱之為 mysql1),這個叢集儲存著 GitHub 核心功能所需的大部分資料,比如使用者資訊、程式碼倉庫、Issues 和拉取請求。

隨著 GitHub 的增長,這種架構難免會面臨巨大的挑戰。我們努力讓資料庫系統保持合理的大小,並使用更新、更強大的機器。任何一個影響 mysql1 的故障都會影響所有在這個叢集儲存資料的功能。

2019 年,為了滿足增長和可用性方面的需求,我們啟動了一個計劃,目標是改進我們對關係型資料庫進行分庫的工具和能力。正如你所想的那樣,這是一項複雜而艱鉅的任務,需要引入和建立各種各樣的工具。

這樣做的結果是,在 2021 年,資料庫主機的負載降低了 50%。這極大減少了與資料庫相關的故障,並提升了 GitHub 網站的可靠性。

虛擬分庫

我們引入的第一個概念叫作資料庫模式虛擬分庫。在進行真正的資料庫分表之前,我們要先確保在應用層面能夠將表分開,並且不影響團隊開發新功能或修改已有的功能。

為此,我們將資料庫表按照領域進行分組,並使用 SQL Linter 來分清領域之間的邊界。這樣我們才能安全地進行資料分庫,避免執行跨分庫的查詢和事務。

模式領域(Schema Domain)

模式領域是我們用來實現虛擬分庫的一個工具。模式領域就是指那些經常一起被用在查詢(例如表連線和子查詢)和事務中的資料庫表的集合。例如,模式領域 gists 包含了與 gists、gist_comments 和 starred_gists 這些功能相關的表。因為它們具有相關性,所以應該被分在一起,它們合在一起被稱為一個模式領域。

模式領域之間有清晰的邊界,並暴露出各個功能之間模糊的依賴關係。在 Rails 應用程式中,這些資訊儲存在 db/schema-domains.yml 配置檔案中,如下所示:

<code data-type="codeline">gists:</code><code data-type="codeline">  - gist_comments</code><code data-type="codeline">  - gists</code><code data-type="codeline">  - starred_gists</code><code data-type="codeline">repositories:</code><code data-type="codeline">  - issues</code><code data-type="codeline">  - pull_requests</code><code data-type="codeline">  - repositories</code><code data-type="codeline">users:</code><code data-type="codeline">  - avatars</code><code data-type="codeline">  - gpg_keys</code><code data-type="codeline">  - public_keys</code><code data-type="codeline">  - users</code>

複製程式碼

SQL Linter

我們基於模式領域構建了兩個 Linter,用於確保領域之間具有清晰的虛擬邊界。我們在查詢語句上添加註解,就可以識別出那些跨越多個模式領域的查詢和事務,並可以允許一些例外情況。如果一個領域沒有違反這個規則,就可以進行虛擬分庫,它們的物理表就可以被遷移到另一個數據庫叢集中。

Query Linter

Query Linter 用於檢查只有屬於同一個模式領域的表才能被針對同一個資料庫的查詢引用。如果它檢測到查詢中包含來自不同領域的表,就會丟擲異常。異常中帶有有用的資訊,可以幫助開發人員解決問題。

因為 Linter 只在開發和測試環境中啟用,開發人員可以在開發過程中發現不合規的查詢。另外,在 CI 執行期間,Linter 可以確保不會有新的不合規查詢被引入。

Linter 還提供了特殊的 /* cross-schema-domain-query-exempted */ 註釋,用它來註解 SQL 查詢語句可以允許一些例外情況,將上述的異常忽略掉。

我們還給 ActiveRecord 增加了新方法,這樣添加註釋就更容易了:

<code data-type="codeline">Repository.joins(:owner).annotate("cross-schema-domain-query-exempted")</code><code data-type="codeline"># => SELECT * FROM `repositories` INNER JOIN `users` ON `users`.`id` = `repositories.owner_id` /* cross-schema-domain-query-exempted */</code>

複製程式碼

將所有查詢加上註解,就可以得到需要修改的查詢語句的清單。以下是我們用來解決例外情況的常用方法。

有時候,我們只需要把表連線查詢拆成單獨的查詢。例如,用 ActiveRecord 的 preload 方法取代 includes 方法。

另一種比較有挑戰性的情況是 has_many :through 關係導致需要連線來自不同模式領域的表。對於這種情況,我們提供了通用解決方案:has_many 新增了 disable_joins 選項,告訴 ActiveRecord 不要執行底層表連線操作,改為執行多次查詢,並在查詢之間傳遞主鍵值。

在應用層進行資料連線,而不是在資料庫層,這也是一種常見的解決方案。例如,使用兩個單獨的查詢替代 INNER JOIN,然後在 Ruby 中執行“union”操作(例如,A.pluck(:b_id) & B.where(id:...))。

有時候,這樣做會帶來效能上的極大提升。根據資料結構和資料集勢的不同,MySQL 的查詢計劃器有時會生成效能較差的查詢執行計劃,而應用層的資料連線可以獲得較穩定的效能。

與大多數與穩定性和效能相關的變更一樣,這些都用 Scientist 庫做過實驗。我們對新舊兩種實現進行了實驗對比,可以客觀地評估每一個變更的效能。

Transaction Linter

除了查詢語句之外,事務也是我們的一個關注點。現有的應用程式程式碼都是基於一定的資料庫模式。MySQL 事務可以保證同一資料庫不同表之間的一致性。如果事務中的查詢所涉及的表被移到其他資料庫中,那就無法保證一致性。

為了弄清楚需要檢查哪些事務,我們引入了 Transaction Linter。與 Query Linter 類似,它可以確保一個事務所涉及的表都屬於同一個模式領域。

這個 Linter 執行在生產環境中,進行大量的取樣,並將對效能的影響降到最低。結果被收集起來,用於分析哪些地方存在跨領域事務,這樣我們就可以決定是否要更新某些程式碼或修改我們的資料模型。

對於那些對事務一致性要求很高的地方,我們將資料抽取到同屬一個模式領域的新表中。這樣可以確保它們位於同一個資料庫叢集中,繼續享有事務一致性保證。這種情況多發生在“多型性”表上,這些表的資料來自不同的模式領域(例如,reactions 表儲存了來自多個不同功能的資料,如 Issues、拉取請求、討論等)。

不停機遷移資料

模式領域在經過虛擬分拆之後,就可以進行物理表遷移。為了進行資料遷移,我們採用了兩種不同的方法:Vitess 和寫切換(Write-Cutover)。

Vitess

Vitess 是一個建立在 MySQL 之上的伸縮層,用於滿足資料分片需求。我們用了它的垂直分片特性,在不停機的情況下將一些表遷移到一起。

我們在 Kubernetes 叢集上部署了 Vitess 的 VTGate。應用程式連線到這些 VTGate 端點上,而不是直接連線到 MySQL。VTGate 實現了同樣的 MySQL 協議,對於應用程式來說與 MySQL 沒有什麼兩樣。

VTGate 程序通過 Vitess 的另一個元件 VTTablet 與 MySQL 例項發生互動。Vitess 的資料表遷移特性是通過 VReplication 來實現的,這個元件負責在資料庫叢集之間複製資料。

寫切換

在 2020 年初,Vitess 的採用還處在早期階段。除此之外,我們還採用了另一種遷移大規模資料表的方法。這樣可以降低依賴單一解決方案所帶來的風險,確保 GitHub 網站的持續可用性。

我們利用 MySQL 的常規復制特性將資料遷移到另一個叢集。在一開始,新叢集被加到舊叢集的複製樹中,然後再用一個指令碼快速執行一些變更來實現切換。

在進行寫切換之前的 MySQL 叢集

在執行指令碼之前,我們先調整應用程式和資料庫複製結構,將目標叢集 cluster_b 作為現有叢集 cluster_a 的子叢集。我們用 ProxySQL 實現 MySQL 主例項之間的多路客戶端連線。cluster_b 上的 ProxySQL 將流量路由到 cluster_a 的主例項上。有了 ProxySQL,我們可以快速改變資料庫的流量路由,將對客戶端(也就是我們的 Rails 應用程式)的影響降到最低。

基於這樣的結構,我們可以很自然地將資料庫連線遷移到 cluster_b。所有的讀流量都流向複製了 cluster_a 主例項資料的主機,所有的寫流量仍然流向 cluster_a 主例項。

隨後,我們開始執行切換指令碼:

  • 開啟 cluster_a 主例項的只讀模式。這個時候,所有向 cluster_a 和 cluster_b 的寫入操作都是不允許的。所有嘗試向資料庫執行寫入操作的 Web 請求都會失敗,並返回 500 錯誤。

  • 從 cluster_a 主例項讀取最後執行的 MySQL GTID。

  • 輪詢 cluster_b 主例項,確認最後執行的 GTID 已達到。

  • 停止從 cluster_a 到 cluster_b 的複製。

  • 更新 cluster_b 的 ProxySQL 配置,將流量重定向到 cluster_b 主例項。

  • 關閉 cluster_a 和 cluster_b 主例項的只讀模式。

  • 大功告成!

經過精心的準備和調整,我們發現,即使是我們最繁忙的資料庫表,執行完以上 6 個步驟也只需要幾十毫秒。由於我們是在一天內流量最不繁忙的時間進行切換,因寫入失敗而導致的使用者可感知錯誤非常少。這樣的結果已經超出了我們的預期。

發現

我們通過寫切換來拆分 mysql1——我們最初的資料庫主叢集。我們一次性遷移了 130 張最繁忙的資料庫表,它們為 GitHub 的核心功能提供支撐:程式碼倉庫、Issues 和拉取請求。寫切換是我們用來降低遷移風險的一種策略,讓我們可以使用多種獨立的工具。另外,因為部署拓撲問題和需要提供讀己之所寫(Read-Your-Write)支援,我們並沒有在所有地方都使用 Vitess 作為遷移資料庫表的工具,但我們預計在未來會將它作為資料遷移的主要工具。

結果

在文章簡介裡所提到的 mysql1,也就是我們的資料庫主叢集,它儲存著 GitHub 核心功能的大部分資料,比如使用者、程式碼倉庫、Issues 和拉取請求。從 2019 年開始,我們逐漸具備了對這個關係型資料庫進行伸縮的能力,並獲得瞭如下結果:

  • 在 2019 年,mysql1 平均每秒處理 95 萬個查詢,其中 90 萬個查詢發生在副本上,5 萬個發生在主例項上。

  • 現在,也就是在 2021 年,同樣是這些表,它們分佈在不同的叢集中。在兩年之內,它們見證了持續的增長,而且一年比一年快。所有這些叢集的伺服器加在一起,平均每秒處理 120 萬個查詢,其中 112 萬 5 千個查詢發生在副本上,7 萬 5 千個發生在主例項上。與此同時,每臺主機的平均負載減少了一半。

這極大減少了與資料庫相關的故障,並提升了 GitHub 網站的可靠性。

更多的分庫策略

除了垂直分庫,我們也進行水平分庫(也就是分片)。我們可以將資料庫表拆分到多個叢集中,為可持續的增長提供支援。我們將在後續文章中分享更多與之相關的工具、Linter 和 Rails 改進的細節內容。

結論

在過去的十多年,GitHub 學會了如何通過伸縮資料庫來滿足不斷增長的需求。我們通常選擇的是“普通”的技術,這些技術被證明很適合我們的規模,因為對於我們來說,可靠性是最為重要的。與此同時,我們也使用一些被業界證明可行的工具,有了這些工具,我們只需要對程式碼做簡單的修改,它們為我們的資料庫在未來增長鋪平了道路。

原文連結: https://github.blog/2021-09-27-partitioning-githubs-relational-databases-scale/