本文描述了一起真實的PostgreSQL生產環境事故,該事故由交易ID回繞引起,這是一種既無聲又嚴重的故障模式。該事件最終導致完全的寫入中斷。故障並非立即發生於配置變更後,也不是由高負載、流量增長或基礎設施問題觸發。

在PostgreSQL中,每個寫入交易都會被分配一個交易ID(XID)。這些交易ID來自一個有限的全局計數器,隨著交易執行不斷遞增。為了安全地重複使用交易ID,PostgreSQL要求定期對舊的行版本進行凍結。凍結會將資料標記為永久可見,防止交易ID無限老化。如果凍結未及時進行,交易ID將繼續接近一個硬性安全限制。

交易ID回繞的危險不僅在於其存在,更在於其表現方式。資料庫可以在無聲無息中運行數月甚至數年,逐漸接近限制。CPU使用率看似正常,I/O模式穩定,查詢效能無明顯下降,沒有類似傳統容量或效能問題的警告跡象。

當安全限制最終達到時,PostgreSQL無法安全地繼續接受寫入交易,否則將有資料損壞風險。此時,PostgreSQL會故意阻止所有寫入操作,資料庫實際上變為唯讀,恢復工作從例行調優轉為緊急運維事件。本文描述的事件發生在一個工作負載穩定且適中的環境中,沒有流量激增、異常查詢行為或停機前的運維變更。故障僅因長時間未完成交易ID凍結而發生。

值得注意的是,這類故障無法通過短期模擬或傳統效能測試有效重現。交易ID回繞受累積交易數和資料長期老化影響,大多數環境中只有經過數月或數年正常運行後才會出現,容易在測試、預備驗證或初期生產部署時被忽略。

本文旨在解釋交易ID回繞是什麼、為何在生產環境中特別危險、過去的配置決策如何悄然導致此問題,以及理解交易ID運算對安全運行PostgreSQL的重要性。

PostgreSQL使用多版本並發控制(MVCC)管理資料的並發存取。每次變更不直接覆寫行,而是創建新版本,舊版本保留直到不再需要。為判斷可見的行版本,PostgreSQL為每個寫入交易分配交易ID(XID),該ID與行版本一同存儲,讀取時與交易快照比較。交易ID在整個PostgreSQL集群中全局唯一,從32位計數器順序分配,總XID空間有限。

雖然計數器理論上可表示約40億個值,但PostgreSQL設定了更早的安全邊界。當最老未凍結交易ID的年齡接近約20億時,系統被視為不安全。

為防止交易ID重用導致資料損壞,PostgreSQL依賴凍結機制。在vacuum操作中,足夠老舊的行版本會被替換為特殊的凍結標記,使其永久可見且安全。如果凍結未及時發生且達到20億閾值,PostgreSQL會故意阻止所有寫入操作。INSERT、UPDATE、DELETE及多數DDL命令將失敗,資料庫實際變為唯讀,直到問題解決。

此行為是刻意且保護性的。系統可能健康運行數月或數年,但一旦超過限制,故障將突然且絕對發生。

該系統屬於一家中型B2B SaaS組織。PostgreSQL是主要的OLTP資料庫,已運行多年。資料庫管理主要由開發團隊負責,包括結構變更、效能調優及大部分配置決策。由於負載穩定且適中,這種管理方式運作良好。應用負載特徵無異常:

從監控儀表板看,系統健康,CPU、記憶體及I/O使用均在正常範圍內。

我加入組織前數月,團隊曾遇到與磁碟I/O相關的效能問題。事件期間可見autovacuum活動,當時認為其可能是問題原因。作為臨時緩解措施,部分表格及更廣範圍內禁用了autovacuum。立即效能問題解決,系統穩定,開發繼續。

然而,autovacuum從未重新啟用。隨時間推移,這種配置被視為正常。資料庫繼續運作,未立即出現問題。禁用autovacuum的長期影響未被積極考慮。

我加入時,無活躍資料庫事件。接手核心資料庫職責後,初期重點是基礎運維工作:驗證備份、測試還原程序、審查災難復原準備、檢查複寫與故障轉移行為。這些都是必要且正確的優先事項。資料庫多年運行無明顯問題,無立即信號顯示根本錯誤。

交易ID回繞健康狀況在初期未被檢查。

約一個月後,事件發生。應用團隊報告寫入操作失敗,INSERT、UPDATE及DDL語句均失敗。

PostgreSQL進入交易ID回繞保護模式,資料庫實際唯讀。此時系統已過警告階段,PostgreSQL已故意保護資料正確性。

當務之急是恢復寫入能力。這非調優,而是復原操作。工作包括識別並終止持有舊快照的長時間或閒置交易,接著對受影響最嚴重的表格進行積極手動VACUUM FREEZE。

過程緊張且複雜。凍結必須足夠推進以超越危險區,同時保持系統穩定。最終完成足夠凍結,寫入恢復,系統穩定。

事件解決後,明確此非偶發問題。根本原因非近期變更,而是多年以前的配置決策。我開始檢查同團隊管理的其他PostgreSQL生產系統。第一個查詢結果令人擔憂:多個生產資料庫中多個表格交易ID年齡極高,且多數明確禁用autovacuum。此刻,情況嚴重性顯現,初次事件非運氣不佳,而是早期警告。

正常運作中,PostgreSQL依賴autovacuum防止交易ID回繞。autovacuum定期掃描表格,移除死行版本並凍結舊行,確保交易ID不無限老化。

PostgreSQL中每個表格追蹤一個稱為relfrozenxid的值,代表該表中最老未凍結的交易ID。隨著集群交易持續,relfrozenxid年齡增加,除非vacuum成功凍結舊行版本。可用查詢觀察交易ID老化狀態。

為防止交易ID重用,PostgreSQL實施硬性安全閾值,由參數控制,預設為2億。當表格relfrozenxid年齡接近此限制且凍結無法推進時,PostgreSQL故意阻止寫入操作以防資料損壞。

若禁用或阻止autovacuum有效運行,凍結不會自動發生。交易ID持續無聲老化,直到達安全閾值,問題突然以生產停機形式浮現。

交易ID消耗僅依賴寫入速率與時間。該系統中:

每日消耗交易ID約864,000,PostgreSQL回繞安全閾值為2億,達到回繞風險時間約231天,即約7.5個月。

無需流量激增或增長,僅時間足夠。

事件惡化的一個重要細節是存在不再活躍使用的表格。PostgreSQL中交易ID為整個資料庫全局遞增,但凍結在表格層級處理,此差異易被忽略。

該環境中,數個表格為早期測試或後來放棄的功能所建,因被視為「不活躍使用」,禁用了autovacuum。禁用後,這些表格停止凍結,交易ID狀態停留於最後一次插入或vacuum時點。

同時,其他應用繼續正常運行,其他表格持續寫入,交易ID每日遞增。時間足夠後,其中一個未使用表格成為整個資料庫最老未凍結交易ID持有者,PostgreSQL將其視為整系統回繞風險。

關鍵點簡單:表格不需活躍使用也可能危險,一個被遺忘且禁用autovacuum的測試表即可觸發交易ID回繞。

SQL Server不會遇到此特定故障,因其不依賴有限且全局老化的交易ID。

PostgreSQL中,行版本存交易ID,來自有限全局計數器,必須最終重用,故需凍結。若未及時凍結,PostgreSQL必須停止寫入以避免資料損壞。

SQL Server採用不同內部機制。交易排序與版本可見性基於日誌序列號(LSN),非可重用交易ID。LSN為單調遞增值,由交易日誌產生,不會以造成回繞歧義的方式重用。

SQL Server使用行版本(如快照隔離或讀已提交快照)時,舊版本以LSN追蹤並存於tempdb。清理依賴活躍交易與日誌截斷,而非達到全局識別碼重用限制。若無法清理舊版本,影響為運維問題,如tempdb增長、效能下降或阻塞增加,但不需停止所有寫入以維護正確性。

總之,PostgreSQL在交易ID重用不安全時必須強制停止,而SQL Server透過LSN設計避免此問題。

此次生產事件非因負載、流量增長或低效查詢引起,而是長期未完成交易ID凍結。資料庫多年看似健康,負載穩定,監控無異常,風險隨時間悄然累積。

事件在我接手核心職責後浮現,復原需積極且謹慎介入。後續檢查發現其他系統亦存在相同風險。PostgreSQL按設計行事,透過停止寫入保護資料完整性,避免損壞。

交易ID回繞非邊緣案例,而是忽視或誤解凍結機制的可預期結果。理解交易ID消耗數學,並將autovacuum視為安全機制而非調優選項,對於安全運行PostgreSQL至關重要。