前兩篇分別談了樂觀鎖與悲觀鎖的理論,以及購票系統各環節的設計決策,這篇將直接進入程式碼實作,把設計思路轉化成可以實際運行的實作。
資料模型
先定義三個核心 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 變得不可分割。
1// Services/TicketInventoryService.cs
2public class TicketInventoryService
3{
4 private readonly IDatabase _redis;
5
6 public TicketInventoryService(IConnectionMultiplexer mux)
7 {
8 _redis = mux.GetDatabase();
9 }
10
11 // 庫存為 null(key 不存在)回傳 -1
12 // 庫存 <= 0 回傳 0(售罄)
13 // 成功扣減則回傳剩餘數量
14 private const string DecrIfPositiveScript = """
15 local current = tonumber(redis.call('GET', KEYS[1]))
16 if current == nil then return -1 end
17 if current <= 0 then return 0 end
18 return redis.call('DECRBY', KEYS[1], tonumber(ARGV[1]))
19 """;
20
21 public async Task<ReservationResult> TryReserveAsync(int eventId, string ticketType, int qty)
22 {
23 var key = $"inventory:{eventId}:{ticketType}";
24 var result = (long)await _redis.ScriptEvaluateAsync(
25 DecrIfPositiveScript,
26 new RedisKey[] { key },
27 new RedisValue[] { qty }
28 );
29
30 return result switch
31 {
32 -1 => ReservationResult.KeyNotFound,
33 0 => ReservationResult.SoldOut,
34 _ => ReservationResult.Success
35 };
36 }
37
38 // 付款失敗或超時,歸還預扣的庫存
39 public async Task ReleaseAsync(int eventId, string ticketType, int qty)
40 {
41 var key = $"inventory:{eventId}:{ticketType}";
42 await _redis.StringIncrementAsync(key, qty);
43 }
44
45 // 初始化庫存(開賣前呼叫)
46 public async Task InitInventoryAsync(int eventId, string ticketType, int totalQty)
47 {
48 var key = $"inventory:{eventId}:{ticketType}";
49 await _redis.StringSetAsync(key, totalQty);
50 }
51}
52
53public enum ReservationResult { Success, SoldOut, KeyNotFound }
Redis 層:座位鎖定
SET NX EX 本身就是原子操作,不需要額外 Lua Script,但在釋放鎖時要確認是自己的鎖才能刪除,否則可能誤刪別人的鎖;這個「確認 + 刪除」的動作需要用 Lua Script 確保原子性。
1// Services/SeatLockService.cs
2public class SeatLockService
3{
4 private readonly IDatabase _redis;
5 private static readonly TimeSpan LockExpiry = TimeSpan.FromMinutes(10);
6
7 public SeatLockService(IConnectionMultiplexer mux)
8 {
9 _redis = mux.GetDatabase();
10 }
11
12 public async Task<bool> TryLockSeatAsync(string seatId, string userId)
13 {
14 var key = $"seat:lock:{seatId}";
15
16 // SET NX EX:Key 不存在時才設定,並附上過期時間
17 // 本身就是原子操作,不需要額外 Lua Script
18 return await _redis.StringSetAsync(key, userId, LockExpiry, When.NotExists);
19 }
20
21 public async Task<bool> ReleaseSeatAsync(string seatId, string userId)
22 {
23 var key = $"seat:lock:{seatId}";
24
25 // 若直接用 GET + DEL,兩個步驟之間可能有 race condition
26 // Lua Script 確保「確認擁有者 + 刪除」是原子操作
27 const string releaseScript = """
28 if redis.call('GET', KEYS[1]) == ARGV[1] then
29 return redis.call('DEL', KEYS[1])
30 else
31 return 0
32 end
33 """;
34
35 var result = (long)await _redis.ScriptEvaluateAsync(
36 releaseScript,
37 new RedisKey[] { key },
38 new RedisValue[] { userId }
39 );
40
41 return result == 1;
42 }
43
44 public async Task<string?> GetSeatOwnerAsync(string seatId)
45 {
46 var value = await _redis.StringGetAsync($"seat:lock:{seatId}");
47 return value.HasValue ? (string?)value : null;
48 }
49
50 public async Task<TimeSpan?> GetRemainingLockTimeAsync(string seatId)
51 {
52 return await _redis.KeyTimeToLiveAsync($"seat:lock:{seatId}");
53 }
54}
PostgreSQL 層:庫存扣減(悲觀鎖)
FOR UPDATE 在 SELECT 的當下就鎖住該 row,後來的 transaction 必須等待前一個完成才能繼續;這個動作確保「查詢到寫入」之間不會有其他操作插入,是十分有效的保障;更多詳情可以參考 PostgreSQL Explicit Locking。
1// Services/TicketPurchaseService.cs
2public class TicketPurchaseService
3{
4 private readonly AppDbContext _db;
5
6 public TicketPurchaseService(AppDbContext db)
7 {
8 _db = db;
9 }
10
11 public async Task<Order?> ConfirmPurchaseAsync(
12 int eventId, string ticketType, string userId, int qty)
13 {
14 // 使用 ReadCommitted 隔離級別,避免髒讀
15 await using var tx = await _db.Database.BeginTransactionAsync(
16 IsolationLevel.ReadCommitted
17 );
18
19 try
20 {
21 // FOR UPDATE:鎖住這一行直到 transaction 結束
22 // 其他 transaction 如果也想 FOR UPDATE 同一行,必須等待
23 var ticket = await _db.Tickets
24 .FromSqlRaw("""
25 SELECT * FROM tickets
26 WHERE event_id = {0} AND ticket_type = {1}
27 FOR UPDATE
28 """, eventId, ticketType)
29 .FirstOrDefaultAsync();
30
31 if (ticket is null || ticket.AvailableQty < qty)
32 {
33 await tx.RollbackAsync();
34 return null;
35 }
36
37 ticket.AvailableQty -= qty;
38
39 var order = new Order
40 {
41 UserId = userId,
42 Status = OrderStatus.Pending,
43 Version = 1
44 };
45
46 _db.Orders.Add(order);
47 await _db.SaveChangesAsync();
48 await tx.CommitAsync();
49
50 return order;
51 }
52 catch
53 {
54 await tx.RollbackAsync();
55 throw;
56 }
57 }
58}
PostgreSQL 層:訂單狀態更新(樂觀鎖)
訂單狀態流轉不需要阻擋其他操作,只需要防止同一筆訂單被重複處理,因此可以使用 version 欄位的條件更新讓第二次相同的請求自然失敗,不需要另外加鎖。
TryUpdateUserInfoAsync 則示範 EF Core 內建的 RowVersion 機制:EF Core 在 SaveChanges 時會自動把 xmin 帶入 WHERE 條件,衝突時拋出 DbUpdateConcurrencyException。
1// Services/OrderService.cs
2public class OrderService
3{
4 private readonly AppDbContext _db;
5 private const int MaxRetries = 3;
6
7 public OrderService(AppDbContext db)
8 {
9 _db = db;
10 }
11
12 public async Task<PaymentResult> MarkAsPaidAsync(int orderId)
13 {
14 for (int attempt = 0; attempt < MaxRetries; attempt++)
15 {
16 try
17 {
18 // affected == 0 代表訂單已被處理過(防止重複付款)
19 // affected == 1 代表成功
20 var affected = await _db.Database.ExecuteSqlRawAsync("""
21 UPDATE orders
22 SET status = 'Paid',
23 version = version + 1
24 WHERE id = {0}
25 AND status = 'Pending'
26 AND version = (SELECT version FROM orders WHERE id = {0})
27 """, orderId);
28
29 return affected == 1
30 ? PaymentResult.Success
31 : PaymentResult.AlreadyProcessed;
32 }
33 catch (DbUpdateConcurrencyException) when (attempt < MaxRetries - 1)
34 {
35 // 使用指數退避,避免重試風暴
36 await Task.Delay(TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)));
37 }
38 }
39
40 return PaymentResult.Failed;
41 }
42
43 // 使用 EF Core 內建的樂觀鎖
44 public async Task<bool> TryUpdateUserInfoAsync(int userId, string newPhone)
45 {
46 for (int attempt = 0; attempt < MaxRetries; attempt++)
47 {
48 try
49 {
50 var user = await _db.Users.FindAsync(userId);
51 if (user is null) return false;
52
53 user.Phone = newPhone;
54
55 // EF Core 在 SaveChanges 時自動帶入 RowVersion 做 WHERE 條件
56 // 若 xmin 不吻合,拋出 DbUpdateConcurrencyException
57 await _db.SaveChangesAsync();
58 return true;
59 }
60 catch (DbUpdateConcurrencyException) when (attempt < MaxRetries - 1)
61 {
62 _db.ChangeTracker.Clear(); // 清除快取,重新讀取最新資料
63 await Task.Delay(TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)));
64 }
65 }
66
67 return false;
68 }
69}
70
71public enum PaymentResult { Success, AlreadyProcessed, Failed }
API 層:完整串接流程
1// Controllers/TicketController.cs
2[ApiController]
3[Route("api/tickets")]
4public class TicketController : ControllerBase
5{
6 private readonly TicketInventoryService _inventory;
7 private readonly SeatLockService _seatLock;
8 private readonly TicketPurchaseService _purchase;
9
10 public TicketController(
11 TicketInventoryService inventory,
12 SeatLockService seatLock,
13 TicketPurchaseService purchase)
14 {
15 _inventory = inventory;
16 _seatLock = seatLock;
17 _purchase = purchase;
18 }
19
20 [HttpPost("purchase")]
21 public async Task<IActionResult> Purchase([FromBody] PurchaseRequest req)
22 {
23 var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
24
25 // Step 1:Redis 原子扣減庫存(毫秒級,第一道防線)
26 var reserveResult = await _inventory.TryReserveAsync(req.EventId, req.TicketType, req.Qty);
27
28 if (reserveResult == ReservationResult.SoldOut)
29 return Conflict(new { message = "票券已售罄" });
30
31 if (reserveResult == ReservationResult.KeyNotFound)
32 return NotFound(new { message = "票種不存在" });
33
34 // Step 2:鎖定座位(僅對號座)
35 if (req.SeatId is not null)
36 {
37 var locked = await _seatLock.TryLockSeatAsync(req.SeatId, userId);
38 if (!locked)
39 {
40 // 座位被佔,歸還 Redis 預扣的庫存
41 await _inventory.ReleaseAsync(req.EventId, req.TicketType, req.Qty);
42 return Conflict(new { message = "座位已被選取,請重新選擇" });
43 }
44 }
45
46 // Step 3:PostgreSQL FOR UPDATE 確認扣減(最終一致性保底)
47 var order = await _purchase.ConfirmPurchaseAsync(
48 req.EventId, req.TicketType, userId, req.Qty
49 );
50
51 if (order is null)
52 {
53 // DB 扣減失敗,回滾所有預扣
54 await _inventory.ReleaseAsync(req.EventId, req.TicketType, req.Qty);
55 if (req.SeatId is not null)
56 await _seatLock.ReleaseSeatAsync(req.SeatId, userId);
57
58 return Conflict(new { message = "購票失敗,請重試" });
59 }
60
61 return Ok(new
62 {
63 OrderId = order.Id,
64 Message = "請於 10 分鐘內完成付款",
65 ExpiresAt = DateTime.UtcNow.AddMinutes(10)
66 });
67 }
68
69 // 查詢座位剩餘鎖定時間(可於前端顯示倒數用)
70 [HttpGet("seats/{seatId}/lock-status")]
71 public async Task<IActionResult> GetSeatLockStatus(string seatId)
72 {
73 var owner = await _seatLock.GetSeatOwnerAsync(seatId);
74 var remaining = await _seatLock.GetRemainingLockTimeAsync(seatId);
75
76 if (owner is null)
77 return Ok(new { status = "available" });
78
79 return Ok(new
80 {
81 status = "locked",
82 remainingSeconds = (int)(remaining?.TotalSeconds ?? 0)
83 });
84 }
85}
86
87public record PurchaseRequest(int EventId, string TicketType, int Qty, string? SeatId);
Dependency Injection 與設定
1// Program.cs
2var builder = WebApplication.CreateBuilder(args);
3
4// PostgreSQL + EF Core
5builder.Services.AddDbContext<AppDbContext>(options =>
6 options.UseNpgsql(builder.Configuration.GetConnectionString("Postgres")));
7
8// Redis(StackExchange.Redis)
9builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
10 ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
11
12// 注入 Services
13builder.Services.AddScoped<TicketInventoryService>();
14builder.Services.AddScoped<SeatLockService>();
15builder.Services.AddScoped<TicketPurchaseService>();
16builder.Services.AddScoped<OrderService>();
17
18var app = builder.Build();
19app.MapControllers();
20app.Run();
1// appsettings.json
2{
3 "ConnectionStrings": {
4 "Postgres": "Host=localhost;Database=YourDatabase;Username=app;Password=secret",
5 "Redis": "localhost:6379"
6 }
7}