前兩篇分別談了樂觀鎖與悲觀鎖的理論,以及購票系統各環節的設計決策,這篇將直接進入程式碼實作,把設計思路轉化成可以實際運行的實作。


資料模型

先定義三個核心 Entity,各自對應不同的鎖策略。

Ticket 代表票種與庫存,Entity 上掛的 RowVersion(對應 PostgreSQL 的 xmin 系統欄位)是樂觀鎖機制,用於後台修改票種名稱、調整票價等一般更新場景。

Order 代表訂單,其中狀態流轉使用樂觀鎖(Optimistic Lock),手動維護 Version 整數欄位,方便在 SQL 層直接用條件更新防止重複付款。

Seat 代表對號座的每一個座位,記錄目前是否被鎖定、被誰鎖定、以及鎖定到什麼時間;它的鎖定狀態由 Redis 的分散式鎖(Distributed Lock)主導,LockedByUserIdLockedUntil 則作為資料庫層的備份紀錄,用於查詢座位狀態或在 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 變得不可分割。

參考:StackExchange.Redis

 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 UPDATESELECT 的當下就鎖住該 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}