Skip to content

使用 EF Core 乐观锁解决竞态条件

Published: at 12:00 AM

使用 EF Core 乐观锁解决竞态条件

原文链接 Solving Race Conditions With EF Core Optimistic Locking

编写代码时,你有多频繁地考虑并发冲突?

你为一个新功能编写代码,确认它工作正常,然后就此打住。

但是一周后,你发现自己引入了一个恶劣的错误,因为你没有考虑并发。

最常见的问题是两个竞争线程执行相同功能的竞态条件。如果在开发过程中不考虑这一点,就会引入使系统处于损坏状态的风险。

然后,我会向你展示如何使用 EF Core 乐观并发来解决这一竞态条件。

这段代码有什么问题?

这段代码片段中隐藏着一个竞态条件。

你看出来了吗?

public Result<Guid> Handle(
    ReserveBooking command,
    AppDbContext dbContext)
{
    var user = dbContext.Users.GetById(command.UserId);
    var apartment = dbContext.Apartments.GetById(command.ApartmentId);
    var (startDate, endDate) = command;

    if (dbContext.Bookings.IsOverlapping(apartment, startDate, endDate))
    {
        return Result.Failure<Guid>(BookingErrors.Overlap);
    }

    var booking = Booking.Reserve(apartment, user, startDate, endDate);

    dbContext.Add(booking);

    dbContext.SaveChanges();

    return booking.Id;
}

调用 IsOverlapping 是一个乐观的检查,以查看是否存在指定日期的预订。

if (dbContext.Bookings.IsOverlapping(apartment, startDate, endDate)) { }

如果它返回 true,我们尝试对公寓进行重复预订。因此,我们返回失败,并且方法完成。

但如果它返回 false,我们保留预订并调用 SaveChanges 将更改保存到数据库中。

问题就在这里。

存在并发请求通过 IsOverlapping 检查并尝试预留预订的机会。没有任何并发控制,两个请求都将成功,我们将在数据库中遇到不一致的状态。

那么我们如何解决这个问题?

使用 EF Core 的乐观并发

悲观并发方法在修改数据之前为数据获取锁。这种方法更慢,并导致竞争事务被阻塞,直到释放锁。EF Core 默认不支持这种方法。

你也可以使用 EF Core 的乐观并发来解决这个问题。它不会锁定任何数据,但如果自查询以来数据已更改,则任何数据修改都将无法保存。

要在 EF Core 中实现乐观并发,你需要将某个属性配置为并发令牌。它随实体一起加载和跟踪。当你调用 SaveChanges 时,EF Core 将比较并发令牌的值与数据库中的值。

假设我们使用的是 SQL Server,它具有原生的 rowversion 列。rowversion 在行更新时自动变化,因此它是并发令牌的绝佳选项。

要将 byte[] 属性配置为并发令牌,你可以用 Timestamp 属性装饰它。它将在 SQL Server 中映射为 rowversion 列。

public class Apartment
{
    public Guid Id { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

我更喜欢另一种方法,因为属性会污染实体。

你可以使用 Fluent API 做到相同的事情。我甚至会使用阴影属性来从实体类中隐藏并发令牌。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Apartment>()
        .Property<byte[]>("Version")
        .IsRowVersion();
}

准确的配置将根据你所使用的数据库而有所不同,因此请检查文档。

乐观并发实际如何工作

当我们配置了并发令牌后,情况就发生了变化。

加载 Apartment 实体时,EF 也将加载并发令牌。

SELECT a.Id, a.Version
FROM Apartments a
WHERE a.Id = @p0

当我们调用 SaveChanges 时,更新语句将比较并发令牌的值与数据库中的值:

UPDATE Apartments a
SET a.LastBookedOnUtc = @p0
WHERE a.Id = @p1 AND a.Version = @p2;

如果数据库中的 rowversion 发生变化,更新的行数将为 0

EF Core 期望更新 1 行,因此它将抛出 DbUpdateConcurrencyException,你需要处理这个异常。

处理并发异常

现在你知道了如何使用 EF Core 的乐观并发,你可以修复之前的代码片段。

如果两个并发请求通过了 IsOverlapping 检查,只有一个能完成 SaveChanges 调用。另一个并发请求将在数据库中遇到 Version 不匹配并抛出 DbUpdateConcurrencyException

在出现并发冲突的情况下,我们需要添加一个 try-catch 语句来捕获 DbUpdateConcurrencyException。如何处理实际异常取决于你的业务需求。有时候,竞态条件甚至可能不存在。

public Result<Guid> Handle(
    ReserveBooking command,
    AppDbContext dbContext)
{
    var user = dbContext.Users.GetById(command.UserId);
    var apartment = dbContext.Apartments.GetById(command.ApartmentId);
    var (startDate, endDate) = command;

    if (dbContext.Bookings.IsOverlapping(apartment, startDate, endDate))
    {
        return Result.Failure<Guid>(BookingErrors.Overlap);
    }

    try
    {
        var booking = Booking.Reserve(apartment, user, startDate, endDate);

        dbContext.Add(booking);

        dbContext.SaveChanges();

        return booking.Id;
    }
    catch (DbUpdateConcurrencyException)
    {
        return Result.Failure<Guid>(BookingErrors.Overlap);
    }
}

什么时候应该使用乐观并发?

乐观并发认为最佳场景也是最有可能的场景。它假设事务之间的冲突将不频繁,并不会在数据上获取锁。这意味着你的系统可以更好地扩展,因为没有阻塞减慢性能。

然而,你仍然需要预期并发冲突,并实施自定义逻辑来处理它们。

如果你的应用程序预期冲突不会太多,使用乐观并发是个好选择。

另一个使用乐观并发的理由是,当你不能在事务的整个长度上持有数据库的打开连接时。这对于悲观锁定是必需的。