Optymistyczna kontrola współbieżności zapobiega utracie danych z powodu aktualizacji ostatniej wygranej z wieloma dostępami (SQL Server)

Strona zaktualizowana :
Data utworzenia strony :

Środowisko pracy

Visual Studio
  • informacji o wersji Visual Studio 2022
.SIEĆ
  • .NET 8
Entity Framework Core
  • Entity Framework Core 8.0
Serwer SQL
  • SQL Server 2022 r.

* Powyższe jest środowiskiem weryfikacyjnym, ale może działać z innymi wersjami.

Informacje o wygrywaniu aktualizacji po braku kontroli

W aplikacji internetowej lub aplikacji klient-serwer wiele osób może uzyskiwać dostęp do danych z jednej bazy danych i aktualizować je. Jeśli nie zostaną podjęte żadne szczególne działania, dane osoby, która je później zaktualizowała, zostaną odzwierciedlone w bazie danych jako najnowsze.

Zwykle nie ma szczególnego problemu z tym, że dane osoby, która zaktualizowała później, są odzwierciedlane jako najnowsze. Problemy mogą wystąpić, gdy wiele osób próbuje uzyskać dostęp do tych samych danych i zaktualizować je w tym samym czasie.

Załóżmy na przykład, że masz następujące dane książki.

Nazwa parametru Wartość
Tytuł książki Książki baz danych
cena 1000

Jeśli dwie osoby otworzą ekran, aby edytować te dane w tym samym czasie, powyższa wartość zostanie wyświetlona. Pan/Pani A próbuje podnieść cenę tej książki o 500 jenów. Pan / Pani B zostaje później poinstruowany, aby podnieść cenę tej książki o kolejne 300 jenów.

Jeśli obaj podniosą cenę osobno, a nie w tym samym czasie, cena książki wyniesie 1800 jenów. Jeśli uzyskasz do niego dostęp w tym samym czasie, zostanie on zarejestrowany odpowiednio jako 1500 jenów i 1300 jenów, więc nie będzie to 1800 jenów, bez względu na to, który z nich się zarejestruje.

Problem polega na tym, że osoba, która zaktualizowała go później, może go zaktualizować, nie znając informacji, które zostały wcześniej zaktualizowane.

Optymistyczna współbieżność

Powyższy problem można rozwiązać, wykonując tym razem "optymistyczną kontrolę współbieżności". Mówiąc prościej, jaki rodzaj kontroli polega na tym, że "wygrywasz pierwszy, jeśli spróbujesz edytować dane w tym samym czasie". Osoby, które spróbują odnowić subskrypcję później, otrzymają błąd w momencie aktualizacji i nie będą mogły się zarejestrować.

Możesz pomyśleć, że nie możesz zarejestrować nowych danych za pomocą tego, ale dzieje się tak tylko "gdy próbujesz to zmienić w tym samym czasie". Możliwe jest, że dwie osoby edytują w zupełnie różnych momentach. Oczywiście w takim przypadku dane ostatniej zaktualizowanej osoby będą najnowsze.

W szczególności, jaki rodzaj kontroli przetwarzania można osiągnąć poprzez "posiadanie wersji danych". Na przykład w powyższym przykładzie będziesz mieć następujące dane.

Nazwa parametru Wartość
Tytuł książki Książki baz danych
cena 1000
Wersja 1

Wersja jest zwiększana o 1 dla każdej aktualizacji rekordu. Na przykład, jeśli Pan/Pani A ustawi cenę na 1500 jenów, wersja będzie miała wartość 2. W tym czasie warunkiem, że aktualizacja może zostać wykonana, jest to, że wersja przed aktualizacją jest taka sama jak wersja w bazie danych. Gdy Pan/Pani je zaktualizuje, wersja w bazie danych wynosi 1, a wersja oryginalnych danych, które są obecnie edytowane, to 1, więc można je zaktualizować.

Na podstawie tej specyfikacji załóżmy sytuację, w której Pan/Pani A i Pan/Pani B edytują te same dane. Kiedy Pan/Pani po raz pierwszy ustawił cenę na 1500 jenów i próbował zaktualizować bazę danych, wersja była taka sama, więc można ją było zaktualizować. W takim przypadku wersja w bazie danych będzie równa 2. Pan/Pani B próbuje edytować dane o wartości 1000 jenów i zaktualizować je w bazie danych jako 1300 jenów. Aktualizacja kończy się niepowodzeniem, ponieważ dostępna wersja to 1, ale wersja w bazie danych to już 2. Tak działa optymistyczna współbieżność.

Entity Framework Core zawiera tę "optymistyczną współbieżność" od razu po wyjęciu z pudełka, dzięki czemu jest stosunkowo łatwa do zaimplementowania.

Nawiasem mówiąc, "optymistyczna współbieżność" jest również znana jako "optymistyczna blokada" lub "optymistyczna blokada" i czasami jest badana i określana tą nazwą. Jest to metoda blokowania, której nie można zaktualizować, ale dane można odczytać. Istnieje również kontrola zwana "blokadą pesymistyczną" jako inna metoda blokowania inna niż "blokada optymistyczna". Jest to metoda, która blokuje ładowanie danych, gdy pierwsza osoba odczytuje dane i nie pozwala nawet na operacje edycji. Może to rozwiązać problem polegający na tym, że danych nie można zaktualizować, mimo że zostały zmienione, ale gdy ktoś edytuje dane, inne osoby nie mogą otworzyć ekranu edycji danych, a jeśli odblokowanie się nie powiedzie, dane zostaną zablokowane na zawsze. Oba mają zalety i wady, więc od operacji zależy, który z nich przyjąć.

Tworzenie bazy danych

W tym artykule wyjaśnię, jak najpierw stworzyć bazę danych dla SQL Server, a następnie automatycznie wygenerować kod. Jeśli chcesz zaimplementować go w sposób oparty na kodzie, tym razem zapoznaj się z automatycznie wygenerowanym kodem i zaimplementuj go w odwrotnej procedurze.

Tworzenie bazy danych

Możesz też zrobić to w SQL, ale łatwiej jest to zrobić za pomocą GUI, więc tym razem robię to za pomocą GUI. Z wyjątkiem nazwy bazy danych, jest ona tworzona domyślnie.

Tworzenie tabeli

Utwórz go za pomocą następującego kodu 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

Nie musisz się martwić o większość parametrów, ponieważ służą one tylko do aktualizacji danych. Interesującym parametrem tym razem jest RowVersion kolumna opisująca . To jest wersjonowanie rekordów. Określając timestamp jako typ, wersja jest automatycznie zwiększana za każdym razem, gdy rekord jest aktualizowany. Ponadto, ponieważ ta wersja jest zarządzana dla poszczególnych tabel, w zasadzie nie ma rekordu tej samej wersji, chyba że ustawisz ją ręcznie.

Dodawanie rekordu

Możesz go dodać za pomocą następującego kodu 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 Nie musisz ustawiać kolumn, ponieważ są one ustawiane automatycznie.

Tworzenie projektu i automatyczne generowanie kodu

Tym razem sprawdzimy działanie za pomocą aplikacji konsolowej. Kroki, które należy wykonać, aby utworzyć projekt i automatycznie wygenerować kod, są opisane w poniższych wskazówkach, więc nie będę się w nie tutaj zagłębiał.

Wygenerowany kod jest następujący:

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!;
}

Sprawdzanie działania optymistycznej kontroli współbieżności

Ponieważ tym razem uruchamiamy go w jednej aplikacji, nie uzyskujemy do niego dostępu stricte w tym samym czasie, ale chcielibyśmy go zaimplementować w formie zbliżonej do niego.

Podobnie jak w przykładzie na początku, pobierane są dwie dane, a gdy każda z nich jest aktualizowana na podstawie pierwszych danych, sprawdź, czy drugi aktualizator otrzyma błąd.

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);
  }
}

To długa historia, ale większość z niej jest napisana na konsoli.

Wynik wykonania jest następujący.

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="}

処理を終了します。

Podzielę to na sekcje.

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("データベースコンテキストを作成しました。");

Tworzę dwa konteksty bazy danych, Jeśli współużytkujesz jeden kontekst bazy danych, zostanie on zapisany w pamięci podręcznej podczas odczytywania danych i będzie to ta sama instancja. Przy założeniu, że każdy z nich jest używany oddzielnie, tworzone są dwa konteksty bazy danych. dbContextC służy do sprawdzania wartości w bazie danych. Naprawdę tego nie potrzebuję, ponieważ A lub B można podmienić.

// それぞれがデータを編集しようと読み込む
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))}");

Zakładając, że uzyskuje się do nich dostęp oddzielnie, odczytują jeden z różnych kontekstów Book bazy danych. W tym momencie nic nie zmieniliśmy, więc wszystkie są takie same.

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="}

Po pierwsze, zmieniłem i zaktualizowałem książkę Pana/Pani. Proces aktualizacji jest UpdateToDatabase podsumowywany w metodzie, a w przypadku wystąpienia wyjątku wyświetlany jest komunikat.

// 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="}

W związku z tym została ona pomyślnie zaktualizowana, a Price RowVersion Księga A została zaktualizowana. Ponadto, jeśli odczytasz wartość DB bezpośrednio, zaktualizowana wartość będzie taka sama jak zaktualizowana wartość. Udało mi się go zaktualizować, ponieważ zarówno dostałem się w RowVersion A, jak i RowVersion w DB były AAAAAAAAH2k= . Wersja uległa zmianie AAAAAAAAH2o= w związku z aktualizacją.

Po zaktualizowaniu A zaktualizuj B w ten sam sposó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="}

Wynikiem jest wyjątekDbUpdateConcurrencyException, a aktualizacja nie powiodła się. bookB.RowVersion AAAAAAAAH2k= jest , ale RowVersion ponieważ jest już AAAAAAAAH2o= , jest oceniane jako niezgodność i występuje błąd aktualizacji. Widać, że zmienna lokalna uległa Price zmianie, ale RowVersion parametr bookB Widać, że wartości po stronie bazy danych w ogóle się nie zmieniły.

Streszczenie

Jest to najłatwiejszy z kilku typów blokad do zaimplementowania, ponieważ w przypadku korzystania z automatycznie wygenerowanego kodu w SQL Server i Entity Framework Core optymistyczna współbieżność jest domyślnie implementowana. Ponieważ jednak ma to na celu jedynie zapobieżenie "uszkodzeniu danych, które mają zostać zaktualizowane", konieczne jest prawidłowe traktowanie wyjątków, gdy w grę wchodzą inne dane lub operacje użytkownika.

Również i tym razem nic mi się nie udało, bo zaimplementowałem RowVersion to w prostej aplikacji konsolowej. Jeśli chcesz wstawić ekran edycji po załadowaniu danych do aplikacji internetowej lub aplikacji klienckiej, RowVersion w jakiś sposób, aby można go było prawidłowo określić, gdy jest aktualizowany.

Jednak Entity Framework Core ma funkcję śledzenia zmian, więc jeśli chcesz ustawić starą RowVersion wartość na wartość odczytaną z bazy danych w momencie aktualizacji,

bookB.RowVersion = <古い RowVersion>;

Nawet jeśli zostanie ustawiony w następujący sposób, nie zostanie poprawnie oceniony jako "optymistyczna kontrola współbieżności". RowVersion Nawet jeśli ustawisz wartość na normalnie, zostanie ona rozpoznana tylko jako zmieniona wartość, więc następujące informacje są

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

Konieczne jest przepisanie wartości przed zmianą.