Optimistic Concurrency Control запобігає втраті даних через оновлення Last-Win з кількома доступами (SQL Server)

Сторінка оновлюється :
Дата створення сторінки :

Робоче середовище

Візуальна студія
  • Visual Studio 2022
.МЕРЕЖІ
  • .NET 8
Структурне ядро сутностей
  • Entity Framework Core 8.0
SQL Сервер
  • SQL Server 2022

* Вище наведено середовище верифікації, але воно може працювати і з іншими версіями.

Про виграшні оновлення після «Без контролю»

У веб-додатку або клієнт-серверному додатку кілька людей можуть отримувати доступ до даних з однієї бази даних і оновлювати їх. Якщо нічого конкретно не робити, то дані людини, яка оновила їх пізніше, будуть відображені в базі даних як найпізніші.

Як правило, немає особливої проблеми в тому, що дані людини, яка оновила пізніше, відображаються як найпізніші. Проблеми можуть виникнути, коли кілька людей намагаються отримати доступ до одних і тих самих даних і оновити їх одночасно.

Наприклад, припустимо, що у вас є такі дані книги.

Значення імені параметра
Назва книги Бази даних книг
ціна 1000

Якщо дві людини відкриють екран для редагування цих даних одночасно, відобразиться вищезазначене значення. Пан А намагається підняти ціну на цю книгу на 500 ієн. Пізніше пану Б доручають підняти ціну на цю книгу ще на 300 ієн.

Якщо двоє з них піднімуть ціну окремо, а не одночасно, ціна книги становитиме 1800 ієн. Якщо ви отримаєте доступ до нього одночасно, він буде зареєстрований як 1500 ієн і 1300 ієн відповідно, тому це не буде 1800 ієн, незалежно від того, який з них реєструє.

Проблема тут полягає в тому, що людина, яка оновила пізніше, може оновити його, не знаючи інформації, яка була оновлена раніше.

Оптимістичний паралелізм

Зазначену проблему можна вирішити, виконавши цього разу «оптимістичний паралельний контроль». Якщо пояснити просто, то що таке контроль, щоб «виграти першим, якщо ви спробуєте редагувати дані одночасно». Ті, хто спробує продовжити пізніше, отримають помилку на термінах оновлення і не зможуть зареєструватися.

Ви можете подумати, що з допомогою цього ви не можете зареєструвати нові дані, але це лише «коли ви намагаєтеся змінити їх одночасно». Дві людини можуть редагувати в абсолютно різний час. Звичайно, в такому випадку дані останньої оновленої особи будуть найновішими.

Зокрема, якого контролю обробки можна досягти, «маючи версію даних». Наприклад, у наведеному вище прикладі у вас будуть такі дані.

Значення імені параметра
Назва книги Бази даних книг
ціна 1000
версія 1

Версія збільшується на 1 для кожного оновлення запису. Наприклад, якщо пан/пані А встановлює ціну 1500 ієн, версія буде 2. У цей час умовою для оновлення є те, що версія до оновлення збігається з версією в базі даних. Коли пан/пані оновлює його, версія в базі даних дорівнює 1, а версія вихідних даних, які зараз редагуються, дорівнює 1, тому їх можна оновити.

Виходячи з цієї специфікації, припустимо ситуацію, коли пан/пані А та пан/пані Б редагують одні й ті самі дані. Коли пан/пані вперше встановив ціну 1500 ієн і спробував оновити базу даних, версія була такою ж, тому її можна було оновити. У цьому випадку версія в базі даних буде 2. Пан/пані Б намагається відредагувати дані про 1000 ієн і оновити їх у базі даних як 1300 ієн. Оновлення не вдається, тому що є версія 1, а версія в базі даних вже 2. Так працює оптимістичний паралелізм.

Entity Framework Core включає цей «оптимістичний паралелізм» з коробки, що робить його відносно простим у впровадженні.

До речі, «оптимістичний паралелізм» також відомий як «оптимістичний замок» або «оптимістичний замок», і його іноді розглядають і говорять під цією назвою. Це метод блокування, який полягає в тому, що дані не можна оновити, але дані можна прочитати. Існує також контроль під назвою «песимістичний замок» як інший метод замикання, відмінний від «оптимістичного блокування». Це метод, який блокує завантаження даних, коли перша особа зчитує дані, і навіть не дозволяє робити операції редагування. Це може вирішити проблему, що дані не можуть бути оновлені, навіть якщо вони були змінені, але коли хтось редагує дані, інші люди не можуть відкрити екран редагування даних, і якщо розблокування не вдається, дані будуть заблоковані назавжди. Обидва мають переваги та недоліки, тому від операції залежить, який з них взяти.

Створення бази даних

У цій статті я поясню, як спочатку створити базу даних для SQL Server, а потім автоматично генерувати код. Якщо ви хочете реалізувати його першим способом, будь ласка, зверніться до автоматично згенерованого коду цього разу та впровадьте його за зворотною процедурою.

Створення бази даних

Ви також можете зробити це на SQL, але простіше зробити це за допомогою графічного інтерфейсу, тому цього разу я роблю це з графічним інтерфейсом. За винятком назви бази даних, вона створюється за замовчуванням.

Створити таблицю

Створіть його за допомогою наступного SQL:

USE [TestDatabase]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Book](
	[ID] [int] NOT NULL,
	[Name] [nvarchar](100) NOT NULL,
	[Price] [money] NOT NULL,
	[RowVersion] [timestamp] NOT NULL,
 CONSTRAINT [PK_Book] PRIMARY KEY CLUSTERED 
(
	[ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

Вам не потрібно турбуватися про більшість параметрів, оскільки вони призначені лише для оновлення даних. Цікавим параметром цього разу є RowVersion стовпець, який описує . Це рекордне версіонування. Вказавши тип timestamp , версія автоматично збільшується щоразу, коли запис оновлюється. Крім того, оскільки керування цією версією здійснюється на основі окремих столів, запис про ту саму версію в основному відсутній, якщо ви не встановите його вручну.

Як додати запис

Ви можете додати його за допомогою наступного SQL:

begin transaction;
insert into Book ([ID],[Name],[Price]) values (1,'C#の本','1000.00');
insert into Book ([ID],[Name],[Price]) values (2,'VB.NETの本','1500.00');
insert into Book ([ID],[Name],[Price]) values (3,'SQL Serverの本','2000.00');
commit;

RowVersion Вам не потрібно встановлювати стовпці, оскільки вони встановлюються автоматично.

Створюйте проєкт та автоматично генеруйте код

Цього разу ми перевіримо роботу з консольним додатком. Кроки для створення проекту та автоматичної генерації коду описані в наступних порадах, тому я не буду вдаватися в них тут.

Згенерований код виглядає наступним чином:

TestDatabaseDbContext.cs

using Microsoft.EntityFrameworkCore;

namespace ConcurrencySqlServer.Models.Database;

public partial class TestDatabaseDbContext : DbContext
{
  public TestDatabaseDbContext() { }

  public TestDatabaseDbContext(DbContextOptions<TestDatabaseDbContext> options) : base(options) { }

  public virtual DbSet<Book> Book { get; set; }

  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263.
    => optionsBuilder.UseSqlServer("Data Source=<サーバー名>;Database=<データベース名>;user id=<ユーザー名>;password=<パスワード>;TrustServerCertificate=true");

  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    modelBuilder.Entity<Book>(entity =>
    {
      entity.Property(e => e.ID).ValueGeneratedNever();
      entity.Property(e => e.RowVersion)
        .IsRowVersion()
        .IsConcurrencyToken();
    });

    OnModelCreatingPartial(modelBuilder);
  }

  partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

Book.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ConcurrencySqlServer.Models.Database;

public partial class Book
{
  [Key]
  public int ID { get; set; }

  [StringLength(100)]
  public string Name { get; set; } = null!;

  [Column(TypeName = "money")]
  public decimal Price { get; set; }

  public byte[] RowVersion { get; set; } = null!;
}

Перевірка роботи оптимістичного паралельного контролю

Оскільки цього разу ми запускаємо його в одному додатку, ми не маємо до нього строго доступу одночасно, але ми хотіли б реалізувати його в близькій до нього формі.

Як і в прикладі на початку, отримується два фрагменти даних, і коли кожен з них оновлюється на основі перших даних, перевірте, чи не отримає останній засіб оновлення помилку.

Program.cs

using ConcurrencySqlServer.Models.Database;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;

Console.WriteLine("Hello, World!");

// 2人が別々にデータベースにアクセスする想定で2つのデータベースコンテキストを作成
Console.WriteLine("データベースコンテキストを作成します。");
using var dbContextA = new TestDatabaseDbContext();
using var dbContextB = new TestDatabaseDbContext();
Console.WriteLine("データベースコンテキストを作成しました。");
Console.WriteLine("");

// それぞれがデータを編集しようと読み込む
Console.WriteLine("ID:1 の Book を読み込みます。");
var bookA = dbContextA.Book.First(x => x.ID == 1);
var bookB = dbContextB.Book.First(x => x.ID == 1);
Console.WriteLine("ID:1 の Book を読み込みました。");
Console.WriteLine($"A  Book : {JsonSerializer.Serialize(bookA)}");
Console.WriteLine($"B  Book : {JsonSerializer.Serialize(bookB)}");
Console.WriteLine($"DB Book : {JsonSerializer.Serialize(dbContextC.Book.AsNoTracking().First(x => x.ID == 1))}");
Console.WriteLine("");


// A の人が最初にデータベースに更新する
bookA.Price += 500;
UpdateToDatabase(dbContextA, "A");

Console.WriteLine($"A  Book : {JsonSerializer.Serialize(bookA)}");
Console.WriteLine($"B  Book : {JsonSerializer.Serialize(bookB)}");
Console.WriteLine($"DB Book : {JsonSerializer.Serialize(dbContextC.Book.AsNoTracking().First(x => x.ID == 1))}");
Console.WriteLine("");

// そのあと B の人が最初にデータベースに更新する
bookB.Price += 300;
UpdateToDatabase(dbContextB, "B");

Console.WriteLine($"A  Book : {JsonSerializer.Serialize(bookA)}");
Console.WriteLine($"B  Book : {JsonSerializer.Serialize(bookB)}");
Console.WriteLine($"DB Book : {JsonSerializer.Serialize(dbContextC.Book.AsNoTracking().First(x => x.ID == 1))}");
Console.WriteLine("");

Console.WriteLine("処理を終了します。");


// データベースに更新するメソッド
void UpdateToDatabase(TestDatabaseDbContext dbContext, string updateUser)
{
  try
  {
    Console.WriteLine($"{updateUser} のデータベースコンテキストを変更保存します。");
    dbContext.SaveChanges();
    Console.WriteLine($"{updateUser} のデータベースコンテキストを変更保存しました。");
  }
  catch (DbUpdateConcurrencyException ex)
  {
    Console.WriteLine($"{updateUser} の更新でエラーが発生しました。");
    Console.WriteLine(ex.Message);
  }
}

Це довга історія, але більша її частина записана на консоль.

Результат виконання виглядає наступним чином.

Hello, World!
データベースコンテキストを作成します。
データベースコンテキストを作成しました。

ID:1 の Book を読み込みます。
ID:1 の Book を読み込みました。
A  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1000.0000,"RowVersion":"AAAAAAAAH2k="}
B  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1000.0000,"RowVersion":"AAAAAAAAH2k="}
DB Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1000.0000,"RowVersion":"AAAAAAAAH2k="}

A のデータベースコンテキストを変更保存します。
A のデータベースコンテキストを変更保存しました。
A  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1500.0000,"RowVersion":"AAAAAAAAH2o="}
B  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1000.0000,"RowVersion":"AAAAAAAAH2k="}
DB Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1500.0000,"RowVersion":"AAAAAAAAH2o="}

B のデータベースコンテキストを変更保存します。
B の更新でエラーが発生しました。
The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
A  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1500.0000,"RowVersion":"AAAAAAAAH2o="}
B  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1300.0000,"RowVersion":"AAAAAAAAH2k="}
DB Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1500.0000,"RowVersion":"AAAAAAAAH2o="}

処理を終了します。

Я розіб'ю його на розділи.

Console.WriteLine("Hello, World!");

// 2人が別々にデータベースにアクセスする想定で2つのデータベースコンテキストを作成
Console.WriteLine("データベースコンテキストを作成します。");
using var dbContextA = new TestDatabaseDbContext();
using var dbContextB = new TestDatabaseDbContext();
using var dbContextC = new TestDatabaseDbContext();
Console.WriteLine("データベースコンテキストを作成しました。");

Я створюю два контексти бази даних, Якщо ви поділяєте один контекст бази даних, він буде кешуватися під час зчитування даних, і це буде той самий екземпляр. Припускаючи, що кожен з них здійснюється окремо, створюються два контексти бази даних. dbContextC призначена для перевірки значень у базі даних. Мені це не дуже потрібно, тому що можна замінити А або Б.

// それぞれがデータを編集しようと読み込む
Console.WriteLine("ID:1 の Book を読み込みます。");
var bookA = dbContextA.Book.First(x => x.ID == 1);
var bookB = dbContextB.Book.First(x => x.ID == 1);
Console.WriteLine("ID:1 の Book を読み込みました。");
Console.WriteLine($"A  Book : {JsonSerializer.Serialize(bookA)}");
Console.WriteLine($"B  Book : {JsonSerializer.Serialize(bookB)}");
Console.WriteLine($"DB Book : {JsonSerializer.Serialize(dbContextC.Book.AsNoTracking().First(x => x.ID == 1))}");

Припускаючи, що доступ до них здійснюється окремо, вони читають один з різних контекстів Book бази даних. На даний момент ми нічого не змінили, тому вони всі однакові.

ID:1 の Book を読み込みます。
ID:1 の Book を読み込みました。
A  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1000.0000,"RowVersion":"AAAAAAAAH2k="}
B  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1000.0000,"RowVersion":"AAAAAAAAH2k="}
DB Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1000.0000,"RowVersion":"AAAAAAAAH2k="}

По-перше, я змінив і оновив книгу пана/пані. Процес UpdateToDatabase оновлення підсумовується у вигляді методу, а при виникненні винятку відображається повідомлення.

// A の人が最初にデータベースに更新する
bookA.Price += 500;
UpdateToDatabase(dbContextA, "A");

Console.WriteLine($"A  Book : {JsonSerializer.Serialize(bookA)}");
Console.WriteLine($"B  Book : {JsonSerializer.Serialize(bookB)}");
Console.WriteLine($"DB Book : {JsonSerializer.Serialize(dbContextC.Book.AsNoTracking().First(x => x.ID == 1))}");
A のデータベースコンテキストを変更保存します。
A のデータベースコンテキストを変更保存しました。
A  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1500.0000,"RowVersion":"AAAAAAAAH2o="}
B  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1000.0000,"RowVersion":"AAAAAAAAH2k="}
DB Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1500.0000,"RowVersion":"AAAAAAAAH2o="}

В результаті вона була успішно оновлена, а Price RowVersion також Книга А були оновлені. Крім того, якщо ви зчитуєте значення БД безпосередньо, оновлене значення буде таким самим, як і оновлене значення. Я зміг його оновити, тому що обидва потрапили в RowVersion А і RowVersion в БД були AAAAAAAAH2k= . Версія змінилася AAAAAAAAH2o= у зв'язку з оновленням.

Після оновлення A оновіть B таким же чином.

// そのあと B の人が最初にデータベースに更新する
bookB.Price += 300;
UpdateToDatabase(dbContextB, "B");

Console.WriteLine($"A  Book : {JsonSerializer.Serialize(bookA)}");
Console.WriteLine($"B  Book : {JsonSerializer.Serialize(bookB)}");
Console.WriteLine($"DB Book : {JsonSerializer.Serialize(dbContextC.Book.AsNoTracking().First(x => x.ID == 1))}");
B のデータベースコンテキストを変更保存します。
B の更新でエラーが発生しました。
The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
A  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1500.0000,"RowVersion":"AAAAAAAAH2o="}
B  Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1300.0000,"RowVersion":"AAAAAAAAH2k="}
DB Book : {"ID":1,"Name":"C#\u306E\u672C","Price":1500.0000,"RowVersion":"AAAAAAAAH2o="}

В результаті є винятокDbUpdateConcurrencyException, і оновлення не вдалося. bookB.RowVersion AAAAAAAAH2k= є , але RowVersion оскільки вже є AAAAAAAAH2o= , це оцінюється як невідповідність і виникає помилка оновлення. Ви можете побачити, що локальна змінна Price bookB змінилася, але RowVersion Ви можете побачити, що значення на стороні бази даних абсолютно не змінилися.

Зведення

Це найпростіший з декількох типів блокування, тому що при використанні автоматично згенерованого коду в SQL Server і Entity Framework Core за замовчуванням реалізований оптимістичний паралелізм. Однак, оскільки це робиться лише для запобігання «оновленню пошкодження даних», необхідно належним чином обробляти винятки, коли задіяні інші дані або операції користувача.

Крім того, цього разу у мене нічого не вийшло, тому що я реалізував RowVersion це в простому консольному додатку. Якщо ви хочете вставити екран редагування після завантаження даних у веб-додатку або клієнтському додатку, RowVersion якимось чином, щоб його можна було правильно визначити при оновленні.

Однак у Entity Framework Core є функція відстеження змін, тому, якщо ви хочете встановити старе RowVersion на значення, яке читається з БД на момент оновлення,

bookB.RowVersion = <古い RowVersion>;

Навіть якщо він буде встановлений таким чином, він не буде правильно оцінений як «оптимістичний паралельний контроль». RowVersion Навіть якщо ви встановите звичайне значення, воно буде розпізнано лише як змінене значення, тому нижче наведено

dbContextB.Entry(bookB).Property("RowVersion").OriginalValue = <古い RowVersion>;

Необхідно переписати значення перед зміною.