如何在EF Core中实现悲观锁

问题描述

在高流量场景下,绝对需要确保一次只有一个进程可以修改一块数据。假设你正在为一个极其受欢迎的音乐会构建售票系统。顾客们热切地抢购门票,最后几张票可能同时被售出。如果你不小心,多个顾客可能认为他们已经确保了最后的座位,导致超售!

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查询。使用上述方法进行行级锁定。任何竞争的事务都将失败,直到当前事务释放锁定。这是一个简单的实现悲观锁的方法, 但在实际系统开发中很有用。

posted @ 2024-04-20 11:53  羽扇冠巾  阅读(257)  评论(0)    收藏  举报