健康的消化系統能有效排除廢物。纖維是健康飲食的重要部分,並非因為它有營養,而是因為它能促進食物通過消化道。

資料庫也不例外。如果你想讓隊列表保持健康,就必須監控那些設計用來清理的系統,避免它們在積壓前失效。

Postgres 在成為適合隊列工作負載的資料庫之前,就已經是熱門選擇。經過多年及多個主要版本的演進,Postgres 對這類工作負載的支援越來越強。

但什麼讓工作隊列特別棘手?即使有這些進步,還存在哪些陷阱?

了解這些很重要,因為它們可能不只會拖垮你的工作隊列,也會影響混合工作負載的資料庫及整個應用程式。

「只用 Postgres」的說法讓人覺得所有工作負載都適合放在 Postgres 資料庫中。這並非完全錯誤。你幾乎可以把任何工作負載丟給 Postgres,並讓它運作。豐富的擴充套件生態系統填補了「原生」Postgres 的功能缺口。

因此,你可能會在同一資料庫中同時運行多種不同的工作負載,如 OLTP、OLAP、時間序列、事件溯源、全文檢索、地理空間及隊列工作負載,這些工作負載有不同需求、挑戰和優先順序,卻共用相同資源。

這些工作負載各有專用服務可獨立使用,但如果你正在閱讀這篇文章,可能是想優化它們如何和諧共存。

在 PlanetScale,我們總是支持為每項工作選擇合適的工具,不論是否為 Postgres。但如果你想了解如何在 Postgres 中維持健康的隊列與混合工作負載共存,請繼續閱讀。

工作隊列表的獨特之處在於大多數列是短暫的:插入、讀取一次、刪除。因此表的大小大致保持不變,但累積吞吐量巨大。

你的應用可能用工作隊列追蹤非同步操作,如發送郵件、開立發票或產生報告。使用 Postgres 的主要好處是可以將工作狀態與資料庫中其他邏輯同步於交易中。

若工作失敗,整個交易失敗並回滾。若交易失敗,工作可能重試或被刪除。使用外部服務則需謹慎協調以保持與應用交易狀態同步。

以下是一個簡單的隊列表範例,用於建立需要執行的個別工作。payload 欄位包含應用完成操作所需的所有資訊。

應用定期查詢待辦工作,尋找最舊且仍待處理的工作,執行必要操作後刪除該工作。

工作者開啟交易並認領下一個待處理工作:

實務上,保持交易盡可能短暫至關重要——交易持續時間越長,vacuum 被阻擋的時間越久。本文範例假設工作者操作在毫秒以下。

工作者執行工作所需操作。若失敗,交易回滾——該列未被修改,鎖定釋放,工作對其他工作者重新可見。

若成功,工作者刪除該工作並提交交易:

為了並行及加速工作處理,你可能希望多個工作者同時執行個別工作。上述查詢中,FOR UPDATE SKIP LOCKED 可防止工作者重複執行相同工作,因為該查詢會鎖定正在處理的列直到交易提交。

工作隊列工作負載本質簡單:取出一列後刪除。但背後還有清理工作。

常見問題是資料庫無法比新工作累積速度更快地清理交易後遺留的資料。

Postgres 已被證明能在大規模下處理此類工作負載,因此其支援工作隊列的能力無庸置疑。

挑戰通常是如何讓工作隊列與資料庫中其他競爭工作負載和諧共存。

隊列表的健康不僅取決於自身設定,也受同一 Postgres 實例上其他交易行為影響。雖然複本及複寫槽也可能影響隊列表,但本文重點在主節點的查詢競爭流量。

當列被修改時,Postgres 會維護多個版本,讓不同交易能看到查詢時的列值。這是 Postgres 實作的「多版本併發控制」(MVCC)核心設計。

這意味著在工作隊列中,針對 DELETE 操作的列不會立即移除,而是標記為刪除、對新交易不可見,並保留在資料庫直到清理完成。這些尚未物理刪除、不可見的列稱為「死元組」。

死元組由 vacuum 操作清理,可手動執行或在健康的 Postgres 中定期自動執行。雖然死元組不會在 SELECT 查詢中返回,但仍會產生成本。

對於順序掃描,執行器會讀取死元組所在的堆頁並檢查可見性後丟棄。

對於索引掃描——工作隊列依賴的 ORDER BY run_at LIMIT 1 查詢類型——成本更隱晦:B-tree 索引會累積指向死元組的參考,導致掃描必須遍歷指向已不可見列的索引條目。

每個死索引條目都意味著額外的 I/O 來檢查堆頁,最後丟棄。這種額外負擔對應用不可見,但隨死元組數量增加會大幅成長。

清理頻率由 autovacuum_naptime 控制,該參數決定啟動程序檢查每個資料庫是否需要 vacuum 的間隔時間,預設約為 1 分鐘。何時 vacuum 取決於死元組閾值 autovacuum_vacuum_threshold 與 autovacuum_vacuum_scale_factor。

假設有一個工作表,定期建立和處理不同類型的任務。另一個應用存取同一資料庫執行大型分析查詢與報告,這些工作優先級較低且較慢完成。

若你查詢工作隊列表,預期會看到三筆待處理工作。

每列包含查詢執行器用以判斷是否應包含在結果中或對當前交易不可見的元資料。雖然無法直接查詢死元組,但可在回傳的活躍列中包含這些元資料。

同時也存在死元組,即先前刪除但尚未物理移除的列,Postgres 執行器在回傳結果前仍須掃描它們。雖然只看到三筆活躍列,但執行器掃描了更多列。

這種情況不只發生在堆頁,任何索引都會保持葉節點排序,每個條目指向堆頁的 ctid。索引掃描會跟隨這些指標並檢查堆頁。當葉節點條目仍存在但對應堆頁列已死時,掃描會浪費資源。概念上(最壞情況為清理未完成):

在我們的假設規模下,三筆工作和六筆死元組無礙。

但若資料庫無法比工作負載產生死元組的速度更快回收,將注定失敗。調校良好且資源充足的 Postgres 叢集可處理每秒數萬筆工作隊列吞吐量。那麼,表膨脹的原因是什麼?

通常是高寫入頻繁度(快速插入、更新、刪除循環)超過 autovacuum 處理速度。但 autovacuum 落後不僅是吞吐量問題。即使 autovacuum 頻繁執行,也無法移除仍對活躍交易可見的死元組。

有幾種常見情況會使 autovacuum 無法有效清理死元組。

某些表鎖會阻礙清理,不當的 autovacuum 設定也會降低清理效率。

在健康資料庫中,autovacuum 會定期運行並清理死元組。

最常見的阻礙是活躍交易阻止死元組成為可回收狀態。Postgres 不會清理任何可能對活躍交易可見的死元組。最舊的活躍交易設定了所謂的「MVCC 地平線」。在該交易完成前,所有比其快照更新的死元組都會被保留。

一個持續兩分鐘的交易會將地平線固定兩分鐘。

另一種導致相同失效模式的工作負載是多個重疊查詢,單獨不長,但持續存在,讓地平線持續被固定。

例如,三個分析查詢各執行 40 秒,錯開 20 秒開始。單一查詢不會因執行過久而超時,但因為總有一個查詢在執行,地平線永遠無法前進,對 vacuum 的影響等同於一個永遠不結束的交易。

若資料庫僅有工作隊列,這種情況不太可能。但你遵循「只用 Postgres」哲學,擁有多重重疊工作負載,各自有優先順序,需避免互相干擾。問題不在於 Postgres 不適合工作隊列或無法快速完成工作,而是這些快速工作及其迅速累積的死元組因其他同時執行的較慢查詢而無法及時清理。

多年來,Postgres 新增工具簡化隊列性能維護。

如前所述,你可調整 autovacuum 設定(如 autovacuum_vacuum_cost_delay 和 autovacuum_vacuum_cost_limit)提升清理頻率與效率。但在我們的情境中,問題不在隊列吞吐量,而是其他工作負載對其的負面影響。

為防止長時間查詢持續執行,有多種逾時設定選項:

但這些都無法解決我們的問題。它們只能限制單一查詢的執行時間,無法限制並發數或執行成本。我們需要防止任何工作負載持續固定 MVCC 地平線。

需要的是能區分不同「類別」流量的工具,讓高優先工作負載不受影響,並限制低優先工作負載的資源使用速率。

Traffic Control 是 PlanetScale 開發的 Insights 擴充套件的一部分,專為 PlanetScale Postgres 提供。它適合需要細緻控制查詢執行與資源消耗的場景。

Traffic Control 中被資源預算限制的查詢會被分配有限資源,超過限制後可能被阻擋。

解決方案是限制重疊較慢查詢的執行頻率及同時執行數。逾時設定無法提供如此細緻控制。限制這些查詢後,可確保 autovacuum 有更大機會以可接受速度清理死元組。

因為解決方案涉及終止某些查詢,應用必須包含重試邏輯。資料庫不是做越少工作越好,而是要平滑工作執行速率,同時完成相同工作量。

在我們的應用中,被阻擋的查詢不會永久拒絕,而是在適當時機重試。

本文靈感來自內部討論是否應將工作隊列放在 Postgres 資料庫,並分享了以下文章。

2015 年,Brandur Leach 發表《Postgres Job Queues & Failure By MVCC》,記錄了 Postgres 支援的工作隊列中一種災難性失效模式。該文也提供測試平台,展示未關閉交易如何固定 MVCC 地平線並阻礙清理。

幸運的是,原始測試平台仍可取得,我們可用它驗證所學。

自 2015 年以來變化甚大。我嘗試用 Postgres 18 重現相同工作負載,看看是否能複製問題。

原測試需 Ruby、Que gem(v0.x),且在 Postgres 9.4 測試。直接執行會測試十年前的函式庫在現代 Postgres 上的表現,非現代 Postgres 的模式。為了隔離 SQL 行為並易於理解,我用 TypeScript 和 Bun 重寫測試。

簡言之,我維持與 Que 相同的遞迴 CTE 模式,使用相同結構、生產速率、工作時間、工作者數量及長時間運行模式。執行於 PlanetScale PS-5 叢集(起價每月 5 美元)。

結果可見但可控的性能下降。原測試 15 分鐘內讓資料庫陷入死循環,我的 PS-5 在同時間內保持工作隊列接近零。但死元組數線性增長,顯示長期仍會遇到問題。新版本 Postgres(部分因 B-tree 索引清理)緩解但未消除問題。

接著我想知道新版 Postgres 是否提升性能,能否解決原問題。2026 年有兩項改進是 2015 年沒有的。

其他條件不變:8 個工作者、50 jobs/sec 生產速率、10ms 工作時間、45 秒後啟動長時間運行者。結果如下:

性能下降曲線幾乎相同。這些更新未影響 MVCC 退化,因兩者掃描相同 B-tree 索引並遇到相同死元組。

主要改進是吞吐量差異,但這反映測試設計而非鎖定策略。50 jobs/sec 生產速率下,CTE 工作者獨立搶工作,速度超過生產者;批次工作者排空隊列並休眠。兩版本均未受壓力。

總結來說,十年前設計的 Postgres 支援隊列,能在 15 分鐘內毀掉資料庫,現在可存活更久,但原問題依然存在。現代 Postgres 提升了底線但未移除上限。若生產速率改為 500 jobs/sec,問題會更快發生,性能下降,應用受影響。

Traffic Control 的資源預算提供多種調節手段,管理目標查詢可用資源:

資源預算可設定一種或多種限制,防止特定工作負載消耗過多資源,影響其他工作負載。

查詢通常透過 SQLCommenter 標籤中的元資料被標記。以本文範例,分析查詢標記為 action=analytics。

因 idle_in_transaction_session_timeout 可終止原基準測試中的「長時間運行」閒置交易,我改用更真實的生產場景觸發退化:多個重疊分析查詢持續開啟交易並執行工作,這類查詢無法用會話逾時輕易終止。

為展示 Traffic Control 抑制退化的效果,我將所有 action=analytics 查詢的最大並行工作者數限制為 1(佔 max_worker_processes 的 25%),確保同時只允許一個分析查詢執行。

為使系統壓力足以在 15 分鐘內產生死循環,我將生產速率提高到 800 jobs/sec。

我在同一 EC2 主機上對同一 PlanetScale 資料庫執行兩次「強化」工作負載:

結果證明能解決核心清理問題。

Traffic Control 能針對特定工作負載限制並行度,這是 autovacuum 調整或逾時設定無法做到的。分析報告仍依容量執行,15 分鐘內完成 15 次。雖然分析查詢完成時間拉長,但隊列始終保持健康。

Postgres 支援的 MVCC 死元組問題並非 2015 年的歷史遺留。現代 Postgres 提升了門檻——B-tree 改進與 SKIP LOCKED 提供顯著緩衝——但底層機制未變。死元組累積因 VACUUM 無法清理,而 VACUUM 無法清理是因長時間或重疊交易固定 MVCC 地平線。

在「只用 Postgres」的世界裡,隊列、分析與應用邏輯共用同一資料庫,這不是理論風險,而是常態。危險不在於劇烈崩潰,而是無聲的性能退化,鎖定時間增加,工作變慢,卻無警報。

Postgres 提供基於逾時的工具,但無法區分工作負載類別或限制並行度。若你同時運行隊列與其他工作負載,最有效的做法是確保 VACUUM 能跟上。Traffic Control 讓這件事變得簡單。