בקרת מקביליות אופטימית מונעת אובדן נתונים עקב עדכוני הזכייה האחרונה עם גישות מרובות (SQL Server)

עודכן דף :
תאריך יצירת דף :

סביבת הפעלה

Visual Studio
  • Visual Studio 2022
.רשת
  • .NET 8
ליבת מסגרת ישות
  • Entity Framework Core 8.0
שרת SQL
  • שרת SQL 2022

* האמור לעיל הוא סביבת אימות, אך הוא עשוי לפעול עם גרסאות אחרות.

מידע על זכייה בעדכונים ללא שליטה

ביישום אינטרנט או ביישום שרת-לקוח, אנשים מרובים יכולים לגשת לנתונים ולעדכן אותם ממסד נתונים יחיד. אם לא נעשה דבר מיוחד, הנתונים של מי שעדכן אותו מאוחר יותר ישתקפו במאגר כמאוחרים ביותר.

בדרך כלל, אין בעיה מיוחדת שהנתונים של האדם שעדכן מאוחר יותר משתקפים כמאוחרים ביותר. בעיות יכולות להתעורר כאשר אנשים מרובים מנסים לגשת לאותם נתונים ולעדכן אותם בו-זמנית.

לדוגמה, נניח שברשותך נתוני הספר הבאים.

ערך שם פרמטר
שם הספר ספרי מאגרי מידע
מחיר 1000

אם שני אנשים פותחים את המסך כדי לערוך נתונים אלה בו-זמנית, הערך לעיל יוצג. מר / גברת א' מנסה להעלות את מחיר הספר הזה ב -500 ין. מר / גברת ב מקבל הוראה מאוחר יותר להעלות את מחיר הספר הזה בעוד 300 ין.

אם שניהם יעלו את המחיר בנפרד במקום באותו זמן, מחיר הספר יהיה 1800 ין. אם אתה ניגש אליו באותו זמן, הוא יירשם כ 1500 ין ו 1300 ין, בהתאמה, כך שזה לא יהיה 1800 ין לא משנה איזה מהם נרשם.

הבעיה כאן היא שמי שעדכן מאוחר יותר יכול לעדכן אותו מבלי לדעת את המידע שעודכן בעבר.

מקבילה אופטימית

את הבעיה הנ"ל ניתן לפתור על ידי ביצוע "שליטה מקבילית אופטימית" הפעם. כדי להסביר בפשטות, איזה סוג של שליטה היא "לנצח הראשון אם אתה מנסה לערוך את הנתונים באותו זמן". מי שינסה לחדש מאוחר יותר יקבל שגיאה במועד העדכון ולא יוכל להירשם.

אתה עשוי לחשוב שאתה לא יכול לרשום נתונים חדשים עם זה, אבל זה רק "כאשר אתה מנסה לשנות את זה באותו זמן". זה אפשרי עבור שני אנשים לערוך בזמנים שונים לחלוטין. כמובן, במקרה זה, הנתונים של האדם האחרון עודכן יהיה האחרון.

באופן ספציפי, איזה סוג של בקרת עיבוד ניתן להשיג על ידי "בעל גרסה של הנתונים". לדוגמה, בדוגמה לעיל, יהיו לך הנתונים הבאים.

ערך שם פרמטר
שם הספר ספרי מאגרי מידע
מחיר 1000
גירסה 1

הגירסה גדלה ב- 1 עבור כל עדכון רשומה. לדוגמה, אם Mr./Ms. A קובע את המחיר ל- 1500 ין, הגרסה תהיה 2. בשלב זה, התנאי שניתן לבצע את העדכון הוא שהגירסה שלפני העדכון זהה לגירסה במסד הנתונים. כאשר Mr./Ms. מעדכן אותו, הגרסה במסד הנתונים היא 1, והגירסה של הנתונים המקוריים שנערכים כעת היא 1, כך שניתן לעדכן אותה.

בהתבסס על מפרט זה, נניח מצב שבו מר / גברת א' ומר / גב' ב' עורכים את אותם נתונים. כאשר 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 העמודה המתארת . זהו ניהול גירסאות רשומות. על-ידי ציון כסוג 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 מיועד לבדיקת הערכים במסד הנתונים. אני לא באמת צריך את זה כי אפשר להחליף את א' או ב'.

// それぞれがデータを編集しようと読み込む
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 ספר א' עודכנו. כמו כן, אם תקרא את ערך DB ישירות, הערך המעודכן יהיה זהה לערך המעודכן. הצלחתי לעדכן את זה כי גם נכנסו RowVersion ב- A וגם RowVersion ב- DB היו AAAAAAAAH2k= . הגירסה השתנתה AAAAAAAAH2o= עקב העדכון.

לאחר עדכון א', עדכן את ב' באותו אופן.

// そのあと 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 אותו באפליקציית קונסולה פשוטה. אם ברצונך להוסיף את מסך העריכה לאחר טעינת הנתונים ביישום אינטרנט או ביישום לקוח, RowVersion בדרך כלשהי, כך שניתן יהיה לקבוע אותו כראוי כאשר הוא מתעדכן.

עם זאת, ל- Entity Framework Core יש פונקציית מעקב אחר שינויים, כך שאם ברצונך להגדיר את הישן RowVersion לערך שנקרא ממסד הנתונים בזמן העדכון,

bookB.RowVersion = <古い RowVersion>;

גם אם היא תיקבע באופן הבא, היא לא תישפט נכון כ"שליטה מקבילית אופטימית". RowVersion גם אם תגדיר את הערך כרגיל, הוא יזוהה רק כערך שהשתנה, כך שלהלן

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

יש צורך לשכתב את הערך לפני השינוי.