🎯 什麼情境該想到我
當你「有一組彼此相關的物件(聚合),想用一把鎖一次鎖住全部,避免只鎖到一部分」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用單一把鎖涵蓋一整組相關物件,讓它們作為一個單位一起被鎖定與釋放。
做法要點:
- 為一組相關物件(如聚合)指定一個共用的鎖點。
- 常見做法:共用版本(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 寫入是聚合邊界劃錯的訊號(見 儲存庫)。
🔗 相關工具
- 樂觀離線鎖 Optimistic Offline Lock(可用共用版本實作整組的樂觀鎖)
- 悲觀離線鎖 Pessimistic Offline Lock(可用根物件作為整組的鎖點)
- 隱含鎖 Implicit Lock(讓框架自動維護整組鎖,避免漏鎖)
- 企業應用架構模式
- 樂觀離線鎖、悲觀離線鎖 —— 兩種機制
- 隱含鎖 —— 讓版本遞增自動化
- 領域模型 —— 聚合邊界決定鎖邊界
- 從屬對應 —— 子實體的對應
- 儲存庫 —— 唯一寫入入口
- 工具-實體值物件與聚合 —— 聚合邊界的判準