昨天講完 Optimistic Locking(樂觀鎖),今天就來看看它的好夥伴:
Pessimistic Locking(悲觀鎖)。
光看名字,就知道是兩位個性完全不同的朋友對吧(笑)。
樂觀派會想:
「應該不會那麼剛好撞在一起啦,真的撞到再處理!」
那悲觀派呢?
今天就來看看它到底有多「悲觀」,以及 BuJo 為什麼會在報名流程裡選擇這種做法。
Pessimistic Locking(悲觀鎖)的想法真的很符合它的名字:
先假設這裡很可能發生衝突,所以先把需要保護的資料門鎖上,讓會互相衝突的操作先等等,再往下處理。
也就是:
開始處理
↓
先把這筆資料的門鎖上
↓
讀取最新資料
↓
判斷能不能繼續
↓
可以 → 寫入
不行 → 拒絕
↓
結束並解鎖讓其他人進入
如果另一個 Request 同時也想取得同一筆互相衝突的鎖,它不會拿著舊資料繼續往下跑。
而是:
先等等。
等前一個 Transaction 完成、鎖被釋放之後,再輪到自己取得鎖、重新讀取最新資料,接著做判斷。
這次 BuJo 剛好就有一個很適合悲觀派出場的情境:
搶最後一個名額。
假設一場 Activity 的人數上限是:
participant_target = 10
現在已經有 9 位參與者。
這時 Request A 和 Request B 幾乎同時按下報名:
Request A Request B
│ │
├─ 讀到目前 9 人 ├─ 也讀到目前 9 人
│ │
├─ 判斷:還有名額 ├─ 判斷:也還有名額
│ │
├─ 建立第 10 筆報名 └─ 建立第 11 筆報名
問題就出現了。
原本的規則明明是:
最多 10 人。
但每一個 Request 單獨看,人數檢查其實都沒有寫錯。
它們都真的看到了:
目前人數 = 9
9 < 10
→ 可以報名
真正的問題,出在:
「檢查人數」和「建立報名」中間那段時間。
所以 BuJo 這次不能只讓兩個 Request 各自讀完再往下做,而是需要讓同一場 Activity 的報名依序通過。
這就是 SELECT ... FOR UPDATE 要出場的地方。
SELECT ... FOR UPDATE 到底多做了什麼?在 BuJo 的 joinActivity() 裡,可以看到這段:
await tx.$queryRaw`
SELECT id
FROM activities
WHERE id = ${id}
FOR UPDATE
`;
原本:
SELECT
是在讀取資料。
加上:
FOR UPDATE
之後,這次查詢除了讀取指定資料,也會對符合條件的 Row(資料列)取得 Row-Level Lock(資料列層級鎖定)。
這裡指定的是:
WHERE id = ${id}
所以 BuJo 鎖住的是:
這一場 Activity 對應的資料列。
不是把整張 activities Table(資料表)全部鎖起來。
也就是說,不同 Activity 的報名通常還是可以各自進行;真正需要等待的,是同一筆資料上互相衝突的鎖定操作。
那把這筆資料的門鎖上之後,BuJo 接下來做了什麼呢?
我們直接進 Code 裡看最清楚。
下面只保留和這次「避免活動人數超收」直接相關的部分:
// 把鎖定、重新讀取、檢查與寫入
// 放在同一個 Transaction(交易)裡
const outcome = await prisma.$transaction(async (tx) => {
// 先取得指定 Activity Row 的 Row-Level Lock
// 同一場 Activity 上衝突的 FOR UPDATE 會先等待
await tx.$queryRaw`
SELECT id
FROM activities
WHERE id = ${id}
FOR UPDATE
`;
// 把這筆資料的門鎖上之後,才重新讀取最新資料
const activity = await tx.activity.findUnique({
where: { id },
include: {
participants: {
where: { status: "joined" },
},
schedule: true,
candidateSlots: {
include: { availabilities: true },
},
},
});
const currentCount = activity.participants.length;
// 用最新人數判斷是否額滿
if (
activity.participant_target &&
currentCount >= activity.participant_target
) {
return {
status: 400,
message: req.t("activity.full"),
};
}
// 還有名額,才建立這筆報名
await tx.activityParticipant.create({
data: {
activity_id: id,
user_id: userId,
},
});
// 真實 Code 後面還會繼續處理時段與站內通知
return null;
});
這段最重要的是整個順序:
先把這筆資料的門鎖上
↓
重新讀取最新資料
↓
計算 currentCount
↓
檢查有沒有額滿
↓
還有名額才 create()
所以前面的 SELECT ... FOR UPDATE 主要負責把這筆資料保護起來。
真正拿來判斷人數的 Activity 和 Participants,則是在取得鎖之後才重新讀取。
還是回到:
人數上限:10
目前人數:9
這次有了 Row-Level Lock(資料列層級鎖定)之後:
| 執行順序 | Request A | Request B |
|---|---|---|
| 1 | 取得 Activity 的 Row-Level Lock | 嘗試取得同一筆鎖 |
| 2 | 繼續執行 | 等待 |
| 3 | 重新讀取,看到目前 9 人 | 等待 |
| 4 | 建立第 10 筆報名 | 等待 |
| 5 | Transaction 提交、釋放鎖 | 取得鎖 |
| 6 | 已完成 | 重新讀取,看到目前 10 人 |
| 7 | 已完成 | 判斷額滿,不建立第 11 筆 |
所以 Request B 等輪到自己之後,不會繼續拿原本的 9 人來判斷,而是重新讀到最新的 10 人:
現在已經 10 人
→ 額滿
→ 不建立報名
這樣原本的規則:
活動人數不能超過
participant_target
即使在兩個 Request 幾乎同時抵達的情況下,還是守得住。
看到這裡,還有一個很重要的問題:
這把 Row-Level Lock(資料列層級鎖定),到底會鎖到什麼時候?
BuJo 的 SELECT ... FOR UPDATE 不是執行完那一行就立刻解鎖。
它會一路維持到目前這個 Transaction 結束:
先把這筆資料的門鎖上
↓
重新讀取最新資料
↓
檢查是否額滿
↓
建立報名
↓
Transaction 結束
↓
釋放鎖
這也是為什麼 FOR UPDATE 不能只單獨出現一下。
如果鎖完馬上結束 Transaction,再跑去外面檢查人數、建立報名,中間就又會留下其他 Request 可以插進來的空隙。
所以這裡真正要想的,不只是:
「哪一行需要加 Lock?」
而是:
「這把 Lock 要一路保護到哪裡,才可以放開?」
在 BuJo 的報名流程裡,答案就是:
一路保護到這次報名需要保持一致的資料庫操作完成。
而這裡的 Transaction,除了讓相關資料庫操作維持在同一個流程裡,也剛好決定了這把 Row-Level Lock 的生命週期。
等 Transaction Commit 或 Rollback 之後,這把鎖才會真正被釋放。
BuJo 報名後如果剛好達到成團人數,也可能需要發送 LINE 外部通知。
但這段沒有一起塞進剛才的 Transaction:
// Transaction 成功提交後
// 才處理 LINE 外部通知
if (formationReadyCreatorId) {
await sendActivityLifecycleLineNotifications({
userIds: [formationReadyCreatorId],
activityId: id,
type: NOTIFICATION_TYPES.FORMATION_READY,
});
}
因為 LINE 通知是一個外部 HTTP Request。
如果外部服務回得比較慢,而我們還在 Transaction 裡等它:
剛才取得的 Row-Level Lock 也可能跟著維持更久。
後面想報名同一場 Activity 的 Request,就得一起多等。
另外,外部訊息一旦真的送出去,也不像資料庫寫入一樣,可以因為 Transaction Rollback(交易回滾)就一起收回。
所以 BuJo 在這裡把兩邊分開:
Transaction 裡
├─ 鎖定 Activity
├─ 重新讀取資料
├─ 檢查人數
├─ 建立報名
└─ 處理需要一起保持一致的資料庫寫入
Transaction 成功提交後
└─ 發送 LINE 外部通知
這不是說所有系統的外部呼叫都一定不能放進 Transaction。
而是這裡要特別考慮:
哪些事情真的需要跟著這把鎖一起被保護?
不需要的工作,就沒必要讓鎖陪著一起等。
BuJo 也替「活動已經額滿」留下了 Test。
這裡只保留和今天主題直接有關的部分:
// 模擬目前已經達到 participant_target
prisma.activity.findUnique.mockResolvedValue(
makeActivity({
status: "recruiting",
participant_target: 1,
participants: [makeParticipant(CREATOR_ID)],
}),
);
await joinActivity(
makeReq({ userId: PARTICIPANT_ID }),
res,
);
// 報名流程有先執行鎖定查詢
expect(prisma.$queryRaw).toHaveBeenCalled();
// 額滿後不能再建立新的 Participant
expect(
prisma.activityParticipant.create,
).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
message: "活動人數已滿",
});
這個 Test 可以確認:
報名流程有執行鎖定查詢
+
目前已額滿
↓
不建立新的 Participant
↓
回傳 400
不過這裡要注意一個邊界。
這個 Test 使用的是 Mock(模擬物件),所以它能驗證的是:
Code 有把鎖定查詢放進報名流程,而且額滿之後不會繼續新增參與者。
它並沒有真的啟動兩個 Transaction 去競爭同一筆 Row-Level Lock。
如果要驗證「第二個 Request 真的會在 PostgreSQL 裡等待」,就需要再搭配連接真實測試資料庫的整合測試。
雖然個性差很多,但它們其實沒有誰一定比較好。
| 比較項目 | Optimistic Locking(樂觀鎖) | Pessimistic Locking(悲觀鎖) |
|---|---|---|
| 基本想法 | 先讓操作進行,真正寫入時再確認 | 先鎖住資料,讓衝突操作暫時等待 |
| 優點 | 沒有衝突時,不需要先讓其他操作等待 | 需要根據最新資料做判斷時,可以先把衝突操作排開 |
| 代價 | 發生衝突時,要另外處理失敗、回報或重試 | 可能增加等待時間,也要控制鎖定範圍與時間 |
| 適合情境 | 衝突較少,而且可以接受其中一次操作最後失敗 | 容易爭搶有限資源,而且不能讓多人同時通過 |
| BuJo 案例 | 確認成團時,避免重複狀態轉換與通知 | 報名最後一個名額時,避免人數超過上限 |
所以並不是:
悲觀鎖比較安全
→ 永遠用悲觀鎖
也不是:
樂觀鎖不用等待
→ 永遠用樂觀鎖
真正要看的,是這條業務規則能不能接受:
大家先往下做
↓
真的發生衝突
↓
其中一個操作最後失敗
還是它更適合:
先讓衝突操作排隊
↓
輪到自己時
↓
再根據最新資料判斷
BuJo 的確認成團,可以接受其中一個 Request 因為 State 已經被改變,而收到 409 Conflict。
但「最後一個名額」真正要守住的是:
不能因為兩個人同時報名,就讓第 11 個人一起進來。
所以兩個情境,最後選了不同的並發控制方式。
如果樂觀鎖像那種:
「先做再說啦!真的撞到了再處理!」
的朋友,
那悲觀鎖就像那種做事情前,會先把可能出問題的地方都想一遍的朋友(笑)。
它看到只剩最後一個名額,不會先假設:
「應該不會剛好兩個人一起來吧。」
而是會先想:
「如果真的同時來了呢?」
所以乾脆先把順序排好,再一個一個處理。
我自己覺得悲觀鎖有趣的地方,是平常同時操作的人不多時,可能很難真的感覺到它帶來的差異。
但如果很多 Request 都在搶同一份有限資源,像是最後一個名額、熱門票券或有限庫存,事情就不一樣了。
這時候:
鎖哪裡、鎖多久、多少 Request 要一起等,可能都會直接影響整個系統的表現。
所以真正要看的,不只是使用者多不多,而是:
「這是不是一筆很多人會同時爭搶,而且不能出錯的資料?」
如果答案是「是」,那即使系統很大,悲觀鎖還是可能很合理。
只是這時候,就更需要控制鎖定的範圍和時間;流量再大時,也可能需要搭配排隊、暫時保留名額等其他機制。
所以現在再看 SELECT ... FOR UPDATE,我已經不會只把它當成一段 SQL。
反而會開始多想一層:
這筆資料真的需要先鎖嗎?如果需要,又該鎖到哪裡才剛剛好?
我覺得這也是悲觀鎖最值得理解的地方。
它不是單純「比較小心」。
而是當很多人同時搶同一份資源時,怎麼小心,本身也會變成系統設計的一部分。
兩個可愛的鎖介紹完了,明天要換另一個讓我腦袋打結的地方了(笑)。
當大家填了一堆「我有空的時間」,BuJo 到底要怎麼把這些重疊的時段算出來?
下一篇,就來拆 候選時段重疊計算演算法。