關於鎖#
鎖(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 | 意義 |
|---|---|
transactionid、virtualxid | 交易 ID 上的鎖 |
relation | 關聯層級鎖 |
tuple | 元組上的鎖 |
object | 非關聯物件上的鎖 |
extend | 關聯延伸鎖 |
page | 某些索引類型使用的頁面層級鎖 |
advisory | 諮詢鎖 |
幾乎所有重量鎖都在需要時自動取得,並在對應交易完成時自動釋放。但也有例外:關聯層級鎖可以明確設定,而諮詢鎖則永遠由使用者管理。
交易 ID 上的鎖#
每筆交易都持有對自己 ID 的排他鎖(虛擬 ID 與真實 ID 皆然,若後者已配發)。
PostgreSQL 為此提供兩種鎖定模式,其相容性矩陣非常簡單:
| Shared | Exclusive | |
|---|---|---|
| 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 Share | AE | SELECT |
| Row Share | E, AE | SELECT FOR UPDATE/SHARE |
| Row Exclusive | S, SRE, E, AE | INSERT, UPDATE, DELETE |
| Share Update Exclusive | SUE, S, SRE, E, AE | VACUUM, CREATE INDEX CONCURRENTLY |
| Share | RE, SUE, SRE, E, AE | CREATE INDEX |
| Share Row Exclusive | RE, SUE, S, SRE, E, AE | CREATE TRIGGER |
| Exclusive | RS, RE, SUE, S, SRE, E, AE | REFRESH 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能與建索引平行執行,而INSERT/UPDATE/DELETE會被阻塞。- 反過來,未完成的、修改 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 的完整情境:
- T1:
UPDATE accounts ...→ 取得accounts的 Row Exclusive 鎖(granted) - T2:
CREATE INDEX ON accounts(client)→ 請求 Share 鎖,掛起(granted = f) - T3:
VACUUM FULL accounts→ 請求 Access Exclusive,與所有模式衝突,也加入佇列 - T4:
SELECT * 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) \gxpid | 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 導致所有排隊行程依序執行。