樂觀併發控制可防止由於具有多次訪問的 Last-Win 更新 (SQL Server) 而導致的數據丟失

更新頁 :
頁面創建日期 :

操作環境

Visual Studio的
  • Visual Studio 2022
。網
  • .NET 8
實體框架核心
  • 實體框架核心 8.0
SQL 伺服器
  • SQL Server 2022

* 以上是一個驗證環境,但它可能適用於其他版本。

關於《Winning Updates After No Control》

在 Web 應用程式或用戶端-伺服器應用程式中,多人可以從單個資料庫訪問和更新數據。 如果不做任何特別的事情,稍後更新它的人的數據將作為最新的反映在資料庫中。

通常,沒有特別的問題,即稍後更新的人的數據會被反映為最新的。 當多人嘗試同時訪問和更新相同的數據時,可能會出現問題。

例如,假設您有以下書籍數據。

參數名稱
書名 資料庫書籍
價格 1000

如果兩個人同時打開螢幕編輯此數據,則會顯示上述值。 A先生/女士試圖將這本書的價格提高500日元。 B先生/女士後來被指示將這本書的價格再提高300日元。

如果他們倆單獨提高價格而不是同時提高價格,那麼這本書的價格將是1800日元。 如果您同時訪問它,它將分別註冊為1500日元和1300日元,因此無論哪個註冊都不會是1800日元。

這裏的問題是,稍後更新的人可以在不知道之前更新的信息的情況下更新它。

樂觀併發

上述問題可以通過這次進行「樂觀併發控制」來解決。 簡單地解釋一下,什麼樣的控制是“如果你試圖同時編輯數據,就先贏”。 那些稍後嘗試續訂的人將在更新時收到錯誤,並且無法註冊。

您可能認為無法使用此功能註冊新數據,但這只是“當您嘗試同時更改它時”。 兩個人可以在完全不同的時間進行編輯。 當然,在這種情況下,最後更新的人的數據將是最新的。

具體來說,通過「擁有數據的一個版本」可以實現什麼樣的處理控制。 例如,在上面的範例中,您將獲得以下數據。

參數名稱
書名 資料庫書籍
價格 1000
版本 1

每次記錄更新時,版本將增加1。 例如,如果 A 先生/女士將價格設置為 1500 日元,則版本將為 2。 此時,可以進行更新的條件是更新前的版本與資料庫上的版本相同。 當 Mr./Ms. 更新它時,資料庫上的版本是 1,當前正在編輯的原始數據的版本是 1,因此可以更新。

根據此規範,假設 A 先生/女士和 B 先生/女士編輯相同的數據。 當先生/女士首次將價格設置為1500日元並嘗試更新資料庫時,版本相同,因此可以更新。 在這種情況下,資料庫上的版本將為 2。 B先生/女士試圖編輯1000日元的數據,並將其更新為1300日元到資料庫中。 更新失敗,因為手頭的版本是1,但資料庫上的版本已經是2。 這就是樂觀併發的工作原理。

Entity Framework Core 包含這種開箱即用的“樂觀併發”,使其相對容易實現。

順便說一下,“樂觀併發”也被稱為“樂觀鎖”或“樂觀鎖”,有時也會用這個名字來審視和談論。 這是一種鎖定方法,數據不能更新,但數據可以讀取。 還有一種稱為“悲觀鎖”的控件,作為“樂觀鎖”之外的另一種鎖定方法。 這是一種在第一個人讀取數據時鎖定數據載入的方法,甚至不允許編輯操作。 它可以解決數據即使已經更改也無法更新的問題,但是當有人正在編輯數據時,其他人無法打開數據編輯螢幕,如果解鎖失敗,數據將永遠被鎖定。 兩者都有優點和缺點,因此要看操作採用哪一種。

創建資料庫

在本文中,我將解釋如何首先為 SQL Server 創建資料庫,然後自動生成代碼。 如果你想以代碼優先的方式實現它,這次請參考自動生成的代碼,並按照相反的過程實現。

創建資料庫

您也可以在 SQL 中製作它,但使用 GUI 製作它更容易,所以我這次使用 GUI 製作它。 除資料庫名稱外,預設情況下會創建該名稱。

創建表

使用以下 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 用於檢查資料庫中的值。 我真的不需要它,因為 A 或 B 可以被替換。

// それぞれがデータを編集しようと読み込む
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 A 書的 和 已更新。 此外,如果直接讀取 DB 值,則更新的值將與更新的值相同。 我能夠更新它,因為兩者都進入了 RowVersion A 和 RowVersion DB 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 它。 如果要在 Web 應用程式或用戶端應用程式中載入資料後插入編輯螢幕, RowVersion 以某種方式,以便在更新時可以正確確定它。

但是,Entity Framework Core 具有更改跟蹤功能,因此,如果要將 old RowVersion 設置為更新時從 DB 讀取的值,

bookB.RowVersion = <古い RowVersion>;

即使按照以下方式設置,也不會被正確判斷為“樂觀併發控制”。 RowVersion 即使您將該值設置為正常值,它也只會被識別為更改的值,因此以下內容是

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

在更改之前,有必要重寫該值。