導語:PostgreSQL 18 中最大的變化是引入了異步 I/O (AIO) 子系統,這引出了一個問題:如何根據工作負載調整它?Tomas Vondra 這篇博客提供瞭如何設置 AIO 配置,並根據你的工作負載進行測試的實用指南。
PostgreSQL 18 已正式發佈,該版本包含大量改進。其中一項重大架構變更是異步 I/O(Asynchronous I/O,簡稱 AIO) ——它支持對 I/O 操作進行異步調度,使數據庫能更好地控制存儲資源,同時提升存儲利用率。
本文不會詳細解釋 AIO 的工作原理,也不會展示詳盡的基準測試結果。本文的核心目標是分享 PostgreSQL 18 中 AIO 的調優建議,並解釋其中一些固有的但卻不顯而易見的權衡關係與限制。
理想情況下,這些調優建議應被納入官方文檔,但這需要基於實踐經驗形成明確共識——而 AIO 作為全新特性,目前尚缺乏足夠的生產環境驗證數據。儘管在開發階段我們已開展了大量基準測試,並據此設定了默認參數,但這無法替代實際生產系統的運行經驗。因此,本文將基於個人經驗,探討如何(可能)調整默認參數,以及在此過程中需權衡的因素。
io_method / io_workers
有一系列與 AIO(或廣義上的 I/O)相關的參數。但您可能只需要關注 Postgres 18 中引入的這兩個:
- io_method = worker (options: sync, io_uring)
- io_workers = 3
其他參數(如 io_combine_limit)都有合理的默認值。對於如何調整它們,我沒有太好的建議,所以暫時保持默認即可。在本文中,我將重點討論這兩個重要的參數。
io_method
io_method 決定了 AIO 實際處理請求的方式——由哪個進程執行 I/O,以及 I/O 是如何被調度的。它有三個可能的值:
sync- 這是一個"向後兼容"選項,在支持的情況下使用posix_fadvice進行同步 I/O。這會將數據預取到頁面緩存中,而不是共享緩衝區裏。worker- 創建一個"I/O 工作進程"池來執行實際的 I/O。當一個後端進程需要從數據文件中讀取一個塊時,它會將一個請求插入到共享內存中的隊列裏。一個 I/O 工作進程被喚醒,執行pread操作,將數據放入共享緩衝區,並通知後端進程。io_uring- 每個後端進程都有一個io_uring實例(一對隊列)並使用它來執行 I/O。與worker的不同之處在於,它不是直接執行pread,而是通過io_uring提交請求。
默認值是 io_method = worker。我們確實考慮過將 sync 或 io_uring 都設為默認值,但我認為 worker 是正確的選擇。它是真正"異步"的,並且隨處可用(因為這是我們自己的實現)。
sync 曾被視為一種"回退"選擇,以防我們在 beta/RC 階段遇到問題。但我們並沒有遇到問題,而且也不確定使用 sync 是否真的會有幫助,因為它仍然要經過 AIO 基礎設施。如果您希望模擬舊版本的行為,仍然可以使用 sync。
io_uring 是一種流行的異步 I/O 方式(不僅僅是磁盤 I/O)。它非常出色,高效且輕量級。但它是 Linux 特有的,而我們需要支持眾多平台。我們本可以使用特定於平台的默認值(類似於 wal_sync_method),但這似乎帶來了不必要的複雜性。
注意: 即使在 Linux 上,也很難驗證io_uring。一些容器運行時(例如 containerd)之前因為安全風險而禁用了io_uring支持。
沒有任何一個 io_method 選項是"普遍最優的"。總會存在某些工作負載下 A 優於 B,反之亦然。最終,我們希望大多數系統都能使用 AIO 並從中受益,同時我們希望保持簡單,所以選擇了 worker。
💡建議: 我的建議是堅持使用 io_method = worker,並調整 io_workers 的值(如下一節所述)。
io_workers
Postgres 的默認配置非常保守。它甚至可以在像樹莓派這樣的小型機器上啓動。但另一方面,對於通常擁有更多 RAM/CPU 的典型數據庫服務器來説,這種保守配置的表現就很糟糕了。要在這樣的大型機器上獲得良好的性能,您需要調整一些參數(shared_buffers, max_wal_size 等)。
我希望我們能有一種自動化方法來為這些基本參數選擇"合適"的初始值,但這比看起來要困難得多。這在很大程度上取決於上下文(例如,同一系統上可能還在運行其他東西)。不過,至少現在有像 PGTune 這樣的工具,能為這些參數提供合理的推薦值。
這當然也適用於 io_workers = 3 這個默認值,它只創建 3 個 I/O 工作進程。對於擁有 8 個核心的小型機器來説,這可能沒問題,但對於 128 個核心的機器來説,這絕對是不夠的。
實際上,我可以通過一個基準測試的結果來證明這一點,這個測試是我為選擇 io_method 默認值而進行的。該基準測試生成了一個合成數據集,然後運行匹配部分數據的查詢(同時強制使用特定的掃描類型)。
注意: 該基準測試(連同腳本、大量結果和更詳細的解釋)最初在關於 io_method 默認值的 pgsql-hackers 郵件列表線程中分享。請查看該線程以獲取更多細節和其他人的反饋。展示的結果來自一台搭載 Ryzen 9900X(12 核/24 線程)和 4 塊 NVMe SSD(配置為 RAID0)的小型工作站。
以下是對比不同 io_method 選項查詢耗時的圖表 PDF 文件:
每種顏色代表不同的 io_method 值(17 代表 "Postgres 17")。對於 "worker" 有兩種數據序列,對應不同數量的工作進程(3 和 12)。這是針對兩個數據集的:
uniform- 均勻分佈(因此 I/O 完全是隨機的)linear_10- 順序分佈帶有一點隨機性(不完美的相關性)
圖表顯示了一些非常有趣的現象:
- 索引掃描 :
io_method沒有影響,這很好理解,因為索引掃描尚未使用 AIO(所有 I/O 都是同步的)。 - 位圖掃描 : 行為更加混亂。
worker方法表現最好,但僅限於有 12 個工作進程時。使用默認的 3 個工作進程時,對於低選擇性的查詢,它的性能實際上很差。 - 順序掃描 : 不同方法之間存在明顯差異。
worker是最快的,比sync(和 PG17)快大約一倍。io_uring介於兩者之間。
在縱軸(y 軸)採用對數刻度的圖表中 PDF 文件,使用 3 個 I/O 工作進程(io_workers=3)的 worker 模式在 bitmap 掃描(位圖掃描)場景下的性能劣勢更為明顯:
io_workers=3 的配置始終是最慢的(在線性圖表中幾乎難以察覺這一點)。
好的一面是,雖然 I/O 工作進程不是免費的,但它們的開銷也不算高。因此,即便工作進程數量偏多,通常也比數量不足要好。
未來,我們可能會根據需求啓動/停止工作進程,使其變得"自適應"。這樣我們就能始終保持最優的進程數量。目前甚至已經有一個進行中的補丁,但它未能納入 Postgres 18。
建議: 考慮增加 io_workers。目前尚無理想的推薦值或計算公式,或許設置為核心數的 1/4 是個可行的選擇?
權衡
放之四海而皆準的最優配置是不存在的。我曾見過"使用 io_uring 以獲得最高效率"的建議,但前面的基準測試清楚地表明,對於順序掃描,io_uring 明顯比 worker 慢。
別誤會,我本身認可 io_uring,它確實是一個出色的接口,而且上述建議也並非"錯誤"。任何性能調優建議本質上都是一種簡化表達,總會存在與之相悖的情況。現實世界從不像建議描述的那樣簡單:這類建議的核心意義,就在於用一條簡潔的規則,掩蓋背後繁雜的複雜細節。
那麼,這些異步 I/O(AIO)方式之間,究竟存在哪些權衡與差異呢?
帶寬
io_uring 和 worker 之間的一個重大區別在於任務的執行位置。對於 io_uring,所有任務都在後端進程內部執行;而對於 worker,這些任務會在獨立的進程中進行。
這可能會對帶寬產生一些值得關注的影響,具體取決於處理 I/O 的開銷大小。而這個開銷可能相當高,因為它涉及:
- 實際的 I/O 操作
- 校驗和驗證(在 Postgres 18 中默認啓用)
- 將數據複製到共享緩衝區
對於 io_uring,所有這些都發生在後端進程本身。I/O 部分可能更高效,但校驗和驗證與內存複製(memcpy)這兩個步驟卻可能成為性能瓶頸。對於 worker,這項工作實際上在工作進程之間分配。如果您有 1 個後端進程和 3 個工作進程,限制就提高了 3 倍。
當然,反之亦然。如果有 16 個連接,那麼對於 io_uring,就是 16 個進程可以驗證校驗和等等。對於 worker,限制就是 io_workers 設置的值。
這就是我建議將 io_workers 設置為核心數約 25% 的原因。我認為還可以設得更高,可能達到每個核心一個 I/O 工作進程。無論如何,3 看起來明顯太低了。
注意: 我相信這種將開銷分散到多個進程的能力,是worker在順序掃描上優於io_uring的原因。在本次基準測試中,約 20% 的差異對於校驗和內存複製來説似乎是合理的。
信號
另一個重要的細節是後端進程與 I/O 工作進程之間進程間通信的開銷,這是基於 UNIX 信號的。一次 I/O 操作的執行流程如下:
- 後端進程將一個讀取請求添加到共享內存的隊列中
- 後端進程向一個 I/O 工作進程發送信號,將其喚醒
- I/O 工作進程執行後端請求的 I/O,並將數據複製到共享緩衝區
- I/O 工作進程向後端進程發送信號,通知其 I/O 已完成
在最壞情況下,這意味着每處理一個 8KB 大小的數據塊,就需要完成一次 “雙向信號傳輸”(共 2 次信號交互)。問題在於,信號傳輸並非 “零成本”—— 一個進程每秒能處理的信號數量是有限的。
我寫了一個簡單的基準測試,用於測試兩個進程之間的信號傳遞性能。在我的機器上,測試結果顯示能達到每秒 25 萬至 50 萬次往返通信。如果每個 8KB 的數據塊都需要一次往返通信,這意味着傳輸速度僅為 2-4GB/s。這並不算快,尤其是考慮到數據可能已經在頁面緩存中,而不僅僅是從存儲中讀取的冷數據。根據一項從頁緩存複製數據的測試,一個進程可以達到 10-20GB/s 的速度,大約是信號傳遞方式的 4 倍。顯然,信號可能會成為一個性能瓶頸。
注意: 具體的限制因硬件而異,在老舊的機器上可能會低得多。但在我能訪問的所有機器上,這個普遍觀察結果都成立。
不過好消息是,這隻會影響"最壞情況"的工作負載,即需要逐個讀取 8KB 頁面。大多數常規工作負載並非如此。後端進程通常會在共享內存中找到很多緩衝區(因此不需要 I/O)。或者,由於預讀,I/O 以更大的塊發生,這將信號開銷分攤到了多個數據塊上。因此,我不認為這會成為一個嚴重的問題。
在關於索引預取的郵件列表線程中,有關於 AIO 開銷(不僅僅是由於信號)的更長時間討論。
關於異步 I/O(AIO)的開銷(不僅限於信號帶來的開銷),在 “索引預取”的郵件列表線程中還有更深入的探討。
文件限制
io_uring 無需任何進程間通信(IPC),因此它不受信號開銷或類似問題的影響。但 io_uring 同樣存在限制,只是限制點有所不同。
例如,每個進程都會受到 “進程級帶寬限制”(比如單個進程最多能執行多少內存複製操作)。但根據頁面緩存測試判斷,這些限制相當高——大約 10-20GB/s。
另一個需要考慮的問題是,io_uring 可能需要相當多的文件描述符。正如這個 pgsql-hackers 線程中所解釋的:
問題在於,使用io_uring時,我們需要為每個可能的子進程創建一個文件描述符(FD),以便一個後端進程可以等待由另一個後端進程發起的 I/O 完成。這些io_uring實例需要在主進程中創建,以便所有後端進程都能訪問。顯然,如果max_connections設置得較高,這有助於更快地達到未調整的軟性RLIMIT_NOFILE限制。
因此,如果您決定使用 io_uring,您可能也需要調整 ulimit -n。
注意: 這並非 Postgres 代碼中您可能遇到文件描述符限制的唯一地方。大約一年前,我提了一個與文件描述符緩存相關的補丁構想。每個後端進程最多保持 max_files_per_process 個打開的文件描述符,默認情況下該 GUC 設置為 1000。這在過去是足夠的,但在使用分區(或按租户的模式)的情況下,很容易觸發大量頻繁且開銷較高的打開/關閉調用。那是一個獨立但類似的問題。
總結
AIO 是 PostgreSQL 18 的一項重大架構變更,但目前仍存在侷限性:僅支持讀操作,部分操作仍依賴舊的同步 I/O 機制。這些限制並非永久性的,預計將在未來版本中逐步解決。
基於本文的分析,最終的 AIO 調優建議如下:
- 保留
io_method = worker的默認值:除非通過基準測試證明io_uring對您的工作負載更優,否則不建議切換。僅在需要模擬 PostgreSQL 17 行為時使用sync(即使這可能導致部分場景性能下降)。 - 根據 CPU 核心數調整
io_workers:建議從核心數的 25% 開始配置,在 I/O 密集場景下可嘗試提高至 100%。
若您在調優過程中發現有趣的結論,歡迎將其反饋給作者,更推薦發佈到 pgsql-hackers 郵件列表。這些經驗將幫助官方完善未來文檔中的調優建議。