🎯 什麼情境該想到我

當你「一筆業務交易橫跨多個請求,想在提交時才用版本號偵測有沒有人動過同一筆資料」的時候。

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

意圖:不預先鎖定,而是在提交時偵測是否發生衝突——若有人在期間內改過同一筆資料就擋下,讓使用者重做。

做法要點:

  • 每筆記錄帶一個版本號(或時間戳)。
  • 讀取時記下版本號;提交時用「WHERE version = 讀到的版本」更新,並讓版本號 +1。
  • 若更新影響列數為 0,代表期間被別人改過 → 衝突,回滾並提示重試。
  • 適合衝突機率低的情境,並發度高。

★★ 唯一正確的實作:條件更新 + 檢查影響列數

-- ★ 版本欄位法(最常用)
UPDATE orders
   SET status = $1, version = version + 1
 WHERE id = $2 AND version = $3;   -- ★★ $3 是讀取時看到的版本
res, err := q.UpdateOrder(ctx, params)
if err != nil { return fmt.Errorf("update: %w", err) }
n, err := res.RowsAffected()
if err != nil { return err }
if n == 0 {
    return ErrConcurrentModification    // ★★ 沒有錯誤,但沒改到 = 被別人改過了
}

★★ RowsAffected() == 0 是唯一的訊號,而它不是錯誤。
漏檢查它 = 樂觀鎖完全沒有作用,而且測試通常抓不到(單執行緒下永遠成功)。

★ 三種「版本」的選擇

做法優點★ 缺點
① version bigint★ 明確、可讀、跨資料庫要記得每次 +1;★ 漏一處就破功
② updated_at timestamptz順便有稽核資訊★★ 同一毫秒內的兩次更新可能相同 → 不可靠
③ ★ PostgreSQL 的 xmin★ 零 schema 變更、DB 自動維護★★ xmin 是 32-bit 且會 wraparound;VACUUM FULL/pg_upgrade 後會變

★★ 選 ①。 ② 在高頻更新下不可靠;③ 看似優雅但「不能長期持有」——
只適合同一個請求內讀了馬上寫,不適合「使用者編輯表單三分鐘後送出」。

★★ 在 REPEATABLE READ/SERIALIZABLE 下行為不同(實測依據)

PostgreSQL 官方文件(2026-09-25 查證)明載:

在 REPEATABLE READ 或 SERIALIZABLE 交易中,若要鎖定的列自交易開始後已被改變,
會直接拋出錯誤。

★ 所以在這兩個隔離級別下,你會拿到 40001 serialization_failure
而不是 RowsAffected() == 0:

// ★★ 兩種失敗都要處理
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "40001" {
    return ErrConcurrentModification      // ★ 序列化失敗 → 可重試
}

★★ 這代表「重試邏輯」必須同時認得兩種訊號——
而多數人只寫了其中一種(見 工具-交易與隔離等級)。

★★ HTTP 層的樂觀鎖:ETag / If-Match

GET  /orders/42          → 200, ETag: "7"                    ★ ETag 就是版本
PUT  /orders/42          → If-Match: "7"
  ├─ 版本相符  → 200, ETag: "8"
  ├─ 版本不符  → ★★ 412 Precondition Failed
  └─ ★ 沒帶 If-Match → 428 Precondition Required(RFC 6585)

★★ 428 比「預設允許無條件更新」重要得多:
不強制 If-Match → 客戶端會忘記帶 → 樂觀鎖在 API 層形同虛設。
★ 判準:這個資源會被多方編輯嗎? 會 → 強制 If-Match,缺了就回 428。

★★ 它真正困難的地方不是技術,是「衝突後怎麼辦」

★ 三種處置,必須由業務決定:
① ★ 整個失敗,叫使用者重做
   → ★★ 使用者填了三分鐘的表單全部消失 —— 這是最糟的體驗
② ★ 重試(把使用者的變更套到新版本上)
   → ★ 只有在「變更是可交換的」時才安全:
     ✅ counter += 1、加一筆明細  ❌ status = "paid"(覆寫語義)
③ ★★ 呈現衝突讓使用者決定(★ 最好,也最貴)
   → 「另一個人把狀態改成 X 了,你要覆蓋還是放棄?」

★★ 關鍵:先問「這個欄位是覆寫語義還是累加語義」。
累加 → 可以自動重試(或改用 SET x = x + 1 根本不需要樂觀鎖)。
覆寫 → 必須讓人決定,不要自動重試(自動重試 = 靜默丟掉別人的變更)。

★ 什麼時候不需要樂觀鎖

  • ★ 單一寫入者(只有一個排程器/一個服務寫這張表)。
  • ★ 操作本身是交換的:UPDATE counters SET n = n + 1 不需要版本。
  • ★ append-only:只 INSERT 不 UPDATE。
  • ★ 衝突極罕見且後果可接受(★ 但要先確認「後果可接受」,不要假設)。

★ 兄弟辨析

差別
悲觀離線鎖★ 先搶鎖再改;樂觀鎖是先改再檢查
粗粒度鎖★ 鎖的粒度(一個聚合共用一個版本號)
隱含鎖★ 讓框架自動加鎖,避免忘記
工作單元單一請求內的變更追蹤;樂觀鎖是跨請求的

🧪 我實際套用的紀錄

  • (待填)

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

  • 衝突頻繁時,使用者常做白工再重來,體驗差。

  • 衝突要到最後提交才發現(已投入的編輯可能全丟)。

  • 高衝突情境改用悲觀離線鎖較合適。

  • ★★ RowsAffected() == 0 是唯一訊號,而它不是錯誤。 漏檢查 = 樂觀鎖完全無作用,且單執行緒測試抓不到。

  • ★★ 在 REPEATABLE READ/SERIALIZABLE 下會拋 40001 而不是回 0 列(PostgreSQL 官方文件,2026-09-25 查證)。重試邏輯要同時認得兩種訊號。

  • ★★ 用 updated_at 當版本不可靠:同一毫秒內的兩次更新可能相同。用 version bigint。

  • ★ PostgreSQL 的 xmin 只適合「讀了馬上寫」:32-bit 會 wraparound,VACUUM FULL/pg_upgrade 後會變。

  • ★★ 覆寫語義的欄位不可以自動重試——那等於靜默丟掉別人的變更。累加語義才可以。

  • ★ API 層要強制 If-Match,缺了回 428,否則客戶端會忘記帶,樂觀鎖形同虛設。

  • ★ 「整個失敗叫使用者重做」是最糟的體驗。三分鐘的表單消失比衝突本身更傷。

  • 版本號要在每一條更新路徑上都 +1。漏一處(例如某個 admin 腳本)就整個破功。

  • 樂觀鎖不保護「讀取後的決策」:你讀到餘額 100 決定扣 50,別人先扣了 80 → 版本檢查會擋下,但你需要重新決策而非重試。

🔗 相關工具