iT邦幫忙

2024 iThome 鐵人賽

DAY 25
0

有了活動資訊之後要開始來建立最核心的售票服務,基本上售票的 API 最好是越簡單越好,做的事情越少 API 的 Latency 通常就會越低。

同步處理

正常來說一位使用者買票時需紀錄哪位使用者買了什麼票,並更新票務狀態避免重複購票還有其他後續的操作等等,把這些操作全部都用同步的方式在一個 Request 內處理完當然相對好管理,也可以確定資料的正確性及一致性,但相對的因為這個 API 要做很多事情就會導致效能較差,而且當高併發發生時可能會有需多的交易因為 Lock 或檢核機制被放棄,假如一位使用者送出一個購票的 Request 但在 Request 處理完之前又有其他的使用者送出了相同資訊的購票請求,很可能後者的請求就會是 fail 了,當量一大這種失敗的請求也很可能會拖垮系統。

非同步處理

因此我們設計了將同步轉成非同步的方法,把整個購票過程作解偶,但在這樣的設計之下我們要怎麼確保資料的正確性及一致性呢?

https://ithelp.ithome.com.tw/upload/images/20240926/20168312rQHrdGYGv4.png

資料一致性

首先針對一致性的部分,因為使用了 Pub/Sub 將整個售票流程調整成非同步的架構,它僅能確保的是最終一致性,當 Pub/Sub 的 Message 處理完後所有地方的資料都會是一致的,但當 Message 還在 Pub/Sub 尚未被處理時, Sales Service 從 Redis 拿到的資訊會是最新的狀態,Process Service 並未收到該 Message,此時 Cloud SQL 內的資料狀態與 Redis 就會有時間差,因此在設計應用程式時就需要針對此種情況做一些特別的設計,如當使用者要查詢購票狀態時若 Message 尚未被處理完畢就告知使用者票務正在處理中,請使用者稍後再查詢或是處理完畢後主動通知使用者。

資料正確性

在同步處理的架構可能會使用資料庫的 ACID 特性來確保不會有重複售票的情況發生,但 RDB 為了確保 ACID TPS 會較低,因此我們使用了 Redis 來替代售票時的資料來源,那我們又如何確保不會重複售票呢?

Redis 的資料型態有很多種,我們使用 Hash 來儲存活動資訊,為了確保不會重複售票使用 Set 來儲存已售票的資料,將活動個 Id 當成 Set 的 Key 裡面,而 Value 則存放已經售出的座位編號,Redis 的 Set 可以確保裡面不會出現相同的資料而 QPS 又可以到達百萬級別,同時卻保不會有重複售票又能處理高併發的請求。

Pub/Sub

當然若只在 Redis 內儲存了有哪些票已經賣出去了是不行的,誰買了哪張票也是非常重要的,但我們不希望在購票的當下去處理這些資料的持久化,因此需要將購票資訊推送給 Pub/Sub 交由 Process 來處理資料的持久化。

實作

ViewModel

新增一個 ViewModel 來接收購票傳入的資訊 ViewModel/TicketViewModel.cs,這裡的 Username 可傳入可不傳入,我們實際上會從 Token 取出正確的使用者名稱

namespace iThome2024.SalesService.ViewModel;

public class TicketViewModel
{
    public int EventId { get; set; }
    public int SeatId { get; set; }
    public string? Username { get; set; }
    public DateTime CreateTime { get; set; }
}

購票 API

接著建立售票的 API 可以看到這裡一共還是跟 Redis 交互了 4 次,才完成整個購票流程,並送出購票資訊給 Pub/Sub ,後續我們還是可以對這裡在進行優化,記得要加上 RequireAuthorization 確保Request 一定帶著合法的 Token 近來,我們才能取得 Username。

app.MapPost("/api/ticket", async (
    TicketViewModel model,
    [FromServices] RedisService redisService,
    [FromServices] PublisherService publisherService,
     ClaimsPrincipal user) =>
{
    try
    {
        // 取得使用者名稱
        model.Username = user.Identity!.Name;
        // 判斷活動是否存在
        var eventObj = await redisService.HashGetAllAsync($"Event:{model.EventId}");
        if (eventObj.Count == 0)
        {
            return Results.BadRequest("Event not found");
        }
        // 判斷座位是否存在
        var seats = JsonSerializer.Deserialize<List<Seat>>(eventObj["Seats"]);
        if ((!seats?.Any(t => t.Id == model.SeatId)) ?? true)
        {
            return Results.BadRequest("Seat not found");
        }
        // 判斷座位是否已售出
        var key = $"Ticket:{model.EventId}:{model.SeatId}";
        if (await redisService.KeyExistsAsync(key))
        {
            return Results.BadRequest("Ticket already exists");
        }
        var addSet = await redisService.SetAddAsync($"Seat:{model.EventId}", model.SeatId.ToString());
        // 購票成功在 Redis 暫存購票紀錄
        if (addSet)
        {
            await redisService.HashSetAllAsync(key, new Dictionary<string, string>
            {
                { "EventId", model.EventId.ToString() },
                { "SeatId", model.SeatId.ToString() },
                { "Username", model.Username! },
                { "CreateTime",model.CreateTime.ToString() }
            });
            await publisherService.Publish(JsonSerializer.Serialize(model));
        }
        else
        {
            return Results.BadRequest("Seat already sold");
        }
        return Results.Created($"/api/ticket/{model.EventId}/{model.SeatId}", model);
    }
    catch (Exception ex)
    {
        return Results.BadRequest(ex.Message);
    }
})
.RequireAuthorization();

測試

正常購票

https://ithelp.ithome.com.tw/upload/images/20240926/201683123tgNJvdvX0.png
不存在的活動購票

https://ithelp.ithome.com.tw/upload/images/20240926/20168312ZHM8jFhvVS.png
不存在的座位購票

https://ithelp.ithome.com.tw/upload/images/20240926/20168312McVXAwWQni.png
確認一下 Processor 有接收到購票資訊

https://ithelp.ithome.com.tw/upload/images/20240926/20168312iDDCJzHhUh.png


上一篇
Day24: 實作-開發-管理活動
下一篇
Day26: 實作-開發-Process
系列文
窮小子的售票系統30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言