自旋鎖#
為保護共享記憶體中的資料結構,PostgreSQL 使用數種比一般重量鎖更輕、更廉價的鎖。
最簡單的是自旋鎖(spinlock)。它們通常只被取得極短的時間(不超過數個 CPU 週期),用來保護特定記憶體單元不被並行更新。
自旋鎖的特性:
- 建立在原子 CPU 指令之上(如 compare-and-swap)
- 只支援排他鎖定模式
- 若所需資源已被鎖住,行程會忙碌等待、反覆執行該指令(在迴圈中「打轉」,名稱由此而來)
- 若在指定時間內取不到鎖,行程暫停一會兒,再開始另一輪迴圈
這個策略的前提是:衝突機率被估計為極低,因此一次失敗的嘗試之後,鎖很可能在幾條指令內就取得。
自旋鎖既無死結偵測,也無監測工具。就實務角度而言,我們只需知道它們存在即可——其正確實作的全部責任在 PostgreSQL 開發者身上。
輕量鎖#
其次是所謂的輕量鎖(lightweight lock,lwlock)。它們被取得的時間為處理某個資料結構(例如雜湊表或指標清單)所需的長度,通常很短;但用於保護 I/O 操作時可能較長。
輕量鎖支援兩種模式:
- 排他(用於資料修改)
- 共享(用於唯讀操作)
在有大量並行行程的高負載系統中,這可能導致一些不愉快的效應。
輕量鎖同樣不提供死結檢查,我們得信任 PostgreSQL 開發者的正確實作。不過它們確實有監測工具,因此與自旋鎖不同,它們是可以被觀察的。
實例#
以下透過兩個共享記憶體結構——緩衝快取與 WAL 緩衝區——說明自旋鎖與輕量鎖如何被使用。
這裡只點名其中一部分鎖;完整圖像過於複雜,大概只有 PostgreSQL 核心開發者會感興趣。
緩衝快取#
| 鎖 | 類型 | 保護對象 |
|---|---|---|
| BufferMapping | 輕量鎖(128 個一組) | 用來在快取中定位特定緩衝區的雜湊表 |
| buffer header | 自旋鎖 | 緩衝區標頭 |
| BufferContent | 輕量鎖 | 緩衝區中頁面的內容 |
| BufferIO | 屬性(作為鎖使用) | 磁碟讀寫進行中的標示 |
| buffer strategy | 自旋鎖 | 空閒緩衝區指標與淘汰機制的時鐘指標 |
BufferMapping:行程存取雜湊表時必須取得此鎖——讀取用共享模式,預期有修改時用排他模式。
雜湊表被存取得非常頻繁,因此這個鎖經常成為瓶頸。為了最大化粒度,它被結構化成一組(tranche)128 個獨立的輕量鎖,各自保護雜湊表的不同部分。
早在 2007 年的 PostgreSQL 8.2,雜湊表鎖就被改成 16 個鎖的一組;十年後 9.5 版發行時組的大小增加到 128——但對現代多核心系統而言可能仍然不夠。

圖 15-1:緩衝快取上的各種鎖——BufferMapping 組、緩衝區標頭自旋鎖、BufferContent 與緩衝區 pin
buffer header(自旋鎖):取得緩衝區標頭的存取權時使用。
某些操作(例如遞增使用計數器)不需要明確的鎖,可用原子 CPU 指令完成。
BufferContent:讀取緩衝區中的頁面時取得。通常只在讀取元組指標期間持有;之後由緩衝區 pin 提供的保護就足夠了。若需修改緩衝區內容,則必須以排他模式取得。
BufferIO:緩衝區從磁碟讀取(或寫入磁碟)時取得。它實質上是一個被當作鎖使用的屬性,而非真正的鎖——它向其他請求存取該頁面的行程發出訊號:必須等待 I/O 操作完成。
WAL 緩衝區#
WAL 快取也使用雜湊表把頁面對應到緩衝區。
| 鎖 | 類型 | 保護對象 |
|---|---|---|
| WALBufMapping | 輕量鎖(單一) | WAL 快取的雜湊表 |
| WALWrite | 輕量鎖 | WAL 頁面寫入磁碟 |
| insert position | 自旋鎖 | 插入指標(空間預留) |
| WALInsert | 輕量鎖(8 個一組) | 已預留空間的填寫 |
與緩衝快取的雜湊表不同,WAL 的雜湊表只由單一 WALBufMapping 鎖保護——因為 WAL 快取較小(通常為緩衝快取大小的 1/32),且緩衝區存取更為有序。
WALWrite 確保 WAL 頁面的磁碟寫入一次只由一個行程執行。
建立 WAL 條目分兩步:
- 預留空間——行程先在 WAL 頁面中預留一塊空間。這一步嚴格有序,必須取得保護插入指標的 insert position 自旋鎖
- 填入資料——空間一旦預留完成,就可由多個並行行程同時填寫。為此每個行程必須取得構成 WALInsert 組的八個輕量鎖中的任何一個

圖 15-2:WAL 快取上的鎖——insert position 自旋鎖保護空間預留,WALInsert 組保護填寫,WALWrite 保護磁碟寫入
監控等待#
鎖對 PostgreSQL 的正確運作不可或缺,但它們可能導致不樂見的等待。追蹤這些等待、理解其來源是有用的。
log_lock_waits#
取得長期鎖概覽最簡單的方法,是開啟 log_lock_waits 參數(預設 off)。它啟用對「所有造成交易等待超過 deadlock_timeout 的鎖」的詳盡記錄。
這些資料是在死結檢查完成時才顯示的——參數名稱正由此而來。
pg_stat_activity#
pg_stat_activity 視圖提供更有用、更完整的資訊。每當某個行程(系統行程或 backend)因等待某事而無法繼續其任務,這個等待就反映在 wait_event_type 與 wait_event 欄位中,分別顯示等待的類型與名稱。
鎖相關的等待構成相當大的一類:
| 類型 | 意義 |
|---|---|
Lock | 重量鎖 |
LWLock | 輕量鎖 |
BufferPin | 被 pin 住的緩衝區 |
行程也可能在等待其他事件:
| 類型 | 意義 |
|---|---|
IO | 輸入/輸出,需要讀寫資料時 |
Client | 客戶端送來的資料(psql 大部分時間處於這個狀態) |
IPC | 另一個行程送來的資料 |
Extension | 由擴充註冊的特定事件 |
有時行程只是沒在做有用的工作。這類等待通常是「正常的」,不代表有任何問題:
| 類型 | 意義 |
|---|---|
Activity | 背景行程處於其主迴圈中 |
Timeout | 計時器 |
每種等待類型下再依等待名稱細分。例如輕量鎖上的等待,會取得該鎖或對應組的名稱。
必須留意:
pg_stat_activity只顯示那些在原始碼中被適當處理的等待。若某個等待名稱沒出現在這個視圖中,代表該行程不處於任何已知類型的等待狀態。這段時間應視為未被計入的時間——這不必然表示該行程沒在等任何東西,只是我們不知道當下究竟發生了什麼。
=> SELECT backend_type, wait_event_type AS event_type, wait_event
FROM pg_stat_activity;
backend_type | event_type | wait_event
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−−
logical replication launcher | Activity | LogicalLauncherMain
autovacuum launcher | Activity | AutoVacuumMain
client backend | |
background writer | Activity | BgWriterMain
checkpointer | Activity | CheckpointerMain
walwriter | Activity | WalWriterMain
(6 rows)此處所有背景行程在取樣時都閒置,而 client backend 正忙於執行查詢、沒在等任何東西。
取樣#
必須考量取樣的隨機性質:
- 等待相對於取樣間隔越短,偵測到它的機會就越低
- 因此較長的取樣間隔需要更多樣本才能反映實際狀況(但提高取樣率也會增加開銷)
- 基於同樣理由,取樣對分析短命的 session 幾乎無用
PostgreSQL 沒有內建的取樣工具,但可用 pg_wait_sampling 擴充。使用方式是把它的函式庫指定在 shared_preload_libraries 並重啟伺服器:
=> ALTER SYSTEM SET shared_preload_libraries = 'pg_wait_sampling';
=> CREATE EXTENSION pg_wait_sampling;這個擴充能顯示保存在其環形緩衝區中的等待歷史,但更有趣的是取得等待剖析圖(waiting profile)——整個 session 期間累積的統計。
實例:正常磁碟 vs. 人工放慢的檔案系統#
跑 60 秒 pgbench 後的等待剖析:
=> SELECT pid, event_type, event, count
FROM pg_wait_sampling_profile WHERE pid = 36367
ORDER BY count DESC LIMIT 4;
pid | event_type | event | count
−−−−−−−+−−−−−−−−−−−−+−−−−−−−−−−−−−−+−−−−−−−
36367 | IO | WALSync | 3478
36367 | IO | WALWrite | 52
36367 | Client | ClientRead | 30
36367 | IO | DataFileRead | 2
(4 rows)預設每秒取樣 100 次(由
pg_wait_sampling.profile_period設定,預設 10 ms)。因此要以秒為單位估算等待時長,把count除以 100。
此例中多數等待與「把 WAL 條目刷到磁碟」有關。
這也很好地說明了未被計入的等待時間:
WALSync事件直到 PostgreSQL 14 才被加上監測;在更低的版本中,等待剖析不會出現第一行——儘管那個等待確實存在。
把檔案系統人工放慢到每次 I/O 需 0.1 秒後,剖析變成:
pid | event_type | event | count
−−−−−−−+−−−−−−−−−−−−+−−−−−−−−−−−−−−−−+−−−−−−−
36747 | IO | WALWrite | 3603
36747 | LWLock | WALWrite | 2095
36747 | IO | WALSync | 22
36747 | IO | DataFileExtend | 19
(4 rows)現在最慢的是 I/O 操作——主要是同步模式下把 WAL 檔案寫到磁碟的那些。由於 WAL 寫入受 WALWrite 輕量鎖保護,對應的那一行也出現在剖析中。
顯然前一個例子中也會取得同一個鎖,但由於等待比取樣間隔還短,它要不是被取樣到極少次,就是根本沒進入剖析。
這再次說明:要分析短暫的等待,你必須取樣相當長的時間。