這篇要回答的問題:handler 用絕對值 UPDATE 已經冪等了,但 event A、B 亂序到達怎辦?同個 row 多 worker 同時寫怎麼避免超扣?Laravel decrement() 怎麼會是地雷?

3 分鐘結論

  • 同 event 重投不同 event 亂序到達 ⸺ 後者要 optimistic concurrency 解(version 欄位 + WHERE version=$expected
  • 跨 row read-modify-write race(兩個 worker 都看到 qty=10、都 UPDATE)解法:SELECT FOR UPDATE(pessimistic lock)
  • Laravel decrement() / increment() 底下就是 delta UPDATE,不冪等 ⸺ 用 篇 4a §4.4 ledger pattern

這篇假設你知道

完整 runnable demo:tools3455147/mq-event-driven-demo


1. Event 亂序到達:optimistic concurrency / versioning

篇 4a 講的都是「同 event 重投」。但 EDA 還有另一個常見問題:不同 event 亂序到達

場景

訂單狀態變更:

2026-05-27 10:00:00  publish status_change(order=A, to='paid')
2026-05-27 10:00:01  publish status_change(order=A, to='shipped')

業務上「paid → shipped」對。但 broker 不保證 ordering,consumer 可能:

worker 收到 shipped 先處理 → status='shipped'
worker 收到 paid 後處理 → status='paid'    ← 倒退了 🔥

handler 用絕對值 UPDATE 已經冪等了,但沒擋到亂序

解法:optimistic concurrency + version

加一個 version 欄位記「這筆 row 被改過幾次」,每次 UPDATE 帶 WHERE version=$expected

CREATE TABLE orders (
  id        UUID PRIMARY KEY,
  status    TEXT NOT NULL,
  version   INT  NOT NULL DEFAULT 1
);

Handler 處理 status change event 時:

async function orderStatusHandler(event) {
  await db.transaction(async (tx) => {
    const cur = await tx.query(`SELECT version FROM orders WHERE id=$1`, [event.order_id]);
    if (cur.rows.length === 0) return;
 
    const expectedVersion = event.expected_version;  // event 內帶
    if (cur.rows[0].version !== expectedVersion) {
      // 版本不對 → 拒絕這個 event,可能是亂序到達
      return;
    }
 
    await tx.query(
      `UPDATE orders SET status=$1, version=version+1
       WHERE id=$2 AND version=$3`,
      [event.to_status, event.order_id, expectedVersion],
    );
  });
}
  • event publish 時帶 expected_version(producer 知道當下版本)
  • consumer 收到 event 時 check current.version == event.expected_version
  • 不對就拒絕(亂序 / 已經被另一個 event 改過)

Demo 對應

/demo/order-status?out_of_order=1 模擬「event 帶過時的 expected_version」:

# 建一筆 order (version=1)
EVENT_ID=$(curl -X POST http://localhost:8000/demo/place-order \
  -H 'Content-Type: application/json' \
  -d '{"user_id":1,"items":["A"]}' | jq -r '.event_id')
 
# pending → paid (順利)
curl -X POST 'http://localhost:8000/demo/order-status' \
  -d "{\"order_event_id\":\"$EVENT_ID\",\"to\":\"paid\"}"
 
# 故意推一個帶舊 version 的 event(模擬亂序到達)
curl -X POST 'http://localhost:8000/demo/order-status?out_of_order=1' \
  -d "{\"order_event_id\":\"$EVENT_ID\",\"to\":\"pending\"}"
 
# audit 顯示後者被拒絕,status 仍是 'paid'
curl -s "http://localhost:8000/audit/event/$EVENT_ID" | jq '.business.orders'

亂序 event 被擋下,status 不會倒退。

Optimistic 為什麼夠用

之所以叫 “optimistic”:假設衝突很少發生。先讀資料 → 處理 → 寫入時帶 version check。撞到就重來。

對 EDA 場景特別合適:handler 之間衝突的機率本來就低(不同 user 的 order 互不相干),用樂觀鎖比拉 pessimistic SELECT FOR UPDATE 划算多了(後者 §2 講)。


2. 跨 row 寫入的鎖:SELECT FOR UPDATE

§1 optimistic concurrency 是「一筆 row 級別」的鎖。但有些場景需要跨 row 的互斥

  • 同個 user 不能同時兩個 reserve 餘額(不然超賣)
  • 同個 inventory item 多 worker 同時扣會 race

場景:read-modify-write race

async function reserveStock(itemId, qty) {
  const stock = await query('SELECT qty FROM stock WHERE item_id=$1', [itemId]);
  if (stock.rows[0].qty >= qty) {
    await query('UPDATE stock SET qty = qty - $1 WHERE item_id=$2', [qty, itemId]);
    return true;
  }
  return false;
}

兩個 worker 同時跑:

worker A: SELECT → qty=10
worker B: SELECT → qty=10
worker A: 10 >= 5 ✓
worker B: 10 >= 8 ✓
worker A: UPDATE qty = 10 - 5 = 5
worker B: UPDATE qty = 5 - 8 = -3  ← 超賣 🔥

解法 1:SELECT FOR UPDATE(pessimistic lock)

BEGIN;
  SELECT qty FROM stock WHERE item_id=$1 FOR UPDATE;  -- 鎖 row
  -- 業務判斷 + UPDATE
COMMIT;

FOR UPDATE 鎖住該 row,其他 transaction 等到 commit/rollback 才能讀。保證 read-modify-write 是原子的

代價:高 contention 場景會卡。

解法 2:用 篇 4a §4.4 的 ledger 把問題避掉

如果業務允許「先扣再驗,扣太多再補」:

INSERT INTO ledger (event_id, item_id, delta=-qty);
UPDATE balance SET qty = qty - $1 WHERE item_id=$2;
-- balance 可能變負,由後續對帳邏輯處理

某些業務(金流、虛擬幣)會接受「短暫負值」+「對帳補正」的模式,避開行鎖。

Demo 對應

/demo/concurrent-events endpoint 同時跑 N 個 worker 撞同 order,對比 naive vs locked:

# Naive: 不鎖、會超扣
curl -X POST 'http://localhost:8000/demo/concurrent-events?mode=naive&count=5&starting_qty=10' | jq
 
# Locked: SELECT FOR UPDATE 鎖 row
curl -X POST 'http://localhost:8000/demo/concurrent-events?mode=locked&count=5&starting_qty=10' | jq

Naive 模式 final_qty 可能變負(race condition);Locked 模式永遠對。


3. 給 Laravel 讀者的術語對譯(可選)

📍 沒用過 Laravel 的讀者可整節跳過,不影響後面理解。

Laravel 在這層有不少 helper,但幾乎都有對應的冪等地雷

Laravel 概念本系列對應冪等 ✅/❌注意
Model::create([...])INSERT 無 ON CONFLICT跑兩次撞 PK throw
Model::firstOrCreate(['key' => $v], [...])INSERT ON CONFLICT DO NOTHING對應 篇 4a §4.1
Model::updateOrCreate(['key' => $v], [...])UPSERT (DO UPDATE)⚠️ 看 attrs對應 篇 4a §4.5;attrs 是絕對值 ✅、相對值 ❌
$model->update(['status' => 'paid'])絕對值 UPDATE對應 篇 4a §4.2
$model->increment('qty', 1)Delta UPDATE對應 篇 4a §4.3這是地雷
$model->decrement('qty', 1)Delta UPDATE同上
DB::transaction(fn() => ...)BEGIN … COMMIT包冪等動作的容器
Model::withTrashed() + soft deleteUPDATE deleted_at絕對值 UPDATE
lockForUpdate()SELECT FOR UPDATE對應本篇 §2

Laravel decrement() / increment() 的真實面貌

// 看起來只是個方法呼叫
$user->decrement('balance', 100);
 
// 但 SQL 是 delta UPDATE:
// UPDATE users SET balance = balance - 100 WHERE id = ?
// → 跑兩次扣 200 → 不冪等

Eloquent 把 SQL 包成 method 後,冪等性變得不明顯。新人寫 $model->decrement(...) 不會想到「我這行 SQL 跑兩次會怎樣」⸺ 因為它看起來就是 OO 物件操作。

規則:handler 內避免increment / decrement。要扣餘額 / 算用量請用 篇 4a §4.4 ledger + balance pattern。Migration unique constraint + firstOrCreate / 絕對值 update 才是冪等的 Laravel 安全姿勢。


4. 反思

寫這篇遇到最微妙的觀察是「亂序並發是兩件不同的事**」:

  • 亂序到達(event A、B 順序顛倒)⸺ optimistic concurrency 解,靠 version check 拒絕過時 event
  • 並發寫入(兩個 worker 同時撞同 row)⸺ pessimistic lock 解,靠 row-level 鎖序列化

新人常以為「我用 transaction 包起來就好」⸺ 但 transaction 解的是 atomicity + isolation 的 transaction 內,不解「不同 transaction 間的順序」。

更深一層:樂觀 vs 悲觀的選擇是 business decision,不是技術 default

  • 衝突少(不同 user 訂單)→ 樂觀,撞到 retry
  • 衝突多(熱門商品庫存)→ 悲觀,序列化
  • 衝突無所謂但要快(廣告 metric)→ 不鎖,事後對帳

最後一個問題給看完這篇的你:

你 maintain 的系統,有沒有「事件亂序就翻車」的 handler?version 欄位有沒有?如果 broker 把今天 10:00 的 paid 事件壓到 11:00 才送進來,你的 orders 表會變什麼樣?


5. 相關文章

本系列前置

本系列接下來

  • 篇 5 → Retry 跟 DLQ — handler 失敗該怎麼處理
  • 篇 6 → EDA 端到端追蹤 — audit 怎麼觀察 row version 隨時間變化

外部參考

Runnable demo: tools3455147/mq-event-driven-demo/demo/order-status?out_of_order=1 (亂序) + /demo/concurrent-events?mode=naive|locked (read-modify-write race) 重現 設計文件: docs/architecture.md