🎯 什麼情境該想到我

當你「怕開發者忘記加鎖,想把加鎖動作交給框架或基礎層自動處理」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:讓應用程式開發者不必自己記得每處加鎖——把加鎖動作藏進框架或層超型(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) {
    // ★★ 兩個交易同時讀、同時寫 → 斷言其中一個失敗
    // ★ 這是唯一能防止「隱含鎖被誤刪」的迴歸測試
}

★ 沒有這個測試,隱含鎖遲早會在某次重構中被拿掉,而沒有人發現。

★ 兄弟辨析

差別
樂觀離線鎖、悲觀離線鎖★ 機制;隱含鎖是「誰負責呼叫它」
粗粒度鎖★ 常配隱含鎖(自動遞增根版本)
層超型★ 把共用的鎖邏輯放在基礎型別上(★ Go 用嵌入或組合)
外掛另一種「行為由框架提供」的模式

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 只要有人繞過框架直接存取,保護就破功。

  • 自動行為降低了可見性,出問題時較難追(隱式魔法)。

  • 需要一致的基礎架構與紀律,確保沒有旁路。

  • ★★ 依賴「每個人每次都記得」的防護等於沒有防護。 這是這一頁的全部理由。

  • ★★ 判準:會不會有「不是你寫的程式」改這張表?(admin 腳本、DBA 手動 UPDATE、其他語言的服務)會 → 必須在資料庫層強制。

  • ★★ 隱含鎖讓死鎖難以推理:你看不到鎖在哪取得,無法檢查鎖定順序是否一致。

  • ★★ 必須有一個「兩個交易同時寫、斷言其中一個失敗」的測試。 沒有它,隱含鎖遲早在某次重構被拿掉而沒人發現。

  • ★★ 新人會假設它不存在,寫了「應該鎖但沒鎖」的碼;然後有人寫 Raw SQL 繞過框架 → 保護消失且無警訊。

  • ★ trigger 的 NEW.version := OLD.version + 1 會讓應用層拿到舊版本,要用 RETURNING version。

  • ★ 務實組合:trigger 保證版本必然遞增,版本檢查留在應用層——這樣資料修補不會破壞版本序列,檢查邏輯仍看得見。

  • Go 的 Versioned[T] 型別包裝是划算的中間方案:「沒讀就寫」在編譯期就不可能。

  • 把業務邏輯推進預存程序是最強的強制,但多數團隊不會接受。

🔗 相關工具