自旋鎖#

為保護共享記憶體中的資料結構,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 條目分兩步:

  1. 預留空間——行程先在 WAL 頁面中預留一塊空間。這一步嚴格有序,必須取得保護插入指標的 insert position 自旋鎖
  2. 填入資料——空間一旦預留完成,就可由多個並行行程同時填寫。為此每個行程必須取得構成 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_typewait_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 輕量鎖保護,對應的那一行也出現在剖析中。

顯然前一個例子中也會取得同一個鎖,但由於等待比取樣間隔還短,它要不是被取樣到極少次,就是根本沒進入剖析。

這再次說明:要分析短暫的等待,你必須取樣相當長的時間。