關於鎖#

(lock)控制對共享資源的並行存取。

並行存取意味著多個行程試圖在同一時間取得同一個資源。這些行程是真正平行執行(若硬體允許)還是以分時模式循序執行,並無差別。

存取資源前,行程必須先取得該資源的鎖;操作完成後必須釋放,資源才對其他行程可用。

  • 若鎖由資料庫系統管理,既定的操作順序會自動被維持
  • 若鎖由應用程式控制,協定就必須由應用程式自行遵守

在低層級,鎖只是一塊定義鎖狀態(是否已被取得)的共享記憶體;它也可以提供額外資訊,例如行程編號或取得時間。

如你所料,共享記憶體區段本身也是一種資源。對這類資源的並行存取,由作業系統提供的同步原語(號誌 semaphore 或互斥鎖 mutex)規範,它們保證存取共享資源的程式碼嚴格循序執行。最底層,這些原語建立在原子 CPU 指令(如 test-and-set 或 compare-and-swap)之上。

一般而言,只要資源能被明確識別並指派一個特定的鎖位址,我們就能用鎖保護它

  • 資料庫物件——表格(由系統目錄中的 oid 識別)、資料頁面(由檔名與檔案內位置識別)、列版本(由頁面與頁內偏移識別)
  • 記憶體結構——雜湊表或緩衝區(由指派的 ID 識別)
  • 甚至是沒有實體表現的抽象資源

但並非總能立刻取得鎖:資源可能已被他人鎖住。此時行程要不加入佇列(若該鎖類型允許),要不稍後重試——無論如何都得等待鎖被釋放。

影響鎖效率的兩個因素#

1. 粒度(granularity)——鎖的「顆粒大小」。當資源形成階層時,粒度就很重要。

例如表格由頁面構成,頁面又由元組構成,這些物件都可以被鎖保護:

  • 表格層級鎖是粗粒度的:即使行程要存取的是不同頁面或不同列,也禁止並行存取
  • 列層級鎖是細粒度的,沒有上述缺點;但鎖的數量會增長

為避免鎖相關中繼資料佔用太多記憶體,PostgreSQL 可套用多種方法,其中之一是鎖升級(lock escalation):細粒度鎖的數量超過某門檻時,就以單一較粗粒度的鎖取代。

2. 鎖可被取得的模式集合

通常只用兩種模式:

  • 排他(exclusive)模式與所有其他模式都不相容,包括它自己
  • 共享(shared)模式允許資源同時被多個行程鎖住

共享模式可用於讀取,排他模式用於寫入。一般來說也可能有其他模式。

更細的粒度與對多種相容模式的支援,帶來更多並行執行的機會。

依持續時間分類#

類型持續時間保護對象特性
長期鎖可能很長(多數情況直到交易結束)關聯、列通常由 PostgreSQL 自動管理,但使用者仍有部分控制權;提供多種模式以支援各種並行操作,並具備完整基礎設施(等待佇列、死結偵測、監測工具)
短期鎖幾分之一秒,很少超過數個 CPU 指令共享記憶體中的資料結構由 PostgreSQL 全自動管理;通常只有極少數模式與最基本的基礎設施,可能完全沒有監測工具

長期鎖的基礎設施雖然龐大,但其維護成本無論如何都遠低於對被保護資料的操作。

PostgreSQL 支援的鎖類型:

  • 長期:重量鎖(heavyweight lock,取得於關聯與其他物件)與列層級鎖
  • 短期:記憶體結構上的各種鎖
  • 此外還有獨立的一組謂詞鎖——儘管名為鎖,它們其實根本不是鎖

重量鎖#

重量鎖屬於長期鎖,在物件層級取得,主要用於關聯,但也可套用於某些其他類型的物件。它們通常保護物件免於並行更新,或在重構期間禁止使用,但也可能滿足其他需求。

這個定義刻意含糊:這類鎖被用於各式各樣的目的,唯一的共通點是它們的內部結構

除非另有明確說明,「鎖」一詞通常指的就是重量鎖

重量鎖位於伺服器的共享記憶體中,可在 pg_locks 視圖中顯示。其總數受限於 max_locks_per_transaction(預設 64)乘以 max_connections(預設 100)。

所有交易共用同一個鎖池,因此單一交易可以取得超過 max_locks_per_transaction 個鎖。真正重要的是系統中的鎖總數不超過所定義的上限。

由於鎖池在伺服器啟動時初始化,修改這兩個參數中的任何一個都需要重啟伺服器

若資源已被以不相容的模式鎖住,試圖取鎖的行程就加入佇列。等待中的行程不浪費 CPU 時間:它們會睡眠,直到鎖被釋放、作業系統喚醒它們。

兩筆交易可能陷入死結(deadlock):第一筆在取得第二筆所持資源之前無法繼續,而第二筆又需要第一筆所持的資源。這只是最簡單的情形,死結也可能牽涉兩筆以上的交易。

由於死結會造成無限等待,PostgreSQL 會自動偵測並中止其中一筆受影響的交易,以確保正常運作能繼續。

pg_locks 視圖 locktype 欄位中的鎖類型名稱:

locktype意義
transactionidvirtualxid交易 ID 上的鎖
relation關聯層級鎖
tuple元組上的鎖
object非關聯物件上的鎖
extend關聯延伸鎖
page某些索引類型使用的頁面層級鎖
advisory諮詢鎖

幾乎所有重量鎖都在需要時自動取得,並在對應交易完成時自動釋放。但也有例外:關聯層級鎖可以明確設定,而諮詢鎖則永遠由使用者管理。

交易 ID 上的鎖#

每筆交易都持有對自己 ID 的排他鎖(虛擬 ID 與真實 ID 皆然,若後者已配發)。

PostgreSQL 為此提供兩種鎖定模式,其相容性矩陣非常簡單:

SharedExclusive
Shared
Exclusive

要追蹤某筆交易是否完成,行程可以以任何模式請求該交易 ID 上的鎖。由於該交易本身已持有對自己 ID 的排他鎖,這個鎖不可能取得,請求的行程於是加入佇列並睡眠。

一旦該交易完成,鎖被釋放,排隊的行程醒來。它顯然不會真的取得鎖(對應的資源已經消失),但那個鎖本來就不是它真正想要的東西——它要的是「知道交易結束了」。

實驗:開啟交易後,它持有對自己虛擬 ID 的排他鎖;取得真實 ID 後,對應的鎖也被加入清單:

=> SELECT locktype, virtualxid, transactionid AS xid, mode, granted
FROM pg_locks WHERE pid = 28980;
   locktype    | virtualxid | xid    |     mode      | granted
−−−−−−−−−−−−−−−+−−−−−−−−−−−−+−−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−−−
 virtualxid    | 5/2        |        | ExclusiveLock | t
 transactionid |            | 122849 | ExclusiveLock | t
(2 rows)

其中 granted 旗標顯示所請求的鎖是否已被取得。

關聯層級鎖#

PostgreSQL 為關聯(表格、索引或任何其他物件)提供多達八種鎖定模式。這樣的多樣性讓能在關聯上並行執行的指令數量最大化。

沒必要背下所有模式、也不必試圖從命名中找邏輯,但通讀這張表、歸納出一般結論,並在需要時回頭查閱,確實很有用。

模式與之衝突的模式典型指令
Access ShareAESELECT
Row ShareE, AESELECT FOR UPDATE/SHARE
Row ExclusiveS, SRE, E, AEINSERT, UPDATE, DELETE
Share Update ExclusiveSUE, S, SRE, E, AEVACUUM, CREATE INDEX CONCURRENTLY
ShareRE, SUE, SRE, E, AECREATE INDEX
Share Row ExclusiveRE, SUE, S, SRE, E, AECREATE TRIGGER
ExclusiveRS, RE, SUE, S, SRE, E, AEREFRESH MATERIALIZED VIEW CONCURRENTLY
Access Exclusive全部DROP, TRUNCATE, VACUUM FULL, LOCK TABLE, REFRESH MATERIALIZED VIEW

可以歸納的重點:

  • Access Share 是最弱的模式,可與除 Access Exclusive 之外的任何模式並用。因此 SELECT 幾乎能與任何操作平行執行——但它不讓你 drop 一張正被查詢的表格。
  • Access Exclusive 與所有模式都不相容
  • 前四種模式允許並行的 heap 修改,後四種不允許。

實例:

  • CREATE INDEX 使用 Share 模式,它與自己相容(可同時在一張表上建多個索引)、也與唯讀操作使用的模式相容。因此 SELECT 能與建索引平行執行,而 INSERTUPDATEDELETE 會被阻塞。
  • 反過來,未完成的、修改 heap 資料的交易會阻塞 CREATE INDEX。此時可改用 CREATE INDEX CONCURRENTLY,它使用較弱的 Share Update Exclusive 模式:建索引耗時更久(甚至可能失敗),但作為回報,允許並行的資料更新
  • ALTER TABLE 有多種變體,分別使用不同的鎖定模式(Share Update Exclusive、Share Row Exclusive、Access Exclusive),詳見官方文件。

實驗:更新一列會鎖住 accounts 表格與其所有索引,產生兩個 Row Exclusive 模式的 relation 鎖:

=> SELECT locktype, lockid, mode, granted
FROM locks WHERE pid = 28980;
   locktype    |     lockid    |       mode       | granted
−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−+−−−−−−−−−
 relation      | accounts      | RowExclusiveLock | t
 relation      | accounts_pkey | RowExclusiveLock | t
 transactionid | 122849        | ExclusiveLock    | t
 virtualxid    | 5/2           | ExclusiveLock    | t
(4 rows)
輔助視圖:把各種鎖 ID 合併成單一欄位
=> CREATE VIEW locks AS
SELECT pid,
  locktype,
  CASE locktype
    WHEN 'relation' THEN relation::regclass::text
    WHEN 'transactionid' THEN transactionid::text
    WHEN 'virtualxid' THEN virtualxid
  END AS lockid,
  mode,
  granted
FROM pg_locks
ORDER BY 1, 2, 3;

等待佇列#

重量鎖形成公平的等待佇列(fair wait queue)。行程若試圖取得的鎖與「目前的鎖」或「佇列中其他行程所請求的鎖」不相容,就會加入佇列。

四個 session 的完整情境:

  1. T1UPDATE accounts ... → 取得 accountsRow Exclusive 鎖(granted)
  2. T2CREATE INDEX ON accounts(client) → 請求 Share 鎖,掛起granted = f
  3. T3VACUUM FULL accounts → 請求 Access Exclusive,與所有模式衝突,也加入佇列
  4. T4SELECT * FROM accounts → 請求 Access Share

即使是單純的 SELECT 查詢也會老老實實地排在 VACUUM FULL 後面——儘管它與第一個 session 執行更新時持有的 Row Exclusive 鎖是相容的

用 pg_blocking_pids 觀察#

pg_blocking_pids 函式提供所有等待的高層概覽:它顯示排在指定行程之前、且已持有或想取得不相容鎖的所有行程 ID。

=> SELECT pid,
  pg_blocking_pids(pid),
  wait_event_type,
  state,
  left(query,50) AS query
FROM pg_stat_activity
WHERE pid IN (28980,29459,29662,29872) \gx
pid              | 28980
pg_blocking_pids | {}
wait_event_type  | Client
state            | idle in transaction
query            | UPDATE accounts SET amount = amount + 100.00 WHERE

pid              | 29459
pg_blocking_pids | {28980}
wait_event_type  | Lock
query            | CREATE INDEX ON accounts(client);

pid              | 29662
pg_blocking_pids | {28980,29459}
wait_event_type  | Lock
query            | VACUUM FULL accounts;

pid              | 29872
pg_blocking_pids | {29662}
wait_event_type  | Lock
query            | SELECT * FROM accounts;

要取得更多細節,可查閱 pg_locks 表格提供的資訊。

交易一旦完成(提交或中止),它的所有鎖都會被釋放,佇列中的第一個行程取得所請求的鎖並醒來。本例中第一個 session 的 ROLLBACK 導致所有排隊行程依序執行。