乐观并发控制可防止由于具有多次访问的 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>;
在更改之前,有必要重写该值。