🎯 什麼情境該想到我
當你「一筆業務交易橫跨多個請求,想在提交時才用版本號偵測有沒有人動過同一筆資料」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:不預先鎖定,而是在提交時偵測是否發生衝突——若有人在期間內改過同一筆資料就擋下,讓使用者重做。
做法要點:
- 每筆記錄帶一個版本號(或時間戳)。
- 讀取時記下版本號;提交時用「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 → 版本檢查會擋下,但你需要重新決策而非重試。
🔗 相關工具
- 悲觀離線鎖 Pessimistic Offline Lock(相對方案:事先鎖定避免衝突)
- 粗粒度鎖 Coarse-Grained Lock(把一組相關物件當一個單位一起做版本控制)
- 隱含鎖 Implicit Lock(把版本檢查交給框架自動處理,避免漏做)
- 企業應用架構模式
- 悲觀離線鎖 —— 先搶鎖的對照
- 粗粒度鎖 —— 鎖的粒度
- 隱含鎖 —— 避免忘記加鎖
- 領域模型 —— 版本欄位屬於聚合根
- 工具-交易與隔離等級 —— 隔離級別下的行為差異