「就用 Postgres」俱樂部的第一條規則,我們是忠實的成員,很簡單。對於 Web 應用程式,Postgres 應該是任何資料儲存和檢索問題的起點。

原因很直接:Postgres 是一個通用資料庫引擎,你的核心 OLTP 工作負載很可能已經運行在 Postgres 上,而且你可能沒有時間去專精於專門的儲存系統。這點,加上硬體和 Postgres 效能的進步,意味著你可以從 Postgres 開始處理任務佇列或訊息佇列、快取、向量嵌入、搜尋和檔案儲存。

這一直是我們在 Hatchet 的主要方法,到目前為止效果不錯。每位工程師都精通從頭開始編寫 Postgres 結構描述和查詢,我們也了解在與 Postgres 查詢規劃器和 MVCC 模型搏鬥時所做的權衡。

但是,對於不斷成長的新創公司來說,這種方法總有極限,而我們終於達到了:在我們的 Postgres 實例中儲存大量的 jsonb 數據。這就是我們從 jsonb 欄位和 toast 表格遷移到我們親切稱為 supertoast 表格的故事。

Hatchet 的基礎資料結構是任務佇列;它是持久化工作流程、DAG、事件以及幾乎所有其他功能的基礎。佇列中的每個任務都包含一個輸入,完成後則有一個輸出。這些輸入和輸出是任意的 JSON 負載,會快速進入系統。

另一個限制是 Hatchet 的設計宗旨是快速;任務從發送到引擎並開始在工作者上運行,平均時間不到 25 毫秒(在最樂觀的情況下,快達 9 毫秒)。這排除了許多候選選項。物件儲存太慢,而且許多託管資料庫可能難以處理,因為網路磁碟的 IOPS 有限。NVMe 磁碟非常適合,而且我們已經在 NVMe 支援的 Postgres 上運行了我們大部分的託管基礎設施!

所以,就像我們系統中的幾乎所有其他東西一樣,我們使用 jsonb 欄位類型將這些負載持久化到 Postgres。

缺點很明顯。即使是小型負載也可能佔用我們 50% 以上的資料庫儲存空間,而較大的負載則可能佔用 90% 以上。但只有最近任務的負載才經常被存取。負載存取遵循冪定律;超過一天前的負載存取頻率非常非常低。這使得資料庫儲存空間有很大一部分閒置在我們的 NVMe 磁碟上,這從成本效益的角度來看並不理想,而且也會膨脹我們的備份。

如果我們的資料庫開始快速填滿,會發生什麼事?雖然 NVMe 磁碟提供了出色的 IOPS,但它們不是網路連接的,這意味著更換磁碟需要我們配置一個全新的資料庫。更糟的是,Hatchet 是一個高周轉率的系統,這意味著與更傳統的讀取密集型 SaaS 工作負載相比,我們的 WAL 非常大。配置新資料庫有時可能需要數小時,這在資料庫接近 100% 儲存容量時可能會讓人感到不安。

一個不太明顯的問題是大型負載對資料庫自動清理 (autovacuum) 操作的影響。我們開始在 Postgres 實例上看到類似以下的極長時間運行的進程:

是的,這是一個接近 18 小時的自動清理!而且有很多方法可以調整它。但更有趣的是它正在清理的表格:一個 TOAST 表格。

TOAST 是 The Oversized-Attribute Storage Technique(超大屬性儲存技術)的縮寫。Postgres 對任何大於 2kb 的列值使用此技術;這些大值然後會被寫入 toast 表格的多個區塊中。

Toast 表格由 Postgres 管理,但向使用者暴露了一些功能。例如,你可以覆蓋具有 toast. 前綴的表格上的自動清理設定,以便為這些表格單獨調整自動清理(根據此論壇回應,預設情況下,toast 表格繼承其自動清理設定自表格)。

正如我們之前所見,自動清理遍歷 toast 表格成本很高,導致資料庫產生非常高的 IOPS 負載——這點稍後會很重要。

為了解決不常存取的負載佔用我們磁碟空間的問題,我們希望將所有熱門負載儲存在 Postgres 中(主要在 toast 表格中),同時將冷門負載卸載到 S3,並在資料庫中儲存一個參考以確保完全一致性。這將為延遲敏感的工作負載提供快速存取,同時為舊任務提供靈活且便宜的儲存,而延遲不是問題。

請注意,此表格按每日時間分區——我們稍後會看到為什麼這很重要!一旦我們跨越 24 小時的門檻,我們將把給定分區中所有現有的負載卸載到 S3,只留下指向 S3 儲存桶的指標作為金鑰。

卸載作業比我們最初想像的要棘手。

這是一個延遲資料複製系統,所以我們選擇了一個自然的資料結構:將卸載建模為我們 supertoast 表格的寫入前日誌 (WAL)。想法是我們會有一個持續運行的作業,它會從 WAL 中取出符合某些年齡標準的列,將負載發送到 S3,然後用我們剛剛寫入的金鑰更新來源記錄。

我們很快就建置並發布了這個系統,但它有明顯的問題:

而且事實證明,WAL 並不是理想的資料模型,因為寫入 S3 是高度可並行的(而且正如我們稍後將看到的,應該批次處理),所以我們希望能夠抓取大量資料。此外,我們意識到 WAL 中的更新和刪除操作在實際意義上並不重要;對於更新,我們可以簡單地內聯重寫負載,而刪除操作將由 S3 生命周期策略回收,只要 supertoast 參考行被正確刪除,資料一致性就能得到保證。

我們很快就必須找出減少 S3 PUT 請求成本的方法;在我們的案例中,每個列一個單獨的 PUT 請求每月將花費我們數萬美元!為了解決這個問題,我們沒有單獨寫入每個負載並儲存其金鑰,而是將個別負載壓縮並串連成一個較大的檔案,然後儲存指向該檔案中每個負載的起始索引和長度的指標以供檢索。

例如,如果我們有兩個負載:

我們將這些負載合併成一個字串(一個檔案),如下所示:

然後我們記錄每個負載的開始位置和長度,如下所示:

最後,我們創建一個金鑰,例如針對負載 B:

然後我們將這個金鑰儲存在資料庫中。這是一個冒號分隔的金鑰,我們可以將其解包成三個值:S3 中的物件金鑰、起始索引和長度。有了這個,我們就可以只讀取 S3 中較大物件的相關位元組範圍,一旦我們讀取了這些位元組,我們只需解壓縮它們,就回到了我們開始時的負載。

這裡的基本想法是,我們可以將我們需要的 S3 操作次數減少幾個數量級,並且理想情況下,通過顯著減少寫入總數(因此,與 S3 的往返次數)來提高應用程式端的吞吐量。

我們設計了一種方法,我將其稱為「寫入並交換」(write-and-swap),而不是通過讀取 WAL 來卸載資料。正如我們之前討論的,我們的負載表格按日期分區,我們認為我們可以利用這一點,以及 Postgres 的一些便利功能,來重寫這個作業,以解決我們遇到的所有問題。

每天,一個 cron 作業會在美國東部時間早上 7:00 左右啟動一個負載處理作業,旨在處理前一天寫入的負載。我們在早上 7:00 開始作業,這樣如果出現任何問題,我們就可以在線處理。

首先,我們創建一個新表格,它是前一天分區的空副本。我們立即手動在此表格上創建一個 CHECK 約束,該約束模擬我們要複製到 S3 的分區約束:

接下來,我們在來源負載分區上創建一個觸發器,將寫入該分區的任何內容複製到新分區,這樣我們在分頁和卸載資料時就不會丟失任何資料:

完成後,我們開始分批處理負載。每個批次都分幾個步驟處理:

我們重複這個過程,直到我們處理完所有負載批次。它看起來像這樣:

作業結束時,我們已經分批遍歷了整個來源分區,並將每個批次卸載到 S3。我們現在也已經將複製的分區填滿了與原始分區相同的所有資料,只是將實際負載替換為 S3 中的金鑰。

這裡分區非常有用:我們可以簡單地刪除舊的分區,這意味著我們不會因為列更新而受到自動清理的壓力。為了安全地做到這一點,我們以 ACCESS EXCLUSIVE 模式鎖定分區表格,刪除舊的分區和觸發器,重命名新分區,並將其附加到父表格,以取代先前分區。

這也是檢查約束派上用場的地方:由於我們在創建副本時創建了一個與原始分區約束相匹配的檢查約束,因此我們可以執行附加操作,而無需再次驗證該約束,這可能會花費幾秒鐘。由於我們不需要驗證,整個交換幾乎是瞬時的。

一旦附加完成,我們就會釋放鎖定,提交,資料就已切換!

我們已經運行這種寫入並交換方法幾個月了,每天持續卸載數億個負載,同時保持資料庫 CPU 使用率和 S3 成本的降低,並且沒有落後!關鍵的見解是,寫入分區副本中的單次寫入比我們 WAL 方法的 UPDATE/DELETE 開銷顯著提高了效能,因為我們可以比寫入原始表格的 UPDATE 更快地進行寫入,同時減少鎖定爭用和自動清理壓力。

Supertoast 的命名歸功於 Ubicloud 的 Daniel Farina。