樂觀併發控制可防止由於具有多次訪問的 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>;
在更改之前,有必要重寫該值。