乐观并发控制可防止由于具有多次访问的 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>;

在更改之前,有必要重写该值。