昨天,我們從 State Machine(狀態機)的地圖一路走進 Code(程式碼),看見 BuJo 怎麼限制 Activity 只能沿著合法路線改變 State。
不過,就算每一條路都規定好了,還有一個問題沒有解決:
如果兩個 Request 幾乎同時看見「這條路可以走」,又一起往下執行呢?
今天就來認識一個我覺得名字超可愛的東西:
Optimistic Locking(樂觀鎖)。
它到底在「樂觀」什麼?等等我們直接放回 BuJo 的 Code 裡,一起來看看它是如何處理兩個 Request 同時撞在一起這種尷尬的情況。
第一次看到這個名字的時候我真的覺得超~可愛(笑)。
然後它的想法也真的非常「樂觀」:
先相信大部分時候,大家不會那麼剛好同時修改同一筆資料。
所以 Optimistic Locking(樂觀鎖)不會一開始就把資料鎖住,要求其他操作排隊。
它會先讓不同操作各自進行,直到真正準備更新時,才做最後一次確認:
讀取資料
↓
各自處理
↓
準備更新
↓
原本的條件還成立嗎?
├─ 成立 → 更新
└─ 不成立 → 不更新
也就是說,它的「樂觀」不是完全不防衝突,而是:
先假設衝突不常發生,真的要寫入時再處理。
Optimistic Locking(樂觀鎖)需要一個可以拿來比較的值。
常見方式包括:
| 比較方式 | 系統記住什麼 | 特點 |
|---|---|---|
| Version Number(版本號) | 例如 version = 3 |
最典型,資料更新時版本號跟著增加 |
| Updated At(最後更新時間) | 例如 updated_at |
可以沿用時間欄位,但要留意時間精度 |
| Original Field Value(原本的欄位值) | 例如原本的 status |
適合只在意特定欄位是否改變的情境 |
不管用哪一種,核心都一樣:
原本的值還符合
→ 才更新成新值
這就是 CAS(Compare-And-Swap,比較並交換)的核心思路。
不過這裡還有一個關鍵:
「比較」和「更新」不能拆成兩個有空隙的動作。
如果先檢查一次:
現在還是 voting
過一下才執行 Update,中間還是可能被另一個 Request 先改掉。
所以真正需要的是:
讓比較條件直接跟著這次 Update 一起執行。
BuJo 沒有另外建立 version。
它拿來比較的,就是昨天一直在看的:
status。
假設兩個確認成團的 Request 幾乎同時抵達,而且都在 Activity 還是 voting 時讀到資料:
Request A Request B
│ │
├─ 讀到 voting ├─ 讀到 voting
│ │
├─ 改成 confirmed ├─ 改成 confirmed
│ │
└─ 建立成團通知 └─ 又建立一次成團通知
最後 Activity 的狀態看起來沒有問題:
status = confirmed
真正出錯的是,同一次成團通知可能被建立兩次。
對使用者來說,就可能因此收到兩則一模一樣的通知。
像這種多個 Request 在重疊的時間裡處理同一份資料,就是 Concurrency(並發)的情境。
而我們現在要避免的,就是像「同一個成團通知被建立兩次」這樣,因為多個操作彼此交錯而產生錯誤結果的 Race Condition(競態條件)。
所以 BuJo 需要守住的是:
同一次確認成團,只能有一個 Request 成功繼續往下執行。
那它到底怎麼判斷誰可以繼續?
答案就藏在更新條件裡。
where 裡先只把這次最重要的部分拉出來看:
const { count } = await tx.activity.updateMany({
where: {
id,
// 現在的 status 仍然要和這次 Request 一開始讀到的一樣
status: activity.status,
},
data: {
// 條件仍成立,才改成 confirmed
status: "confirmed",
},
});
這裡其實同時限制了:
id
→ 找到這一場 Activity
status: activity.status
→ 再加上原本的 State 當更新條件
假設 Request A 和 Request B 一開始都讀到:
voting
Request A 先執行:
WHERE id = ... AND status = voting
↓
符合
↓
更新成 confirmed
等 Request B 執行時:
WHERE id = ... AND status = voting
↓
資料庫現在已經是 confirmed
↓
不符合
↓
不更新
也就是說,前面提到的 CAS,在 BuJo 裡就是透過這次 Conditional Update(條件更新)落地:
status 仍然符合原本的值
→ 才更新成 confirmed
count 告訴程式誰更新成功了Prisma 的 updateMany() 會回傳實際更新了幾筆資料。
在這段流程裡,主要有兩種結果:
count |
代表什麼 | 後續處理 |
|---|---|---|
count === 1 |
有 1 筆資料符合 id + status,更新成功 |
繼續確認成團流程 |
count === 0 |
原本的 status 已經不再符合 |
停止後續處理 |
所以 Code 接著會判斷:
// 沒有任何資料符合原本的 id + status
// 代表這次 Request 依賴的 State 已經改變
if (count === 0) {
return false;
}
count === 0 並不代表 Backend 故障。
而是代表這次 Request 原本依賴的 State,已經被其他操作改變。
因此 BuJo 選擇回傳:
409 Conflict(衝突)
BuJo 也替這個衝突分支留下了 Test。
這裡先模擬 Conditional Update(條件更新)沒有更新到任何資料,也就是:
count = 0
// 模擬另一個操作已經先改變 Activity State
// 因此這次 Conditional Update 沒有更新到任何資料
prisma.activity.updateMany.mockResolvedValueOnce({ count: 0 });
await confirmFormation(makeReq(), res);
// 沒搶到這次狀態轉換,就不能繼續建立站內通知
expect(prisma.notification.createMany).not.toHaveBeenCalled();
// API 要明確回報目前 State 已經發生衝突
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
message: "此活動狀態已被異動,請重新整理後再試",
});
這個 Test 驗證的就是:
count = 0
↓
停止後續處理
↓
不建立站內通知
↓
回傳 409 Conflict
也就是確認:
原本的 State 已經失效後,這次 Request 不會繼續把後面的流程做完。
到這裡,Optimistic Locking(樂觀鎖)已經幫我們守住:
只有成功更新 State 的 Request 能繼續。
不過昨天也看過,確認成團不只會改變 status。
BuJo 還要一起:
Activity → confirmed
+
儲存 confirmed_slot_id
+
建立站內通知
這幾個寫入共同代表一次「確認成團」。
所以真正的 Code 會把它們放進同一個 Transaction(交易)裡:
// 把這次「確認成團」需要的資料庫操作放進同一個 Transaction
const won = await prisma.$transaction(async (tx) => {
const { count } = await tx.activity.updateMany({
where: {
id,
// 只有 State 還和這次 Request 一開始讀到的一樣,才允許更新
status: activity.status,
},
data: {
status: "confirmed",
},
});
// 沒搶到狀態轉換,就直接停止
if (count === 0) return false;
// 只有成功改變 State 的 Request 才能繼續儲存最後確認的時段
await tx.activitySchedule.update({
where: { activity_id: id },
data: {
confirmed_slot_id: confirmedSlotId,
},
});
// 也只有勝出的 Request 會建立站內通知
if (notifyTargets.length > 0) {
await tx.notification.createMany({
data: notifyTargets.map((participant) => ({
user_id: participant.user_id,
type: "activity_confirmed",
reference_id: id,
reference_type: "activity",
})),
});
}
return true;
});
// 沒搶到這次 State Transition 就回報目前資料已經被其他操作改變
if (!won) {
return res.status(409).json({
message: req.t("activity.stateChangedConcurrently"),
});
}
這裡可以把兩件事分開看:
Optimistic Locking(樂觀鎖)
→ 決定哪一個 Request 能繼續
Transaction(交易)
→ 保證勝出的這組資料庫操作一起成功或一起失敗
例如 Activity 已經改成 confirmed,後面的確認時段卻寫入失敗,Transaction 會讓前面的資料庫變更一起取消。
這樣就不會留下:
Activity 已經 confirmed
但是 confirmed_slot_id 沒有成功寫入
這種只完成一半的資料。
納今天的 Optimistic Locking(樂觀鎖)就講到這裡啦~
我真的覺得樂觀鎖這個名字很可愛。
它很像那種做事很敢衝的朋友:
「先做再說啦!真的撞到了再處理!」
不會因為覺得「等等可能有人跟我撞在一起」,就先站在原地不動。
但它的「樂觀」也不是毫無戒心。
它只是選擇先往前走,到了真正要寫入資料的時候,再替自己做最後一次確認。
有點像:
「我相信應該不會出事,但真的要交出去以前,我還是會再看一眼。」
所以它不是什麼都不管的樂天派,而是:
先做、再確認,該留的最後一道把關還是沒有少。
樂觀鎖介紹完了,是不是會想:
有樂觀鎖,那是不是也有悲觀鎖呀?
當然有啦~~
明天就來看看 Pessimistic Locking(悲觀鎖),又會是什麼個性的傢伙吧!