낙관적 동시성 제어로 여러 액세스를 통한 마지막 승리 업데이트로 인한 데이터 손실 방지(SQL Server)

페이지 업데이트 :
페이지 생성 날짜 :

운영 환경

비주얼 스튜디오
  • 비주얼 스튜디오 2022
.그물
  • .그물 8
Entity Framework Core
  • Entity Framework 코어 8.0
SQL 서버
  • SQL 서버 2022

* 위의 내용은 검증 환경이지만 다른 버전에서도 작동할 수 있습니다.

통제 불가 후 업데이트 승리 정보

웹 응용 프로그램 또는 클라이언트-서버 응용 프로그램에서는 여러 사용자가 단일 데이터베이스의 데이터에 액세스하고 업데이트할 수 있습니다. 특별히 아무 작업도 수행하지 않으면 나중에 업데이트 한 사람의 데이터가 데이터베이스에 최신 상태로 반영됩니다.

일반적으로 나중에 업데이트 한 사람의 데이터가 최신으로 반영되는 것은 특히 문제는 없습니다. 여러 사람이 동시에 동일한 데이터에 액세스하고 업데이트하려고 할 때 문제가 발생할 수 있습니다.

예를 들어 다음과 같은 책 데이터가 있다고 가정합니다.

매개 변수 이름
책의 이름 데이터베이스 서적
1000

두 사람이 동시에 이 데이터를 편집하기 위해 화면을 열면 위의 값이 표시됩니다. A씨는 이 책의 가격을 500엔 올리려고 합니다. B씨는 나중에 이 책의 가격을 300엔 더 올리라는 지시를 받는다.

두 사람이 동시에 가격을 올리는 것이 아니라 따로 올리면 책의 가격은 1800 엔이됩니다. 동시에 액세스하면 각각 1500 엔과 1300 엔으로 등록되기 때문에 어느 쪽에 등록해도 1800 엔이되지 않습니다.

여기서 문제는 나중에 업데이트 한 사람이 이전에 업데이트 된 정보를 모르고 업데이트 할 수 있다는 것입니다.

낙관적 동시성

앞서 언급한 문제는 이번에 "낙관적 동시성 제어"를 수행하여 해결할 수 있습니다. 간단히 설명하자면, "동시에 데이터를 편집하려고 하면 먼저 이기는 것"이 컨트롤의 종류입니다. 나중에 갱신을 시도하면 업데이트 타이밍에 오류가 발생하여 등록을 할 수 없습니다.

이것으로 새로운 데이터를 등록 할 수 없다고 생각할지도 모릅니다 만, 이것은 "동시에 변경하려고했을 때"만입니다. 두 사람이 완전히 다른 시간에 편집할 수 있습니다. 물론 이 경우 마지막으로 업데이트된 사람의 데이터가 최신 데이터가 됩니다.

구체적으로, "데이터 버전을 가짐"으로써 어떤 종류의 처리 제어를 얻을 수 있는지. 예를 들어 위의 예에서는 다음과 같은 데이터가 있습니다.

매개 변수 이름
책의 이름 데이터베이스 서적
1000
버전 1

버전은 각 레코드 업데이트에 대해 1씩 증가합니다. 예를 들어, A 씨가 가격을 1500 엔으로 설정하면 버전은 2가됩니다. 이때 업데이트를 할 수 있는 조건은 업데이트 전의 버전이 데이터베이스의 버전과 동일해야 한다는 것입니다. Mr./Ms.가 업데이트하면 데이터베이스의 버전이 1이고 현재 편집중인 원본 데이터의 버전이 1이므로 업데이트 할 수 있습니다.

이 사양에 따라 A씨와 B씨가 동일한 데이터를 편집하는 상황을 가정합니다. Mr./Ms.가 처음 가격을 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 . 이것이 레코드 버전 관리입니다. type으로 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="}

먼저 Mr./Ms.의 책을 변경하고 업데이트했습니다. 업데이트 프로세스는 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 값을 직접 읽으면 업데이트된 값은 업데이트된 값과 동일합니다. A와 RowVersion DB AAAAAAAAH2k=RowVersion 모두 있었기 때문에 업데이트 할 수있었습니다. 업데이트로 인해 버전이 변경 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= 이므로 불일치로 판단되어 업데이트 오류가 발생합니다. 로컬 변수가 bookB 변경된 것을 Price 볼 수 있지만 RowVersion 데이터베이스 측의 값이 전혀 변경되지 않은 것을 볼 수 있습니다.

요약

SQL Server 및 Entity Framework Core에서 자동 생성된 코드를 사용할 때 낙관적 동시성이 기본적으로 구현되기 때문에 이 잠금 유형은 여러 잠금 유형 중 구현하기 가장 쉽습니다. 그러나 "업데이트되는 데이터 손상"을 방지하기 위한 것뿐이므로 다른 데이터가 관련되거나 사용자 작업이 관련된 경우 예외를 적절하게 처리할 필요가 있습니다.

또한 이번에는 간단한 콘솔 응용 프로그램에서 구현 RowVersion 했기 때문에 아무 것도 관리하지 않았습니다. 웹 응용 프로그램이나 클라이언트 응용 프로그램에 데이터를 로드한 후 편집 화면을 삽입하려는 경우, RowVersion 업데이트 될 때 제대로 확인할 수 있도록 어떤 식 으로든.

그러나 Entity Framework Core에는 변경 내용 추적 기능이 있으므로 업데이트 시점의 DB에서 읽은 값으로 이전 RowVersion 값을 설정하려면

bookB.RowVersion = <古い RowVersion>;

다음과 같이 설정하더라도 "낙관적 동시성 제어"로 올바르게 판단되지 않습니다. RowVersion 값을 정상적으로 설정해도 변경된 값으로만 인식되기 때문에 다음과 같습니다.

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

변경하기 전에 값을 다시 써야 합니다.