上一篇談了悲觀鎖(Pessimistic Lock)和樂觀鎖(Optimistic Lock)的基本概念,而這篇要把焦點放在一個具體的場景:演唱會購票系統。

為什麼會選這個場景呢?因為它的併發特性非常極端:開賣瞬間可能湧入數十萬個請求,庫存有限、不能超賣、用戶又非常敏感,幾乎把所有併發控制(Concurrency Control)的挑戰都壓縮在一起了,適合用來思考鎖策略的設計。


系統的核心矛盾

購票系統面臨一個根本的矛盾:速度和正確性永遠在對抗。

為了正確性,你想讓每個請求排隊、一個一個處理,但這樣系統會慢到讓人放棄;為了速度,你想讓所有請求並行處理,但這樣很可能超賣或出現資料不一致;而解決這個矛盾的方式,不是選擇其中一邊,而是在不同的環節用不同的策略。


把系統拆成幾個關鍵環節

庫存扣減:這是整個系統最不能出錯的地方

票券超賣是商業損失,無法事後補救;這裡需要的是一致性而不是高效能,寧願讓請求排隊等待,也必須保證每次扣減前看到的都是真實的剩餘數量。

這是悲觀鎖(Pessimistic Lock)典型的使用場景;在 PostgreSQL 裡,用 SELECT ... FOR UPDATE 鎖住那行資料,讓後來的交易(Transaction)必須等待;這個鎖從查詢的當下持有,直到 Transaction 提交(Commit)或回滾(Rollback)才釋放。

代價是明確的:高併發下這裡會成為瓶頸,請求會大量排隊,但在「超賣後果不可逆」的前提下,這個代價值得付。

座位鎖定:互斥資源,必須強佔

對號座的選位,是另一個互斥資源(Exclusive Resource)的場景,用戶 A 選了 A-12,在她付款完成之前,A-12 不能被用戶 B 選走。

這裡的問題不只是資料庫併發,更是跨請求(Request)的狀態持有;用戶進入付款頁面後,這把鎖需要持續存在 10 分鐘,若超時便自動釋放;資料庫鎖的生命週期只存在於一個 Transaction 裡,無法做到跨 Request 持有。

這時可以改用 Redis 的分散式鎖(Distributed Lock)SET NX EX(不存在才設置,並附上過期時間),第一個選到這個座位的請求成功鎖定,後來的請求看到 key 已存在,直接被拒絕;過了 10 分鐘之後,Redis 的 TTL 自動刪除這個 key,座位自動釋放回可選狀態。

庫存預扣:在資料庫前擋掉大多數請求

純粹依賴 PostgreSQL 的 FOR UPDATE,在演唱會開賣瞬間會承受巨大的資料庫壓力。大量請求湧進來,每個都要等待前一個 Transaction 釋放鎖,資料庫連線很快就會耗盡。

這時可以在資料庫前面加一層 Redis 的原子操作(Atomic Operation):先在 Redis 裡做庫存預扣,用 Lua Script 確保「檢查庫存是否足夠」和「扣減庫存」這兩步是符合原子操作且不可分割;因此大多數「票已售罄」的請求,在這一層就被擋掉了,不需要進入資料庫,只有成功預扣的請求才會繼續往下,讓 PostgreSQL 做最終確認。

訂單狀態更新:防止重複付款,不需要阻擋其他操作

用戶付款完成後,訂單狀態從「待付款」變成「已付款」;這裡的風險是重複處理:用戶快速點了兩次付款按鈕,或第三方金流的回調(Callback)重複送來。

這個場景不需要阻擋其他 Transaction,只需要防止同一個訂單被處理兩次,因此樂觀鎖(Optimistic Lock)的版本號(Version Number)機制剛好夠用:第一次更新成功,版本號從 1 變成 2;第二次進來時,版本號不吻合,更新 0 筆,並判定為「已處理過」;沒有鎖,沒有等待,就沒有死鎖(Deadlock)的風險。

用戶資料更新:幾乎不會衝突,樂觀鎖足夠

用戶修改個人資料(電話、地址)這種操作,同一個人同時在多台裝置改同一個欄位的機率極低,因此這裡用 EF Core 內建的 Version 機制做樂觀鎖(Optimistic Lock)就足夠了,不需要任何額外的邏輯。


Redis 和 PostgreSQL 的分工

把上面的分析整理起來,兩層的分工就很清楚了:

  • Redis 負責速度:庫存預扣和座位鎖定都在 Redis 完成,它的作用是在資料庫承受壓力之前,先用記憶體操作擋掉大量無效請求;開賣瞬間的流量高峰,大多數都會在這一層被消化掉。
  • PostgreSQL 負責正確性:通過 Redis 的請求才進入資料庫,並且使用 FOR UPDATE 做最終的庫存確認,確保即使 Redis 出現任何異常,資料庫層依然能獨立防止超賣。

分層設計的另一個好處是容錯性:Redis 掛掉時,系統效能會大幅下降,但 PostgreSQL 的悲觀鎖(Pessimistic Lock)仍然能獨立確保資料的正確性,系統不會完全崩潰。


一個請求的完整旅程

綜合以上所有環節,一個搶票請求的生命週期大概是這樣:

用戶送出購票請求
        │
        ▼
[Redis] Lua Script 原子(Atomic)扣減庫存
        ├─ 售罄 → 立即回傳,不進入 DB
        │
        ▼
[Redis] SET NX 嘗試鎖定座位(對號座)
        ├─ 座位被佔 → 歸還庫存,拒絕請求
        │
        ▼
[PostgreSQL] FOR UPDATE 確認扣減,建立訂單
        ├─ 失敗(極少)→ 歸還所有預扣
        │
        ▼
進入付款流程,Redis TTL 10 分鐘倒數
        ├─ 超時未付款 → 自動釋放座位,訂單標記逾期
        │
        ▼
[PostgreSQL] version 條件更新,訂單狀態改為已付款
        ├─ 版本不吻合(重複請求)→ 直接忽略
        │
        ▼
出票完成 ✓

設計背後的核心思考

回頭看整個設計,可以發現一個貫穿所有決策的原則:鎖的強度,和出錯的代價成正比。

庫存超賣代價最高,所以用最謹慎的悲觀鎖(Pessimistic Lock)作為基底,再加上 Redis 預扣提高效能;訂單重複付款代價中等,用樂觀鎖(Optimistic Lock)的版本號(Version Number)防止;用戶資料衝突代價最低,EF Core 內建的 RowVersion 就夠了;這不是一個「哪種鎖比較好」的問題,而是「每個環節值得付出多少代價來換取安全性」的問題。


下一篇會直接探討程式碼,用 ASP.NET + Redis + PostgreSQL 把上面的這些設計實作出來。