处理并发冲突

提示

可在 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;

请注意,除了 PersonId WHERE 子句中的 之外,EF Core 还为 Version 添加了条件;这 Version 仅在列自查询后未发生更改时才修改行。

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

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

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

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

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 中的冲突检测