iT邦幫忙

2024 iThome 鐵人賽

DAY 28
0

昨天我們對售票 API 進行了壓力測試,可以看到結果是慘不忍睹,RPS 只 27 ,今天我們要好好地來優化購票 API。

增加 Log

首先我們要先知道整個流程裡面到底是哪個操作導致 RPS 低下的,我們需要對購票 API 增加一些 Log 來看看是誰在搞鬼

try
    {
        // 取得使用者名稱
        model.Username = user.Identity!.Name;
        // 判斷活動是否存在
        var checkEventStopWatch = new Stopwatch();
        checkEventStopWatch.Start();
        var eventObj = await redisService.HashGetAllAsync($"Event:{model.EventId}");
        if (eventObj.Count == 0)
        {
            return Results.BadRequest("Event not found");
        }
        checkEventStopWatch.Stop();
        app.Logger.LogInformation($"Check Event Elapsed: {checkEventStopWatch.ElapsedMilliseconds} ms");
        // 判斷座位是否存在
        var checkSeatStopWatch = new Stopwatch();
        checkSeatStopWatch.Start();
        var seats = JsonSerializer.Deserialize<List<Seat>>(eventObj["Seats"]);
        if ((!seats?.Any(t => t.Id == model.SeatId)) ?? true)
        {
            return Results.BadRequest("Seat not found");
        }
        checkSeatStopWatch.Stop();
        app.Logger.LogInformation($"Check Seat Elapsed: {checkSeatStopWatch.ElapsedMilliseconds} ms");
        // 判斷座位是否已售出
        var checkTicketStopWatch = new Stopwatch();
        checkTicketStopWatch.Start();
        var key = $"Ticket:{model.EventId}:{model.SeatId}";
        if (await redisService.KeyExistsAsync(key))
        {
            return Results.BadRequest("Ticket already exists");
        }
        checkTicketStopWatch.Stop();
        app.Logger.LogInformation($"Check Ticket Elapsed: {checkTicketStopWatch.ElapsedMilliseconds} ms");
        // 儲存購票紀錄
        var saveTicketStopWatch = new Stopwatch();
        saveTicketStopWatch.Start();
        var addSet = await redisService.SetAddAsync($"Seat:{model.EventId}", model.SeatId.ToString());
        saveTicketStopWatch.Stop();
        app.Logger.LogInformation($"Save Ticket Elapsed: {saveTicketStopWatch.ElapsedMilliseconds} ms");
        // 購票成功在 Redis 暫存購票紀錄
        if (addSet)
        {
            var addTicketStopWatch = new Stopwatch();
            addTicketStopWatch.Start();
            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));
            addTicketStopWatch.Stop();
            app.Logger.LogInformation($"Add Ticket Elapsed: {addTicketStopWatch.ElapsedMilliseconds} ms");
        }
        else
        {
            return Results.BadRequest("Seat already sold");
        }
        return Results.Created($"/api/ticket/{model.EventId}/{model.SeatId}", model);
    }
    catch (Exception ex)
    {
        return Results.Problem(
        detail: ex.StackTrace,
        statusCode: 500,
        title: ex.Message);
    }

找出根因

我們針對有 每個主要流程加入 Log 再到 Cloud Logging 看看是誰在搞鬼

https://ithelp.ithome.com.tw/upload/images/20240929/20168312fx8NAEeBJT.png
https://ithelp.ithome.com.tw/upload/images/20240929/20168312gVX2HTH1CE.png
https://ithelp.ithome.com.tw/upload/images/20240929/20168312H8gJtnQDRN.png
https://ithelp.ithome.com.tw/upload/images/20240929/20168312GW596lBhL0.png
可以看到除了 Save Ticker 跟 Add Ticket 其他三個操作所需的時間都還有優化的空間。

優化

活動檢查

先從效能最差的步驟開始優化,來看一下 判斷活動是否存在的程式碼片段

// 判斷活動是否存在
var checkEventStopWatch = new Stopwatch();
checkEventStopWatch.Start();
var eventObj = await redisService.HashGetAllAsync($"Event:{model.EventId}");
if (eventObj.Count == 0)
{
    return Results.BadRequest("Event not found");
}
checkEventStopWatch.Stop();

可以看到我們這裡是直接嘗試去取出 Event 的物件,因為後續檢查座位時會需要相關的資訊,我們先忽略檢查座位需要的資訊,單純讓此段程式碼就只做檢查活動是否存在,下方檢查座位因為有用到 eventObj 可以先假定座位都沒問題先註解起來。

  // 判斷活動是否存在
  var checkEventStopWatch = new Stopwatch();
  checkEventStopWatch.Start();
  if (!await redisService.KeyExistsAsync($"Event:{model.EventId}"))
  {
      return Results.BadRequest("Event not found");
  }
  checkEventStopWatch.Stop();
  app.Logger.LogInformation($"Check Event Elapsed: {checkEventStopWatch.ElapsedMilliseconds} ms");

接著用相同的條件 100 vus 壓 30 秒,可以看到所需的時間大幅縮小了

https://ithelp.ithome.com.tw/upload/images/20240929/20168312xBN45VQehv.png

座位檢查

但是目前流程是沒有座位是否存在的檢查,我們需要加回去座位檢查,但又不希望每次都從 Redis 取出大量的資料來做來判斷,我們可以怎麼做呢?這裡我想到的方法是調整資料結構,原本 Redis 內 Seat Set 是存放已經被購買的票,可以反過來 Set 內存放所有位置資訊,不做 Add 而是使用 Remove,如果 Remove 失敗表示座位已經售出或是不存在,再判斷是已售出或不存在。

首先修改寫入 Redis 的部分,新增寫入全部座位的 Seat

var entry = await context.Event.Include(e => e.Seats).FirstOrDefaultAsync(e => e.Id == id);
if (entry == null)
{
    return Results.NotFound();
}
//HashSetAllAsync
await redisService.HashSetAllAsync($"Event:{entry.Id}", new Dictionary<string, string>
{
    { "Name", entry.Name },
    { "EventDate", entry.EventDate.ToString() },
    { "StartSalesDate", entry.StartSalesDate.ToString() },
    { "EndSalesDate", entry.EndSalesDate.ToString() },
    { "Description", entry.Description },
    { "Remark", entry.Remark },
    { "Seats", JsonSerializer.Serialize(entry.Seats) }
});
await redisService.HashSetAsync($"Event:{entry.Id}", "Seats", JsonSerializer.Serialize(entry.Seats));
// 寫入全部座位
await redisService.SetAddAsync($"Seat:{entry.Id}", entry.Seats?.Select(t => t.Id.ToString()).ToList() ?? []);
return Results.Ok(entry);

再來調整購票 API 檢查座位的部分,改用 Remove 的方式,調整完畢的購票 API 如下

app.MapPost("/api/ticket", async (
    TicketViewModel model,
    [FromServices] RedisService redisService,
    [FromServices] PublisherService publisherService,
     ClaimsPrincipal user) =>
{
    try
    {
        // 取得使用者名稱
        model.Username = user.Identity!.Name;
        // 判斷活動是否存在
        var checkEventStopWatch = new Stopwatch();
        checkEventStopWatch.Start();
        if (!await redisService.KeyExistsAsync($"Event:{model.EventId}"))
        {
            return Results.BadRequest("Event not found");
        }
        checkEventStopWatch.Stop();
        app.Logger.LogInformation($"Check Event Elapsed: {checkEventStopWatch.ElapsedMilliseconds} ms");
        // 判斷座位是否存在
        var checkSeatStopWatch = new Stopwatch();
        checkSeatStopWatch.Start();
        var key = $"Ticket:{model.EventId}:{model.SeatId}";
        if (!await redisService.SetRemoveAsync($"Seat:{model.EventId}", model.SeatId.ToString()))
        {
            // 座位不存在 Set 判斷是否已售出
            var checkTicketStopWatch = new Stopwatch();
            checkTicketStopWatch.Start();
            if (await redisService.KeyExistsAsync(key))
            {
                checkTicketStopWatch.Stop();
                app.Logger.LogInformation($"Check Ticket Elapsed: {checkTicketStopWatch.ElapsedMilliseconds} ms");
                return Results.BadRequest("Seat already sold");
            }
            else
            {
                checkSeatStopWatch.Stop();
                app.Logger.LogInformation($"Check Seat Elapsed: {checkSeatStopWatch.ElapsedMilliseconds} ms");
                return Results.BadRequest("Seat not found");
            }
        }
        // 購票成功在 Redis 暫存購票紀錄
        var addTicketStopWatch = new Stopwatch();
        addTicketStopWatch.Start();
        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));
        addTicketStopWatch.Stop();
        app.Logger.LogInformation($"Add Ticket Elapsed: {addTicketStopWatch.ElapsedMilliseconds} ms");
        return Results.Created($"/api/ticket/{model.EventId}/{model.SeatId}", model);
    }
    catch (Exception ex)
    {
        return Results.Problem(
        detail: ex.StackTrace,
        statusCode: 500,
        title: ex.Message);
    }
})
.RequireAuthorization();

因為資料結構已經有所改變,要建立一個新的活動來做測試,可以用先前寫好的 k6 腳本再建立一個新的活動,再重新針對這個活動進行購票的測試

https://ithelp.ithome.com.tw/upload/images/20240929/20168312e4GTGEmAHE.png
可以看到調整過後 一樣 100 vus 壓 30 秒 RPS 可以來到 接近 900。

再重新確認一次每個流程所花費的時間

https://ithelp.ithome.com.tw/upload/images/20240929/201683128rFG86IW3x.png
https://ithelp.ithome.com.tw/upload/images/20240929/20168312hBK7JJvzwP.png
https://ithelp.ithome.com.tw/upload/images/20240929/20168312UTMQuiPGyN.png
https://ithelp.ithome.com.tw/upload/images/20240929/20168312Hl0MC9u5kx.png
除了推送 Message 給 Pub/Sub 其他流程大致上都是< 1ms 的處理時間,理論上 RPS 應該能到打到更高,我們將 vus 做一個調升,拉到 300 vus 試試

https://ithelp.ithome.com.tw/upload/images/20240929/20168312Fb1DegJwhd.png
QPS 已經上升到 1338 這個應該是我個人電腦能夠壓到的極限,我同時在我的 MAC 進行壓測

https://ithelp.ithome.com.tw/upload/images/20240929/20168312reu6XpgR2t.png
兩個裝置合併大約是 2400 左右的 RPS,實際上能夠承載的 RPS 應該可以到更高,礙於我手邊並沒有其他裝置方便進行測試,壓力測試就先到這個階段。


上一篇
Day27: 實作-壓力測試-K6
下一篇
Day29: 實作-資料驗證
系列文
窮小子的售票系統30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言