在不更新行版本的情况下检查实体的并发性

Checking Concurrency on an Entity without updating the Row Version

我有一个 父实体 ,我需要进行 并发检查 (如下注释)

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

我有一堆 客户端进程 从这个 父实体 [=83= 访问 只读 值],主要是更新它的子实体

约束条件

  1. 客户端不应干扰彼此的工作,(例如,更新子记录不应在父实体[上引发并发异常).

  2. 我有一个服务器进程执行更新这个父实体,在这种情况下,如果父实体已更改,则客户端进程需要抛出。

注意 : 客户端的并发检查是牺牲性的,服务器的工作流是关键任务.

问题

我需要检查(从客户端进程父实体是否已更改而不更新父实体实体的行版本

EF:

中对 父实体 进行并发检查非常容易
// Update the row version's original value
_db.Entry(dbManifest)
      .Property(b => b.RowVersion)
      .OriginalValue = dbManifest.RowVersion; // the row version the client originally read

// Mark the row version as modified
_db.Entry(dbManifest)
       .Property(x => x.RowVersion)
       .IsModified = true;

IsModified = true交易破坏者,因为它强制更改行版本。或者,在上下文中,来自客户端进程的此检查将导致 父实体 中的行版本更改,这会不必要地干扰其他 客户端进程' 工作流程。

解决方法:我可能会将来自客户端进程的 SaveChanges 包装在一个事务中,然后是后续的依次读取父实体的行版本,如果行版本已更改则回滚。

总结

是否有 开箱即用的方法 Entity Framework 我可以 SaveChanges (在 客户端进程 子实体 )还检查 父实体的行版本 是否有已更改(未更新 父实体行版本)。

那么,您需要做的是在写入子实体时检查父实体的并发令牌(时间戳)。唯一的挑战是父时间戳不在子实体中。

您没有明确说明,但我假设您使用的是 EF Core。

查看 https://docs.microsoft.com/en-us/ef/core/saving/concurrency,如果 UPDATE 或 DELETE 影响零行,EF Core 似乎会抛出并发异常。为了实现并发测试,EF 添加了一个 WHERE 子句来测试并发标记,然后测试 UPDATE 或 DELETE 是否影响了正确的行数。

您可以尝试向 UPDATE 或 DELETE 添加一个额外的 WHERE 子句来测试父级的 RowVersion 的值。我认为您可以使用 System.Diagnostics.DiagnosticListener class 拦截 EF Core 2 来做到这一点。在 https://weblogs.asp.net/ricardoperes/interception-in-entity-framework-core and a discussion at . Evidently EF Core 3 (I think it is coming in September/October) will include an interception mechanism similar to that which was in EF pre-Core, see https://github.com/aspnet/EntityFrameworkCore/issues/15066

上有一篇关于它的文章

希望这对你有用。

有一个非常简单的解决方案,“out-of-2-boxes”,但它需要进行两次修改我不确定您是否可以或愿意:

  • 创建包含 ParentRowVersion
  • updatable 子视图 table
  • 将子实体映射到此视图

让我来展示一下这是如何工作的。一切都非常简单。

数据库模型:

CREATE TABLE [dbo].[Parent]
(
[ID] [int] NOT NULL IDENTITY(1, 1),
[Name] [nvarchar] (50) NOT NULL,
[RowVersion] [timestamp] NOT NULL
) ON [PRIMARY]
ALTER TABLE [dbo].[Parent] ADD CONSTRAINT [PK_Parent] PRIMARY KEY CLUSTERED  ([ID]) ON [PRIMARY]

CREATE TABLE [dbo].[Child]
(
[ID] [int] NOT NULL IDENTITY(1, 1),
[Name] [nvarchar] (50) NOT NULL,
[RowVersion] [timestamp] NOT NULL,
[ParentID] [int] NOT NULL
) ON [PRIMARY]
ALTER TABLE [dbo].[Child] ADD CONSTRAINT [PK_Child] PRIMARY KEY CLUSTERED  ([ID]) ON [PRIMARY]
GO
CREATE VIEW [dbo].[ChildView]
WITH SCHEMABINDING
AS
SELECT Child.ID
, Child.Name
, Child.ParentID
, Child.RowVersion
, p.RowVersion AS ParentRowVersion
FROM dbo.Child
INNER JOIN dbo.Parent p ON p.ID = Child.ParentID

视图已更新table 因为它符合 conditions for Sql Server views to be updatable.

数据

SET IDENTITY_INSERT [dbo].[Parent] ON
INSERT INTO [dbo].[Parent] ([ID], [Name]) VALUES (1, N'Parent1')
SET IDENTITY_INSERT [dbo].[Parent] OFF

SET IDENTITY_INSERT [dbo].[Child] ON
INSERT INTO [dbo].[Child] ([ID], [Name], [ParentID]) VALUES (1, N'Child1.1', 1)
INSERT INTO [dbo].[Child] ([ID], [Name], [ParentID]) VALUES (2, N'Child1.2', 1)
SET IDENTITY_INSERT [dbo].[Child] OFF

Class 型号

public class Parent
{
    public Parent()
    {
        Children = new HashSet<Child>();
    }
    public int ID { get; set; }
    public string Name { get; set; }
    public byte[] RowVersion { get; set; }
    public ICollection<Child> Children { get; set; }
}

public class Child
{
    public int ID { get; set; }
    public string Name { get; set; }
    public byte[] RowVersion { get; set; }

    public int ParentID { get; set; }
    public Parent Parent { get; set; }
    public byte[] ParentRowVersion { get; set; }
}

上下文

public class TestContext : DbContext
{
    public TestContext(string connectionString) : base(connectionString){ }

    public DbSet<Parent> Parents { get; set; }
    public DbSet<Child> Children { get; set; }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Parent>().Property(e => e.RowVersion).IsRowVersion();
        modelBuilder.Entity<Child>().ToTable("ChildView");
        modelBuilder.Entity<Child>().Property(e => e.ParentRowVersion).IsRowVersion();
    }
}

汇集在一起​​

这段代码更新了一个 Child 而一个假的并发用户更新了它的 Parent:

using (var db = new TestContext(connString))
{
    var child = db.Children.Find(1);

    // Fake concurrent update of parent.
    db.Database.ExecuteSqlCommand("UPDATE dbo.Parent SET Name = Name + 'x' WHERE ID = 1");
    
    child.Name = child.Name + "y";
    db.SaveChanges();
}

现在 SaveChanges 抛出所需的 DbUpdateConcurrencyException。当父更新被注释掉时,子更新成功。

我认为这种方法的优点是它非常独立于数据访问库。您只需要一个支持乐观并发的 ORM。将来搬到 EF-core 不会有问题。

从一个项目到另一个项目,我在广泛的平台(不仅是 .Net)上遇到了这个问题。 从架构的角度来看,我可以提出几个并非 EntityFramework 独有的决定。 (至于我#2更好)

选项 1 实现乐观锁定方法。一般的想法听起来像:“让我们更新客户端,然后检查父级的状态”。您已经提到了“使用事务”的想法,但是乐观锁定可以减少保留父实体所需的时间。类似于:

var expectedVersion = _db.Parent...First().RowVersion;
using (var transactionScope = new TransactionScope(TransactionScopeOption.Required))
{
    //modify Client entity there
    ...
    //now make second check of Parent version
    if( expectedVersion != _db.Parent...First().RowVersion )
        throw new Exception(...);
    _db.SaveChanges();
}

注意!根据 SQL 服务器设置(隔离级别),您可能需要应用到父实体 select-for-update 请查看如何操作。 How to implement Select For Update in EF Core

选项 2 至于我更好的方法而不是 EF 使用显式 SQL 类似的东西:

UPDATE 
    SET Client.BusinessValue = :someValue -- changes of client
    FROM Client, Parent
         WHERE Client.Id = :clientToChanges -- restrict updates by criteria
         AND Client.ParentId = Parent.Id -- join with Parent entity
         AND Parent.RowVersion = :expectedParent

在 .Net 代码中进行此查询后,您需要检查是否恰好有 1 行受到影响(0 表示 Parent.Rowversion 已更改)

if(_db.ExecuteSqlCommand(sql) != 1 )
    throw new Exception();

也尝试在其他 DB-table 的帮助下分析“全局锁定”设计模式。您可以在那里阅读有关此方法的信息 http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html