🎯 什麼情境該想到我
當你「資料衝突很常發生,想在開始存取前就先取得鎖、避免白做工」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:在存取資料前先取得鎖,讓同時間只有一個業務交易能動它,從源頭避免衝突。
做法要點:
- 用一張鎖表(lock table)記錄「哪筆資料被誰鎖住」。
- 開始編輯前先嘗試取鎖,取到才能繼續;取不到就等待或被拒。
- 完成交易或逾時後釋放鎖。
- 常見鎖型:獨占寫鎖、讀寫鎖等,依需求選擇。
★★ 三種實作,作用域與代價完全不同
(PostgreSQL 官方文件查證,2026-09-25)
-- ★ ① SELECT ... FOR UPDATE —— 交易級的列鎖
SELECT * FROM orders WHERE id = $1 FOR UPDATE;
-- ★ 作用域:這個交易結束(★ 所以無法跨請求持有)
-- ★ 官方細節:FOR UPDATE 會「修改被選中的列以標記鎖定」⇒ ★★ 會造成磁碟寫入
-- ★ 變體:FOR NO KEY UPDATE / FOR SHARE / FOR KEY SHARE(鎖強度遞減)
-- ★ ② NOWAIT / SKIP LOCKED
SELECT * FROM orders WHERE id = $1 FOR UPDATE NOWAIT; -- ★ 鎖不到就報錯
SELECT * FROM jobs WHERE … FOR UPDATE SKIP LOCKED LIMIT 10; -- ★ 鎖不到就跳過
-- ★★ 官方警告:SKIP LOCKED「提供了不一致的資料視圖,不適合一般用途,
-- 但可用於避免多個消費者存取類佇列表格時的鎖競爭」
-- → ★ 所以它是「任務佇列」的專用工具,不要用在一般查詢上
-- ★★ ③ Advisory lock —— 應用層定義的鎖,可以跨交易
SELECT pg_advisory_xact_lock($1); -- ★ 交易級:交易結束自動釋放
SELECT pg_advisory_lock($1); -- ★★ session 級:★ 不遵守交易語義!★★ Advisory lock 的三個關鍵事實(最容易踩的)
官方文件(2026-09-25):
① ★★ session 級 advisory lock「不遵守交易語義」
→ ★ 在一個後來被 rollback 的交易中取得的鎖,rollback 後「仍然被持有」
→ ★★ 所以:session 級鎖必須顯式釋放,而且 defer 也救不了 panic 之外的情況
→ ★ 除非你真的需要跨交易,★★ 一律用 pg_advisory_xact_lock(交易級)
② ★ 同一個 session 可以重複取得同一個鎖
→ 取得 N 次就要釋放 N 次才真正釋放
③ ★★ 鎖存在共享記憶體池裡,上限由 max_locks_per_transaction × max_connections 決定
→ 官方說「典型上限是數萬到數十萬個」
→ ★ 耗盡時「伺服器將無法授予任何鎖」—— ★★ 整個資料庫癱瘓,不只是你的功能
★★ 還有一個官方明列的陷阱:
SELECT pg_advisory_lock(id) FROM foo WHERE id = 12345; -- ok
SELECT pg_advisory_lock(id) FROM foo WHERE id > 12345 LIMIT 100; -- ★★ 危險!
-- ★ LIMIT 不保證在鎖定函式之前套用 → 可能鎖到你沒預期的列,而且不會釋放(dangling)
SELECT pg_advisory_lock(q.id) FROM (SELECT id FROM foo WHERE id > 12345 LIMIT 100) q; -- ok★★ 「離線」鎖的真正難題:跨請求持有鎖
★ PoEAA 的原始場景:使用者在編輯畫面停留三分鐘,要防止別人同時改
→ ★★ 但資料庫的列鎖做不到這件事(交易不能開三分鐘)
→ ★ 所以真正的「離線悲觀鎖」是一張鎖表:
CREATE TABLE edit_locks (
resource_id text PRIMARY KEY,
owner_id text NOT NULL,
acquired_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL -- ★★ 沒有這一欄就是死鎖
);★★ expires_at 是不可省略的:
持鎖者的瀏覽器關掉、程序被 OOM kill、網路斷了 →
鎖永遠不會釋放,那個資源從此沒人能編輯。
★ 配套三件事:
① ★ 續約(heartbeat):編輯中每 30 秒更新 expires_at
② ★★ 強制解鎖的管理功能(★ 一定會用到,而且要有審計)
③ ★ 清理過期鎖的排程(或查詢時忽略已過期的鎖)
★★ 死鎖:這是悲觀鎖唯一必然的代價
// ★★ 兩個交易以相反順序鎖兩列 → 死鎖
// A: lock(order1) → lock(order2)
// B: lock(order2) → lock(order1)
// → PostgreSQL 偵測到後會 kill 其中一個,回 40P01 deadlock_detected★ 三個對策:
① ★★ 固定鎖定順序(★ 最有效):一律按 ID 排序後依序鎖
ids := slices.Sorted(slices.Values(ids)) // ★ Go 1.23+
② ★ 用 [[粗粒度鎖]]:一次只鎖一個聚合根,不鎖多列
③ ★ 設 lock_timeout,寧可失敗也不要無限等待
SET LOCAL lock_timeout = '3s';
★★ 死鎖必須當成「預期會發生」來處理,要有重試(★ 而重試要冪等)。
★ 什麼時候用悲觀而非樂觀
| 選悲觀 | 選 樂觀離線鎖 |
|---|---|
| ★ 衝突頻繁(熱點資源) | 衝突罕見 |
| ★ 重做的代價很高(長表單、複雜計算) | 重做便宜 |
| ★ 必須序列化(發號、扣庫存) | 可以事後檢查 |
★ 任務佇列搶單(SKIP LOCKED) | — |
★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
明顯降低並發度,鎖住期間別人動不了。
-
需處理死結、以及使用者忘了釋放鎖(逾時機制)。
-
衝突其實很少時,改用樂觀離線鎖並發更好。
-
★★ session 級 advisory lock 不遵守交易語義:rollback 後鎖仍被持有(官方文件明載)。一律用
pg_advisory_xact_lock。 -
★★ advisory lock 耗盡共享記憶體池時「伺服器無法授予任何鎖」——整個資料庫癱瘓,不只你的功能。上限典型是數萬到數十萬。
-
★★
SELECT pg_advisory_lock(id) FROM foo WHERE … LIMIT 100是官方列出的危險寫法:LIMIT 不保證在鎖定函式前套用 → dangling locks。 -
★★ 鎖表必須有
expires_at。持鎖者的瀏覽器關掉 → 資源永遠沒人能編輯。還要配續約、強制解鎖、清理排程。 -
★★
SKIP LOCKED官方說「提供不一致的資料視圖,不適合一般用途」——它是任務佇列的專用工具。 -
★★ 死鎖要當成「預期會發生」:固定鎖定順序(按 ID 排序)、設
lock_timeout、重試且冪等。 -
★
FOR UPDATE會造成磁碟寫入(標記列為鎖定),不是「免費的讀取」。 -
交易不能開三分鐘。 跨請求的鎖必須是應用層的鎖表,不是資料庫列鎖。
-
強制解鎖功能一定會用到,要有審計日誌。
🔗 相關工具
- 樂觀離線鎖 Optimistic Offline Lock(相對方案:提交時才偵測衝突)
- 粗粒度鎖 Coarse-Grained Lock(一次鎖住一整組相關物件)
- 隱含鎖 Implicit Lock(把加鎖動作交給框架自動處理)
- 企業應用架構模式
- 樂觀離線鎖 —— ★ 多數情況的預設選擇
- 粗粒度鎖 —— 減少死鎖的鎖粒度
- 隱含鎖 —— 避免忘記加鎖
- 工具-交易與隔離等級 —— 隔離級別、死鎖與重試
- 工具-線性一致性與共識 —— 分散式鎖的更深討論