GitHub 關係型數據庫垂直分庫實踐

作者 | GitHub

譯者 | 屠靈

策劃 | 趙鈺瑩

十多年前,與當時的大多數 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 配置文件中,如下所示:

gists:
  - gist_comments
  - gists
  - starred_gists
repositories:
  - issues
  - pull_requests
  - repositories
users:
  - avatars
  - gpg_keys
  - public_keys
  - users

SQL Linter

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

Query Linter

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

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

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

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

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

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

有時候,我們只需要把表連接查詢拆成單獨的查詢。例如,用 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 主實例。

隨後,我們開始執行切換腳本:

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

發現

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

結  果

在文章簡介裏所提到的 mysql1,也就是我們的數據庫主集羣,它保存着 GitHub 核心功能的大部分數據,比如用戶、代碼倉庫、Issues 和拉取請求。從 2019 年開始,我們逐漸具備了對這個關係型數據庫進行伸縮的能力,並獲得瞭如下結果:

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

更多的分庫策略

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

結  論

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

原文鏈接:

https://github.blog/2021-09-27-partitioning-githubs-relational-databases-scale/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-TNOYRfRp-U3zXCQS6yAEQ