這篇要回答的問題: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
這篇假設你知道
- 篇 4a DB 寫入冪等性:6 種 SQL idiom ⸺ 同 event 重投的 SQL 安全寫法
- SQL transaction + 基本
BEGIN ... COMMIT - 不熟下面這些詞時 → #16 EDA 名詞速查:optimistic concurrency / SELECT FOR UPDATE / read-modify-write race / advisory lock
完整 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' | jqNaive 模式 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 delete | UPDATE 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. 相關文章
本系列前置:
- 篇 4a DB 寫入冪等性:6 種 SQL idiom ⸺ 同 event 重投的 SQL 安全寫法
- 篇 3 Consumer 端冪等性 ⸺ handler 入口 dedup
本系列接下來:
- 篇 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