处理并发冲突

提示

可在 GitHub 上查看此文章的示例

在大多数情况下,数据库会由多个应用程序实例并发使用,每个实例对数据执行独立修改。 在同一时间修改相同的数据时,可能会发生不一致和数据损坏,例如,当两个客户端修改同一行中以某种方式相关的不同列时。 本页将讨论确保数据在发生此类并发更改时保持一致的机制。

开放式并发

EF Core 实现了乐观并发,即假定并发冲突相对较少。 与悲观方法(预先锁定数据,然后再进行修改)相反,乐观并发不会进行锁定,但如果数据自查询后发生更改,则会安排数据修改在保存时失败。 此并发失败会报告给应用程序,应用程序会进行相应处理,例如可能会对新数据重试整个操作。

在 EF Core 中,乐观并发是通过将属性配置为并发令牌来实现的。 该并发令牌会在查询实体时进行加载和跟踪,就像任何其他属性一样。 然后,在 SaveChanges() 期间执行更新或删除操作时,数据库中的并发令牌值将与 EF Core 读取的原始值进行比较。

为了了解其工作原理,让我们假设我们位于 SQL Server 上,并定义一个具有特殊 Version 属性的典型 Person 实体类型:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

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

在 SQL Server 中,这会配置一个并发令牌,该令牌在每次发生行更改时都会在数据库中自动更改(下面提供了更多详细信息)。 完成此配置后,让我们执行一个简单的更新操作,看看会发生什么情况:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. 在第一步中,从数据库加载一个 Person,其中包括并发令牌,EF 会像往常一样跟踪该令牌以及其余属性。
  2. 然后,以某种方式修改 Person 实例 - 我们更改了 FirstName 属性。
  3. 然后,我们指示 EF Core 保留该修改。 由于配置了并发令牌,EF Core 会将以下 SQL 发送到数据库:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

请注意,除了 WHERE 子句中的 PersonId 外,EF Core 还为 Version 添加了一个条件。这样,只有在 Version 列自我们查询以来未发生更改的情况下,才会修改该行。

在正常(“乐观”)情况下,不会发生并发更新,因此 UPDATE 将成功完成,从而修改行;数据库会向 EF Core 报告有一行受到了 UPDATE 的影响,正如预期的那样。 但是,如果发生并发更新,则 UPDATE 无法找到任何匹配的行,并报告没有行受到影响。 因此,EF Core 的 SaveChanges() 会引发一个该应用程序必须正确捕获和处理的 DbUpdateConcurrencyException。 下面在解决并发冲突下详细介绍了执行此操作的方法。

虽然上述示例讨论的是对现有实体的更新, 在尝试删除同时在进行修改的行时,EF 也会引发 DbUpdateConcurrencyException。 但是,添加实体时永远不会引发此异常;虽然如果插入了具有相同键的行,数据库确实可能会引发唯一约束冲突,但这会导致引发特定于提供程序的异常,而不是 DbUpdateConcurrencyException

本机数据库生成的并发令牌

在上面的代码中,我们使用 [Timestamp] 属性将属性映射到 SQL Server rowversion 列。 由于 rowversion 会在行更新时自动更改,因此它作为保护整行的最小工作量并发令牌非常有用。 将 SQL Server rowversion 列配置为并发令牌的方法如下所示:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

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

上面显示的 rowversion 类型是特定于 SQL Server 的功能;设置自动更新并发令牌的细节因数据库而异,且某些数据库根本不支持这些类型(例如 SQLite)。 有关确切的详细信息,请参阅提供商文档。

应用程序管理的并发令牌

你可以在应用程序代码中管理并发令牌,而不是让数据库自动管理。 这允许对不存在本机自动更新类型的数据库(如 SQLite)使用乐观并发。 但是,即使在 SQL Server 上,应用程序管理的并发令牌也可以精确控制哪些列更改将导致令牌重新生成。 例如,你可能有一个包含某些缓存值或非重要值的属性,并且不希望对该属性的更改触发并发冲突。

下面是将 GUID 属性配置为并发令牌:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }

    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

由于此属性不是数据库生成的,因此每当持久保留更改时,都必须在应用程序中分配此属性:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
context.SaveChanges();

如果希望始终分配新的 GUID 值,可以通过 SaveChanges 拦截器执行此操作。 但是,手动管理并发令牌的一个优点是,可以精确控制何时重新生成并发令牌,以避免不必要的并发冲突。

解决并发冲突

无论如何设置并发令牌,若要实现乐观并发,应用程序必须正确处理发生并发冲突并引发 DbUpdateConcurrencyException 的情况:这称为“解决并发冲突”

一个选项是仅通知用户更新因存在冲突更改而失败;然后,用户可以加载新数据,然后重试。 或者,如果应用程序正在执行自动更新,则会直接在重新查询数据后立即循环并重试。

解决并发冲突的一种更复杂的方法是合并数据库中新值的挂起更改。 合并哪些值的确切细节取决于应用程序,并且该过程可以由显示两组值的用户界面来指示。

有三组值可用于帮助解决并发冲突:

  • “当前值”是应用程序尝试写入数据库的值。
  • “原始值”是在进行任何编辑之前最初从数据库中检索的值。
  • “数据库值”是当前存储在数据库中的值。

处理并发冲突的常规方法是:

  1. SaveChanges 期间捕获 DbUpdateConcurrencyException
  2. 使用 DbUpdateConcurrencyException.Entries 为受影响的实体准备一组新更改。
  3. 刷新并发令牌的原始值以反映数据库中的当前值。
  4. 重试该过程,直到不发生任何冲突。

在下面的示例中,将 Person.FirstNamePerson.LastName 设置为并发令牌。 在包括应用程序特定逻辑以选择要保存的值的位置处有一条 // TODO: 注释。

using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = context.People.Single(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";

// Change the person's name in the database to simulate a concurrency conflict
context.Database.ExecuteSqlRaw(
    "UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");

var saved = false;
while (!saved)
{
    try
    {
        // Attempt to save changes to the database
        context.SaveChanges();
        saved = true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            if (entry.Entity is Person)
            {
                var proposedValues = entry.CurrentValues;
                var databaseValues = entry.GetDatabaseValues();

                foreach (var property in proposedValues.Properties)
                {
                    var proposedValue = proposedValues[property];
                    var databaseValue = databaseValues[property];

                    // TODO: decide which value should be written to database
                    // proposedValues[property] = <value to be saved>;
                }

                // Refresh original values to bypass next concurrency check
                entry.OriginalValues.SetValues(databaseValues);
            }
            else
            {
                throw new NotSupportedException(
                    "Don't know how to handle concurrency conflicts for "
                    + entry.Metadata.Name);
            }
        }
    }
}

使用隔离级别进行并发控制

通过并发令牌实现乐观并发并不是确保数据在发生并发更改时保持一致的唯一方法。

确保一致性的一种机制是“可重复读取”事务隔离级别。 在大多数数据库中,此级别可保证事务在数据库中看到的是该事务启动时的数据,而不受任何后续并发活动的影响。 从上面的基本示例来看,当我们查询 Person 以某种方式更新它时,数据库必须确保在该事务完成之前没有其他事务干扰该数据库行。 根据数据库实现情况的不同,这可通过以下两种方式之一进行:

  1. 当查询该行时,事务会对其进行共享锁定。 尝试更新该行的任何外部事务都将受到阻止,直到该事务完成。 这是一种悲观锁定,由 SQL Server“可重复读取”隔离级别实现。
  2. 数据库允许外部事务更新行,而不是锁定,但当你自己的事务尝试对其进行更新时,将引发“序列化”错误,指示发生了并发冲突。 这是一种乐观锁定(与 EF 的并发令牌功能不同),由 SQL Server 快照隔离级别以及 PostgreSQL 可重复读取隔离级别实现。

请注意,“可序列化”隔离级别可提供与可重复读取级别相同的保证(并会额外提供其他保证),因此它的功能与上述级别相同。

使用更高的隔离级别来管理并发冲突则会更简单,不需要并发令牌,且具有其他优势。例如,可重复读取级别可确保事务始终在事务内的所有查询中看到相同的数据,从而避免不一致。 但是,此方法确实有其缺点。

首先,如果数据库实现使用锁定来实现隔离级别,则尝试修改同一行的其他事务必须在整个事务范围内受到阻止。 这可能会对并发性能产生负面影响(请尽可能缩短事务时间!),但请注意,EF 的机制会引发异常,并强制你重试,这也会产生影响。 这适用于 SQL Server 可重复读取级别,但不适用于不会锁定查询行的快照级别。

更重要的是,此方法需要使用一个跨所有操作的事务。 例如,如果查询 Person 以便向某个用户显示其详细信息,然后等待该用户进行更改,那么该事务必须在可能很长的一段时间内保持活动状态(这在大多数情况下都应该避免)。 因此,当包含的所有操作均立即执行并且事务不依赖于可能会增加其持续时间的外部输入时,此机制通常适用。

其他资源

有关包含冲突检测的 ASP.NET Core 示例,请参阅 EF Core 中的冲突检测