上一篇談了悲觀鎖(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 把上面的這些設計實作出來。