🎯 什麼情境該想到我

當你「同一筆資料有兩條路徑會去改它的狀態」——超時取消 vs 使用者剛好付款成功、退款 vs 出貨、重複投遞的同一則 MQ 訊息——而你正要寫「先 SELECT 出來、判斷狀態、再 UPDATE」這種三步走的時候。

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

問題:三步走中間有一道窗

// ❌ 反模式
o, _ := repo.Get(ctx, orderID)
if o.Status != "UNPAID" {
    return nil              // 已經付款了,不取消
}
repo.SetStatus(ctx, orderID, "CANCELED")   // ← 這一行執行時,它可能已經被付款了

SELECT 到 UPDATE 之間就是 TOCTOU(time-of-check to time-of-use)窗口。在預設的 READ COMMITTED/REPEATABLE READ 下,這個 SELECT 是快照讀,不會擋住別人改。付款回呼在這個縫隙裡把狀態改成 PAID,然後你的 UPDATE 把它蓋回 CANCELED——使用者付了錢,訂單卻被取消。

解法:把判斷塞進 WHERE,讓 DB 的行鎖當裁判

UPDATE orders
   SET status = 'CANCELED', cancel_time = NOW()
 WHERE id = ? AND status = 'UNPAID';

規則只有一條:affected rows = 1 表示你贏了,= 0 表示別人先改掉了,放棄。

res, err := db.ExecContext(ctx,
    `UPDATE orders SET status='CANCELED', cancel_time=NOW()
      WHERE id=? AND status='UNPAID'`, orderID)
if err != nil {
    return err
}
n, err := res.RowsAffected()
if err != nil {
    return err
}
if n == 0 {
    // 已被付款回呼/前一次重試改掉了 —— 這是正常結果,不是錯誤
    return nil
}
// 只有真的改成功的這一次,才做後續副作用
return releaseStock(ctx, orderID)

為什麼安全:UPDATE 會對符合條件的列加排他行鎖,兩個並發的 UPDATE 被序列化,第二個看到的是第一個已提交的 status,條件不成立 → 改到 0 列。誰先搶到誰贏,資料庫的行鎖幫你判勝負,你不需要額外的分散式鎖。

這同時就是冪等:同一個取消請求重跑十次,第二次之後條件都不成立,只會有一次真的生效。

★ 兩個會讓「affected rows」說謊的陷阱

1. MySQL 的 changed rows vs found rows。 MySQL 預設 RowsAffected 回傳的是實際被改動的列數,不是符合 WHERE 的列數。所以 SET status='CANCELED' WHERE status='CANCELED'(值沒變)會回 0,即使有列符合條件。

寫成 WHERE status='UNPAID' 時這不成問題(值一定會變),但如果你的條件允許「改成同一個值」,判斷就會誤判。go-sql-driver/mysql 可以在 DSN 加 clientFoundRows=true 切成「回傳 matched 列數」——但這是連線層級的全域行為,改之前要確認整個服務都能接受。比起改 flag,把條件寫成「狀態一定會改變」更穩。

PostgreSQL 沒這個歧義:UPDATE 的 command tag 回傳的就是被 WHERE 命中並更新的列數。

2. 副作用必須跟狀態變更同一個 transaction。 上面的 releaseStock 如果在 UPDATE 提交後才跑、中間 crash,就會出現「訂單取消了但庫存沒還」。兩種正解:

  • 庫存也在同一個 DB → 包進同一個 BEGIN ... COMMIT。
  • 庫存在別的服務/要發事件 → 用 工具-Transactional-Outbox-Pattern,把「取消」和「待發事件」一起提交。

進階:明確寫出狀態機的合法轉移

條件不只能寫一個值。把整張轉移表放進 WHERE:

-- 出貨只允許從 PAID 或 PACKING 進來
UPDATE orders SET status='SHIPPED'
 WHERE id=? AND status IN ('PAID','PACKING');

好處是非法轉移不會靜默發生,而是回 0 列讓你決定怎麼處理(記 log/告警/回錯誤)。狀態機一旦超過四五個狀態,把轉移表寫在程式裡一份、WHERE 裡用它產生條件,比散落各處的 if 好維護。

🧪 我實際套用的紀錄

  • 2026-09-25:(待填)

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

  • n == 0 不是錯誤。它是「別人先做了」的正常結果,不要回 error 讓上游一直重試。
  • 需要知道「為什麼輸」時要多讀一次。條件更新只告訴你贏或輸,不告訴你對方把狀態改成什麼。要記錄原因就在 n == 0 後補一次 SELECT(此時已無競態,狀態是終態)。
  • 不適用於「需要讀取當前值來計算新值」且值域無界的情況(例如餘額加減)。那要嘛用 SET balance = balance + ?(DB 端計算),要嘛帶版本號做樂觀鎖 WHERE version = ?。
  • 不能取代交易隔離等級的思考。條件更新解的是「同一列的狀態競賽」;跨列、跨表的不變量(例如「庫存總量必須等於各訂單佔用之和」)要靠交易或聚合邊界 → 工具-交易與隔離等級、工具-實體值物件與聚合。
  • 在 MongoDB/Redis 上對應的是 findOneAndUpdate 的 filter 條件、以及 Lua 腳本裡的條件判斷——原理相同:把判斷和寫入變成一個原子操作。

🔗 相關工具