用 ASP.NET + Redis + PostgreSQL 實作購票系統的併發控制

前兩篇分別談了樂觀鎖與悲觀鎖的理論,以及購票系統各環節的設計決策,這篇將直接進入程式碼實作,把設計思路轉化成可以實際運行的實作。 資料模型 先定義三個核心 Entity,各自對應不同的鎖策略。 Ticket 代表票種與庫存,Entity 上掛的 RowVersion(對應 PostgreSQL 的 xmin 系統欄位)是樂觀鎖機制,用於後台修改票種名稱、調整票價等一般更新場景。 Order 代表訂單,其中狀態流轉使用樂觀鎖(Optimistic Lock),手動維護 Version 整數欄位,方便在 SQL 層直接用條件更新防止重複付款。 Seat 代表對號座的每一個座位,記錄目前是否被鎖定、被誰鎖定、以及鎖定到什麼時間;它的鎖定狀態由 Redis 的分散式鎖(Distributed Lock)主導,LockedByUserId 和 LockedUntil 則作為資料庫層的備份紀錄,用於查詢座位狀態或在 Redis 異常時做補救。 1// Entities/Ticket.cs 2public class Ticket 3{ 4 public int Id { get; set; } 5 public int EventId { get; set; } 6 public string TicketType { get; set; } = default!; 7 public int AvailableQty { get; set; } 8 9 // EF Core 樂觀鎖:使用 PostgreSQL xmin 系統欄位,免額外欄位 10 // xmin 是每次 row 被更新時自動遞增的系統欄位,完全不需要手動維護 11 [Timestamp] 12 public byte[] RowVersion { get; set; } = default!; 13} 14 15// Entities/Order.cs 16public class Order 17{ 18 public int Id { get; set; } 19 public string UserId { get; set; } = default!; 20 public OrderStatus Status { get; set; } 21 22 // 手動版本號,用於訂單狀態流轉的樂觀鎖 23 public int Version { get; set; } 24} 25 26// Entities/Seat.cs 27public class Seat 28{ 29 public string SeatId { get; set; } = default!; // e.g. "A-12" 30 public int EventId { get; set; } 31 public SeatStatus Status { get; set; } 32 public string? LockedByUserId { get; set; } 33 public DateTime? LockedUntil { get; set; } 34} 35 36public enum OrderStatus { Pending, Paid, Cancelled, Expired } 37public enum SeatStatus { Available, Locked, Sold } 1// Data/AppDbContext.cs 2public class AppDbContext : DbContext 3{ 4 public DbSet<Ticket> Tickets => Set<Ticket>(); 5 public DbSet<Order> Orders => Set<Order>(); 6 public DbSet<Seat> Seats => Set<Seat>(); 7 8 protected override void OnModelCreating(ModelBuilder builder) 9 { 10 // 將 RowVersion 對應到 PostgreSQL 的 xmin 欄位 11 builder.Entity<Ticket>() 12 .Property(t => t.RowVersion) 13 .IsRowVersion() 14 .HasColumnName("xmin") 15 .HasColumnType("xid"); 16 } 17} Redis 層:庫存預扣 單純使用 DECR 可能會有 Race Condition 的風險,在「讀取 → 判斷 → 扣減」三步驟之間可能插入其他操作;使用 Lua Script 則能夠確保 Redis 的原子執行,使得整個 check-and-decrement 變得不可分割。 ...

2026年6月1日

為什麼購票系統需要兩種不同的鎖

上一篇談了悲觀鎖(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 做最終確認。 ...

2026年5月31日

樂觀鎖與悲觀鎖:併發的控制

併發問題的根源,大多來自「多個操作同時想動同一份資料」;當系統只有一個使用者時,這不是問題,然而流量一旦上來,很多我們以為不可能同時發生的事,就會同時發生;面對這個問題,思路分成了兩個截然不同的方向。 悲觀鎖:先鎖再說 悲觀鎖(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);不過在某些場景下,這是正確的選擇,因為有些錯誤是無法事後補救的。 ...

2026年5月30日