Проверка параллелизма на сущности без обновления версии строки

У меня есть родительский объект, который мне нужен для проверки параллелизма (как указано ниже)

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

У меня есть куча клиентских процессов, которые обращаются к значениям только для чтения из этой родительской сущности и в первую очередь обновляют ее дочерние сущности.

Ограничение

  1. Клиенты не должны мешать друг другу работать (например, обновление дочерних записей не должно вызывать исключение параллелизма в родительской сущности).

  2. У меня есть серверный процесс, который обновляет эту родительскую сущность, и в этом случае клиентский процесс должен выбросить, если родительская сущность была изменена.

Примечание: проверка параллелизма клиента является жертвенной, рабочий процесс сервера является критически важным.

Эта проблема

Мне нужно проверить (из процесса клиента), изменилась ли родительская сущность без обновления версии строки родительской сущности.

Достаточно просто выполнить проверку параллелизма родительской сущности в EF:

// Update the row version 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 (в клиентском процессе для дочерних объектов), но также проверить, изменилась ли версия строки родительского объекта (без обновления версии строки родительского объекта).

Ответ 1

Существует удивительно простое решение "из двух коробок", но оно требует двух модификаций, я не уверен, что вы можете или хотите сделать:

  • Создайте обновляемое представление для дочерней таблицы, содержащей столбец ParentRowVersion
  • Сопоставьте сущность Child с этим представлением

Позвольте мне показать, как это работает. Все довольно просто.

Модель базы данных:

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

Представление является обновляемым, поскольку оно соответствует условиям для обновляемости представлений Sql Server.

данных
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

Модель класса

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-ядро не будет проблемой.

Ответ 2

Что нужно сделать, это проверить токен параллелизма (Timestamp) родительской сущности при записи в дочернюю сущность. Единственная проблема заключается в том, что родительская timestamp отсутствует в дочерних объектах.

Вы не указали явно, но я предполагаю, что вы используете EF Core.

Глядя на https://docs.microsoft.com/en-us/ef/core/saving/concurrency, кажется, что EF Core сгенерирует исключение параллелизма, если UPDATE или DELETE влияют на ноль строк. Чтобы реализовать тестирование параллелизма, EF добавляет предложение WHERE, тестирующее токен параллелизма, а затем проверяет, было ли на UPDATE или DELETE изменено правильное число строк.

Вы можете попытаться добавить дополнительное предложение WHERE в UPDATE или DELETE, которое проверяет значение родительского RowVersion. Я думаю, что вы могли бы сделать это, используя класс System.Diagnostics.DiagnosticListener для перехвата EF Core 2. На нем есть статья по адресу https://weblogs.asp.net/ricardoperes/interception-in-entity-framework -CORE и обсуждение в Могу ли я настроить перехватчик еще в EntityFramework Ядра? , Очевидно, что EF Core 3 (я думаю, что это произойдет в сентябре/октябре) будет включать механизм перехвата, аналогичный тому, который был в EF pre-Core, см. Https://github.com/aspnet/EntityFrameworkCore/issues/15066.

Надеюсь, это полезно для вас.

Ответ 3

От проекта к проекту я сталкиваюсь с этой проблемой на широких платформах (не только .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 (уровней изоляции) вам может потребоваться обратиться к родительской сущности для выбора, пожалуйста, посмотрите, как это сделать. Как реализовать функцию "Выбрать для обновления" в 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.SaveChanges() != 1 )
    throw new Exception();

Также попробуйте проанализировать шаблон проектирования "Глобальная блокировка" с помощью дополнительной БД-таблицы. Вы можете прочитать об этом подходе там http://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html