🎯 什麼情境該想到我

當你「有一組彼此相關的物件(聚合),想用一把鎖一次鎖住全部,避免只鎖到一部分」的時候。

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

意圖:用單一把鎖涵蓋一整組相關物件,讓它們作為一個單位一起被鎖定與釋放。

做法要點:

  • 為一組相關物件(如聚合)指定一個共用的鎖點。
  • 常見做法:共用版本(shared version)——整組共用一個版本號;或以根物件(root)作為鎖的持有點。
  • 鎖住根,即等於鎖住整組;一次鎖全部,不會有部分鎖漏。
  • 常與樂觀/悲觀離線鎖搭配,決定鎖定的顆粒度。

★★ 它要解決的問題:一個聚合有五張表,鎖哪一張?

★ 沒有粗粒度鎖:
  訂單主檔 version = 3
  訂單品項 各自有 version
  出貨地址 有 version
→ ★★ A 改了品項、B 改了地址 → 兩邊的版本檢查都通過
   → ★ 但「訂單總額」這個跨表的不變式被破壞了,而且沒有任何錯誤

★★ 核心洞見:不變式的範圍決定鎖的範圍。
如果「品項 + 地址 + 主檔」必須一起維持某個規則,它們就必須共用一把鎖。

★ 兩種實作

-- ★★ ① 共用版本號(shared version)—— 版本放在聚合根上
UPDATE orders SET version = version + 1
 WHERE id = $1 AND version = $2;     -- ★ 改任何子表都要先通過這個檢查
-- ★ 然後才改 order_lines / shipping_addresses
-- → ★★ RowsAffected() == 0 就是整個聚合的衝突
 
-- ★ ② 根鎖(root lock)—— 悲觀版本
SELECT 1 FROM orders WHERE id = $1 FOR UPDATE;    -- ★ 只鎖根
-- ★ 然後自由讀寫子表(因為所有人都得先鎖根)

★★ ① 的關鍵:改子表也必須遞增根的版本。
漏了這一步(「我只改品項,不用動訂單」)→ 粗粒度鎖完全失效。
→ ★ 這正是 隱含鎖 要防的事:讓「遞增根版本」由框架而非開發者負責。

★★ Go 的實作:把它做進 repo 的 Save,不要讓呼叫端記得

// ★ 所有寫入都經過這一個入口 ⇒ 不可能漏掉版本遞增
func (r *Repo) Save(ctx context.Context, q db.Querier, o *order.Order) error {
    s := o.Snapshot()
 
    // ★★ 第一步永遠是「遞增聚合根的版本」——這就是粗粒度鎖
    n, err := q.BumpOrderVersion(ctx, db.BumpOrderVersionParams{
        ID: s.ID, ExpectedVersion: s.Version,
    })
    if err != nil { return fmt.Errorf("bump version: %w", err) }
    if n == 0 { return order.ErrConcurrentModification }
 
    // ★ 之後才寫主檔欄位與所有子表 —— 都在同一個交易裡
    if err := r.saveHead(ctx, q, s); err != nil { return err }
    return r.saveLines(ctx, q, s)
}

★★ 判準:呼叫端有沒有辦法「繞過 Save 直接改子表」?
有 → 粗粒度鎖遲早會失效。
→ ★ 對策:子表的寫入權只給 repo(同 package 的未匯出方法,或資料庫層的權限)。

★★ 代價:鎖粒度變粗 = 併發度變低

★ 一張訂單 100 個品項,兩個使用者各改一個不同品項
→ ★★ 粗粒度鎖讓其中一個必須重試
→ ★ 細粒度鎖(各品項自己的版本)可以並行,但跨品項的不變式守不住

★★ 判準:這兩個變更真的可能互相破壞嗎?
不會(改不同品項的備註)→ 粗粒度鎖是不必要的併發損失。
會(改品項數量會影響總額與庫存)→ 必須粗粒度。

★ 實務做法:先粗粒度(安全),量到競爭再細化。
反過來(先細後粗)很難做——因為你已經有一堆繞過根的寫入路徑了。

★★ 這條線畫太大就是災難

★★ 「整個購物車 + 使用者 + 錢包」共用一把鎖
→ ① 使用者改個暱稱,就跟他的購物車衝突
   ② ★ 所有寫入競爭同一個版本號 ⇒ 熱點,吞吐被單一列限制
   ③ ★ 交易變長(要寫五張表)⇒ 鎖持有時間變長 ⇒ 惡性循環
→ ★ 這是 [[領域模型]] 那一頁「聚合太大」的具體後果

★ 判準回到聚合邊界:「這些東西必須同時正確嗎?」
(見 工具-實體值物件與聚合)

★ 兄弟辨析

差別
樂觀離線鎖★ 機制;粗粒度鎖是粒度的決定
悲觀離線鎖★ 根鎖版本用的就是它
隱含鎖★ 讓「遞增根版本」由框架負責,避免漏掉
領域模型★ 聚合邊界 = 鎖邊界
從屬對應子實體的對應方式

🧪 我實際套用的紀錄

  • (待填)

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

  • 顆粒度太粗會降低並發(動一個小物件也鎖住整組)。

  • 需要清楚定義「哪些物件屬於同一組」(聚合邊界)。

  • 物件間關聯鬆散、可獨立變動時,粗粒度鎖反而綁太多。

  • ★★ 改子表也必須遞增聚合根的版本。 漏了這一步粗粒度鎖完全失效——而這是最常見的錯誤。

  • ★★ 把版本遞增做進 repo 的 Save 唯一入口,不要讓呼叫端記得。子表的寫入權只給 repo。

  • ★★ 鎖範圍畫太大是災難:改暱稱跟購物車衝突、所有寫入競爭同一列成為熱點、交易變長讓鎖持有更久。

  • ★ 判準:這兩個變更真的可能互相破壞嗎? 不會 → 粗粒度鎖是不必要的併發損失。

  • ★ 先粗粒度(安全)、量到競爭再細化。 反過來很難做,因為已經有繞過根的寫入路徑。

  • 聚合邊界 = 鎖邊界。 這一頁的取捨其實是 領域模型 的聚合設計問題。

  • 一個交易用三個 repo 寫入是聚合邊界劃錯的訊號(見 儲存庫)。

🔗 相關工具