Notion網站如何將單體PostgreSQL分片成一個水平分割槽的資料庫群?

banq發表於2021-10-08

在我們不斷努力提高應用程式效能的過程中,分片是一個重要的里程碑。在過去的幾年裡,看到越來越多的人將 Notion 應用到他們生活的方方面面,我感到欣慰和欣慰。不出所料,所有新的公司 wiki、專案跟蹤器和圖鑑都意味著要儲存數十億個新塊、檔案和空間。到 2020 年年中,很明顯,產品使用量將超過我們值得信賴的 Postgres 整體的能力,後者已經盡職盡責地為我們服務了五年和四個數量級的增長。隨叫隨到的工程師經常被資料庫 CPU 峰值喚醒,並且簡單的僅目錄遷移變得不安全和不確定。
在分片方面,快速發展的初創公司必須進行微妙的權衡。
 

設計分片方案
如果您以前從未對資料庫進行過分片,這裡有一個想法:不是使用逐漸增加的例項垂直擴充套件資料庫,而是透過跨多個資料庫對資料進行分割槽來進行水平擴充套件。現在,您可以輕鬆啟動額外的主機以適應增長。不幸的是,現在您的資料位於多個地方,因此您需要設計一個在分散式環境中最大化效能和一致性的系統。
 

應用級分片
我們決定實施我們自己的分割槽方案並從應用程式邏輯路由查詢開始,這種方法稱為應用程式級分片。在我們最初的研究,我們還考慮打包分片/叢集解決方案,如Postgres的Citus或MySQL的Vitess。雖然這些解決方案以其簡單性而吸引人,並提供開箱即用的跨分片工具,但實際的叢集邏輯是不透明的,我們希望控制資料的分佈。
應用級分片要求我們做出以下設計決策:

  • 我們應該分片哪些資料?使我們的資料集獨一無二的部分原因是:block表反映了使用者建立內容的樹,這些在大小、深度和分支因子上可能有很大差異。例如,單個大型企業客戶產生的負載比許多普通個人工作空間的總和還要多。我們只想對必要的表進行分片,同時保留相關資料的區域性性。
  • 我們應該如何對資料進行分割槽?好的分割槽鍵確保元組在分片之間均勻分佈。分割槽鍵的選擇還取決於應用程式結構,因為分散式連線很昂貴,而且事務性保證通常僅限於單個主機。
  • 我們應該建立多少個分片?這些碎片應該如何組織?這種考慮既包括每個表的邏輯分片數量,也包括邏輯分片和物理主機之間的具體對映。

  

決策 1:將所有與BLOCK相關的資料分片
由於 Notion 的資料模型圍繞塊BLOCK的概念展開,每個塊BLOCK在我們的資料庫中佔據一行,因此block表是分片的最高優先順序。但是,塊可能會引用其他表。我們決定透過某種外來鍵關係對錶中所有可訪問的表進行分片。並非所有這些表都需要分片,但如果一條記錄儲存在主資料庫中,而其相關塊儲存在不同的物理分片上,我們可能會在寫入不同的資料儲存時引入不一致。
  

決策 2:按工作區 ID 對塊資料進行分割槽
一旦我們決定要分片哪些表,我們就必須將它們分開。選擇一個好的分割槽方案很大程度上取決於資料的分佈和連通性;由於 Notion 是基於團隊的產品,因此我們的下一個決定是按工作區 ID對資料進行分割槽。
每個工作區在建立時都會分配一個 UUID,因此我們可以將 UUID 空間劃分為統一的儲存區。因為分片表中的每一行都是一個塊或與一個塊相關,並且每個塊都屬於一個工作區,所以我們使用工作區 ID 作為分割槽鍵。由於使用者通常一次在單個工作區中查詢資料,因此我們避免了大多數跨分片連線。
  

決策3:容量規劃
決定了分割槽方案後,我們的目標是設計一個分片設定,該設定將處理我們現有的資料規模,以輕鬆滿足我們的兩年使用預測。以下是我們的一些限制條件:

  • 例項型別:磁碟 I/O 吞吐量,以IOPS量化,受 AWS 例項型別和磁碟容量的限制。我們需要至少 60K 的總 IOPS 來滿足現有需求,並在需要時能夠進一步擴充套件。
  • 物理和邏輯分片的數量:為了保持 Postgres 正常執行並保留 RDS 複製保證,我們設定了每個表 500 GB 和每個物理資料庫 10 TB 的上限。我們需要選擇多個邏輯分片和多個物理資料庫,以便分片可以在資料庫之間平均分配。
  • 例項數量:更多的例項意味著更高的維護成本,但一個更強大的系統。
  • 成本:我們希望我們的賬單隨著我們的資料庫設定線性擴充套件,並且我們希望能夠靈活地分別擴充套件計算和磁碟空間。


在對數字進行分析之後,我們確定了一個由480 個邏輯分片組成的架構,這些分片均勻分佈在32 個物理資料庫中。層次結構如下所示:
  • 物理資料庫(共 32 個)
    • 邏輯分片,表示為 PostgresSchema表(每個資料庫 15 個,總共 480 個)
      • block 表(每個邏輯分片 1 個,總共 480 個)
      • collection 表(每個邏輯分片 1 個,總共 480 個)
      • space 表(每個邏輯分片 1 個,總共 480 個)
      • 等所有分片表

為什麼是 480 個分片?關鍵是,480 可以被很多數字整除——這提供了新增或刪除物理主機的靈活性,同時保持統一的分片分佈。例如,將來我們可以從 32 臺主機擴充套件到 40 臺主機,再到 48 臺主機,每次都進行增量跳轉。
相比之下,假設我們有 512 個邏輯分片。512 的因數都是 2 的冪,這意味著如果我們想保持碎片均勻,我們會從 32 臺主機跳到64 臺主機。任何 2 的冪都需要我們將物理主機的數量加倍以進行升級。選擇具有很多因素的值!
 

遷移到分片
一旦我們建立了分片方案,就該實施它了。對於任何遷移,我們的一般框架是這樣的:

  1. 雙重寫入:傳入的寫入同時應用於舊資料庫和新資料庫。
  2. 回填:一旦開始雙寫,將舊資料遷移到新資料庫。
  3. 驗證:確保新資料庫中資料的完整性。
  4. 切換:實際切換到新的資料庫。這可以增量地完成,例如雙讀,然後遷移所有讀。

  

用審計日誌雙重寫入
雙寫階段可確保新資料同時填充舊資料庫和新資料庫,即使新資料庫尚未使用。雙重寫入有幾種選擇:

  • 直接寫入兩個資料庫:看似簡單,但任何寫入的任何問題都可能很快導致資料庫之間的不一致,使得這種方法對於關鍵路徑生產資料儲存來說太不穩定了。
  • 邏輯複製:內建Postgres 功能,使用釋出/訂閱模型將命令廣播到多個資料庫。在源資料庫和目標資料庫之間修改資料的能力有限。
  • 審計日誌和追趕指令碼:建立審計日誌表以跟蹤對遷移表的所有寫入。追趕過程遍歷審計日誌並將每次更新應用到新資料庫,根據需要進行任何修改。

我們選擇了審計日誌策略而不是邏輯複製,因為後者block在初始快照步驟中難以跟上表寫入量。
 

回填舊資料
一旦傳入的寫入成功傳播到新資料庫,我們就會啟動回填過程以遷移所有現有資料。由於m5.24xlarge我們配置的例項上有全部 96 個 CPU (!) ,我們的最終指令碼需要大約三天的時間來回填生產環境。
任何值得回味的回填都應該在寫入舊資料之前比較記錄版本,跳過最近更新的記錄。透過以任何順序執行追趕指令碼和回填,新資料庫最終會聚合以複製單體。
 

驗證資料完整性
遷移與底層資料的完整性一樣好,所以在分片與單體更新後,我們開始驗證正確性的過程。

  • 驗證指令碼:我們的指令碼從給定值開始驗證 UUID 空間的連續範圍,將單體應用上的每條記錄與相應的分片記錄進行比較。由於全表掃描的成本高得令人望而卻步,因此我們隨機抽取了 UUID 並驗證了它們的相鄰範圍。
  • “暗讀”:在遷移讀查詢之前,我們新增了一個標誌來從新舊資料庫中獲取資料(稱為暗讀)。我們比較了這些記錄並丟棄了分片副本,記錄了過程中的差異。引入暗讀增加了 API 延遲,但提供了無縫切換的信心。


作為預防措施,遷移和驗證邏輯由不同的人實現。否則,有人在兩個階段犯同樣錯誤的可能性更大,削弱了驗證的前提。
 

艱難的教訓
雖然分片專案的大部分內容都讓 Notion 的工程團隊處於最佳狀態,但我們事後會重新考慮許多決定。這裡有一些例子:

  • 早點分片。作為一個小團隊,我們敏銳地意識到與過早最佳化相關的權衡。但是,我們一直等到現有資料庫嚴重緊張,這意味著我們必須非常節儉地遷移,以免增加更多負載。這種限制使我們無法使用邏輯複製進行雙寫。工作區 ID(我們的分割槽鍵)尚未填充到舊資料庫中,回填此列會加劇單體應用的負載。相反,我們在寫入分片時即時回填每一行,需要一個自定義的追趕指令碼。
  • 旨在實現零停機遷移。雙寫吞吐量是我們最終切換的主要瓶頸:一旦我們關閉伺服器,我們需要讓追趕指令碼完成將寫入傳播到分片。如果我們再花一週時間最佳化指令碼,以便在切換期間花費 <30 秒來趕上分片,則可能可以在負載均衡器級別進行熱交換而不會停機。
  • 引入組合主鍵而不是單獨的分割槽鍵。今天,分表中的行使用複合鍵:id,舊資料庫中的主鍵;和space_id,當前排列中的分割槽鍵。由於無論如何我們都必須進行全表掃描,因此我們可以將兩個鍵組合成一個新列,從而無需在space_ids整個應用程式中傳遞。


儘管有這些假設,分片還是取得了巨大的成功。對於 Notion 使用者來說,幾分鐘的停機時間使產品明顯更快。在內部,鑑於時間敏感的目標,我們展示了協調的團隊合作和果斷的執行。



 

相關文章