如何在EF Core中實現悲觀鎖

羽扇冠巾發表於2024-04-20

問題描述

在高流量場景下,絕對需要確保一次只有一個程序可以修改一塊資料。假設你正在為一個極其受歡迎的音樂會構建售票系統。顧客們熱切地搶購門票,最後幾張票可能同時被售出。如果你不小心,多個顧客可能認為他們已經確保了最後的座位,導致超售!

Entity Framework Core是一個好工具,但它沒有直接提供悲觀鎖的機制。其提供的樂觀鎖雖然也可以工作,但在高併發場景下,可能導致很多重試。

這裡有一個簡化的程式碼片段來說明面臨的售票問題:

public async Task Handle(CreateOrderCommand request)
{
    await using DbTransaction transaction = await unitOfWork
        .BeginTransactionAsync();
    Customer customer = await customerRepository.GetAsync(request.CustomerId);
    Order order = Order.Create(customer);
    Cart cart = await cartService.GetAsync(customer.Id);
    foreach (CartItem cartItem in cart.Items)
    {
        // 哎呀...如果兩個請求同時到達這裡怎麼辦?
        TicketType ticketType = await ticketTypeRepository.GetAsync(cartItem.TicketTypeId);
        ticketType.UpdateQuantity(cartItem.Quantity);
        order.AddItem(ticketType, cartItem.Quantity, cartItem.Price);
    }
    orderRepository.Insert(order);
    await unitOfWork.SaveChangesAsync();
    await transaction.CommitAsync();
    await cartService.ClearAsync(customer.Id);
}

解決方案

上面虛構的示例用來演示這個問題。在結賬期間,會驗證每張票的剩餘數量。如果此時多個併發請求嘗試購買同一張票會怎樣?最糟糕的情況是“超售”: 併發請求可能會看到有可售的門票,並完成結賬。

要靠原生SQL來解決這個問題,EF Core呼叫原生SQL查詢也很容易:

public async Task<TicketType> GetWithLockAsync(Guid id)
{
    return await context
        .TicketTypes
        .FromSql(
            $@"SELECT id, event_id, name, price, currency, quantity
            FROM ticketing.ticket_types
            WITH (UPDLOCK)
            WHERE id = {id}") // SQL Server: 鎖定或立即失敗
        .SingleAsync();
}

WITH (UPDLOCK):這是SQL Server中悲觀鎖的核心。它告訴資料庫“鎖定這一行,如果它已經被鎖定,立即丟擲一個錯誤。”

錯誤處理

我們將GetWithLockAsync呼叫包裝在try-catch塊中,以處理鎖定失敗,要麼重試,要麼通知使用者。

結語

由於EF Core沒有內建的方法新增查詢提示,我們透過編寫原始SQL查詢。使用上述方法進行行級鎖定。任何競爭的事務都將失敗,直到當前事務釋放鎖定。這是一個簡單的實現悲觀鎖的方法, 但在實際系統開發中很有用。

相關文章