🎯 什麼情境該想到我
當你「怕開發者忘記加鎖,想把加鎖動作交給框架或基礎層自動處理」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:讓應用程式開發者不必自己記得每處加鎖——把加鎖動作藏進框架或層超型(Layer Supertype),自動、一致地執行。
做法要點:
- 把取鎖/檢查版本的邏輯放進共用的基礎設施(如資料對應層、層超型)。
- 開發者做正常的讀寫,框架在背後自動套用對應的鎖策略。
- 消除「靠人自律記得加鎖」這個最脆弱的環節。
- 前提:所有存取都要走這條統一路徑,繞過就失效。
★★ 它的全部價值:讓「忘記加鎖」變成不可能
// ❌★★ 顯式鎖的問題:只要有一條路徑忘記,整套防護就破了
func (s *Service) UpdateNote(ctx context.Context, id ID, note string) error {
return s.q.UpdateOrderNote(ctx, id, note) // ★ 忘了檢查版本
}
// → ★ 而它會通過 code review,因為「只是改個備註」看起來無害
// → ★★ 直到有人同時改了狀態,而這條路徑把它覆蓋掉★★ 這是安全機制的通則:依賴「每個人每次都記得」的防護,等於沒有防護。
★ 隱含鎖的做法是把鎖移到一個「不可能被繞過」的地方。
★ Go 的三個層次(由弱到強)
// ★ ① 唯一入口(最務實)—— 所有寫入都經過 repo.Save
// ★ 保證來自「子表的寫入方法未匯出」+「同 package 的紀律」
// ⚠️ 弱點:同 package 內仍可繞過(Go 的邊界是 package)
// ★★ ② 型別系統強制 —— 讓「沒有版本」的物件無法被儲存
type Versioned[T any] struct {
v T
version int64 // ★ 未匯出,外部無法偽造
}
func (r *Repo) Save(ctx context.Context, q db.Querier, o Versioned[order.Order]) error
// → ★★ 呼叫端必須先 Get(拿到 Versioned),才能 Save
// ⇒ ★ 「沒讀就寫」在編譯期就不可能
// ★★★ ③ 資料庫層強制(最強,因為它擋得住所有客戶端)
CREATE TRIGGER orders_bump_version BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION bump_version();
-- ★ 再加一個檢查:若 NEW.version <> OLD.version + 1 就 RAISE★★ 判準:會不會有「不是你寫的程式」去改這張表?
會(admin 腳本、資料修補、其他語言的服務、DBA 手動 UPDATE)→ ★ 必須用 ③。
不會 → ① 或 ② 就夠,而 ② 的編譯期保證很划算。
★★ 資料庫層的強制:兩種做法與代價
-- ★ ① trigger 自動遞增版本
CREATE FUNCTION bump_version() RETURNS trigger AS $$
BEGIN NEW.version := OLD.version + 1; RETURN NEW; END;
$$ LANGUAGE plpgsql;
-- ★ 好處:任何 UPDATE 都會遞增,連手動改都算
-- ★★ 代價:① 邏輯藏在資料庫裡,讀程式碼看不到
-- ② ORM 的「寫回後讀取版本」會拿到舊值(★ 要 RETURNING version)
-- ③ trigger 的效能成本與除錯困難
-- ★★ ② 權限:直接禁止繞過
REVOKE UPDATE ON orders FROM app_user;
GRANT EXECUTE ON FUNCTION update_order(...) TO app_user; -- ★ 只能走預存程序
-- ★ 最強,但★★ 把業務邏輯推進資料庫,多數團隊不會接受★ 務實建議:用 ① 的 trigger 做「版本必然遞增」,
而「版本檢查」留在應用層(WHERE version = $expected)。
→ ★ 這樣資料修補腳本不會破壞版本序列,而應用層仍看得見檢查邏輯。
★★ 隱含鎖的固有風險:它讓鎖變得看不見
★ 三個具體後果:
① ★★ 死鎖變得難以推理
你看不到鎖在哪裡取得 → ★ 無法檢查「鎖定順序是否一致」(見 [[悲觀離線鎖]])
② ★ 效能問題難以定位
「為什麼這個查詢會等」→ 因為某個 trigger/框架加了鎖,而程式碼裡沒有那一行
③ ★★ 新人會假設它不存在
→ ★ 寫了一段「應該要鎖但沒鎖」的碼,測試通過,因為框架幫他鎖了
→ ★ 然後有人繞過框架(寫 Raw SQL)→ ★★ 保護消失而沒有任何警訊
★★ 對策:隱含鎖必須「明確地被文件化」,而且要有一個測試證明它在作用:
func TestConcurrentUpdateIsRejected(t *testing.T) {
// ★★ 兩個交易同時讀、同時寫 → 斷言其中一個失敗
// ★ 這是唯一能防止「隱含鎖被誤刪」的迴歸測試
}★ 沒有這個測試,隱含鎖遲早會在某次重構中被拿掉,而沒有人發現。
★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
只要有人繞過框架直接存取,保護就破功。
-
自動行為降低了可見性,出問題時較難追(隱式魔法)。
-
需要一致的基礎架構與紀律,確保沒有旁路。
-
★★ 依賴「每個人每次都記得」的防護等於沒有防護。 這是這一頁的全部理由。
-
★★ 判準:會不會有「不是你寫的程式」改這張表?(admin 腳本、DBA 手動 UPDATE、其他語言的服務)會 → 必須在資料庫層強制。
-
★★ 隱含鎖讓死鎖難以推理:你看不到鎖在哪取得,無法檢查鎖定順序是否一致。
-
★★ 必須有一個「兩個交易同時寫、斷言其中一個失敗」的測試。 沒有它,隱含鎖遲早在某次重構被拿掉而沒人發現。
-
★★ 新人會假設它不存在,寫了「應該鎖但沒鎖」的碼;然後有人寫 Raw SQL 繞過框架 → 保護消失且無警訊。
-
★ trigger 的
NEW.version := OLD.version + 1會讓應用層拿到舊版本,要用RETURNING version。 -
★ 務實組合:trigger 保證版本必然遞增,版本檢查留在應用層——這樣資料修補不會破壞版本序列,檢查邏輯仍看得見。
-
Go 的
Versioned[T]型別包裝是划算的中間方案:「沒讀就寫」在編譯期就不可能。 -
把業務邏輯推進預存程序是最強的強制,但多數團隊不會接受。
🔗 相關工具
- 樂觀離線鎖 Optimistic Offline Lock(隱含鎖常用來自動化其版本檢查)
- 悲觀離線鎖 Pessimistic Offline Lock(隱含鎖可自動代為取得/釋放鎖)
- 粗粒度鎖 Coarse-Grained Lock(可由框架自動維護整組的鎖)
- 企業應用架構模式
- 樂觀離線鎖、悲觀離線鎖 —— 被隱含化的機制
- 粗粒度鎖 —— 常配隱含鎖自動遞增根版本
- 層超型 —— 共用鎖邏輯的落點
- 外掛 —— 行為由框架提供
- 儲存庫 —— 唯一寫入入口
- 工具-交易與隔離等級 —— 死鎖與重試