問題描述
在高流量場景下,絕對需要確保一次只有一個程序可以修改一塊資料。假設你正在為一個極其受歡迎的音樂會構建售票系統。顧客們熱切地搶購門票,最後幾張票可能同時被售出。如果你不小心,多個顧客可能認為他們已經確保了最後的座位,導致超售!
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查詢。使用上述方法進行行級鎖定。任何競爭的事務都將失敗,直到當前事務釋放鎖定。這是一個簡單的實現悲觀鎖的方法, 但在實際系統開發中很有用。