併發問題的根源,大多來自「多個操作同時想動同一份資料」;當系統只有一個使用者時,這不是問題,然而流量一旦上來,很多我們以為不可能同時發生的事,就會同時發生;面對這個問題,思路分成了兩個截然不同的方向。


悲觀鎖:先鎖再說

悲觀鎖(Pessimistic Lock)的世界觀很直接:衝突一定會發生,所以在操作資料之前就先把它鎖住,其他人等我用完再說;它像一個很謹慎的人,進房間之前先把門鎖上,做完事情才開鎖讓別人進來;在這段期間,任何人想進來都必須等待。

資料庫層面最常見的實作是 SELECT ... FOR UPDATE,在查詢的當下就對那幾行資料加上排他鎖,其他 transaction 如果也想修改同一行,就必須等待前一個 transaction 結束:

 1BEGIN;
 2
 3SELECT available_qty
 4FROM tickets
 5WHERE event_id = 1 AND ticket_type = 'VIP'
 6FOR UPDATE;  -- 從這一刻起,這行資料被鎖住
 7
 8-- 確認有票後才扣減
 9UPDATE tickets
10SET available_qty = available_qty - 1
11WHERE event_id = 1 AND ticket_type = 'VIP';
12
13COMMIT;  -- 鎖在這裡釋放

這個做法的代價很明顯:併發效能較低,因為後來的請求都必須排隊等待;而且如果兩個 transaction 互相等待對方釋放鎖,就會發生死鎖(Deadlock);不過在某些場景下,這是正確的選擇,因為有些錯誤是無法事後補救的。


樂觀鎖:先做,衝突了再重來

樂觀鎖(Optimistic Lock)的假設剛好相反:衝突是例外,不是常態,所以不需要提前加鎖,讓大家都能自由讀取和修改;但在最後提交的時候,必須確認:「在操作期間,有沒有人也動過這份資料?」

最常見的實作方式是版本號(Version):每筆資料帶著一個 version 欄位,每次更新都讓版本號 +1;要提交時就帶著讀取時的版本號下條件,如果版本號不吻合,代表有人搶先改過了:

 1-- 讀取時記下版本號
 2SELECT id, available_qty, version
 3FROM tickets
 4WHERE event_id = 1;
 5-- 假設讀到 version = 5
 6
 7-- 提交時帶著版本號做條件更新
 8UPDATE tickets
 9SET available_qty = available_qty - 1,
10    version = version + 1
11WHERE event_id = 1
12  AND version = 5;  -- 如果這時 version 已經變成 6,這行會更新 0 筆
13
14-- 檢查 affected rows:
15-- 1 筆 → 成功
16-- 0 筆 → 衝突,需要重試

樂觀鎖的優點是沒有鎖的開銷,併發效能高,也不存在死鎖問題;然而,其代價是一旦衝突發生,就必須自己處理重試邏輯;如果衝突頻繁,反而比悲觀鎖更耗資源。


兩種鎖,兩種適用場景

不同情境下,判斷應該使用哪一種鎖的核心問題是:這個場景的衝突頻率高不高?衝突發生時的代價是什麼?

衝突頻率高、後果不可逆的場景,選悲觀鎖;例如庫存扣減:同一批票同時被很多人搶,超賣了就是商業損失,沒有辦法事後「反超賣」,因此寧願讓請求排隊,也要保證正確。

衝突頻率低、代價只是重試的場景,選樂觀鎖;像是用戶個人資料更新:同一個人同時在兩台裝置改電話號碼的機率極低,就算真的衝突了,讓後來的那次重試也不是大問題,不需要為了這種低概率事件而加一把永遠排隊的鎖。

可以使用一個簡單原則來判斷:越接近「錢」和「庫存」就選用悲觀鎖,越靠近「用戶私人資料」就選用樂觀鎖。


以購票系統為例

演唱會開賣的瞬間,可能有數十萬人同時搶同一批票;這個場景可以把兩種鎖的適用情境都濃縮在一起:

  • 座位扣減是典型的悲觀鎖場景;庫存只有一份且不能超賣,必須強制讓請求一個一個來,確保每次扣減前看到的都是最新的剩餘座位數字。
  • 訂單狀態更新則適合樂觀鎖;一筆訂單從「待付款」變成「已付款」,只需要防止重複處理,不需要阻擋任何其他操作;用版本號當作條件更新,第二次相同的請求就會自然失敗。

下一篇會深入研究購票系統的各個環節,探討每個地方選擇了不同的鎖策略,以及其背後的設計思考。