2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EFCoreのデータ競合のリカバリ時の動きを一からプロジェクトを作って説明する

Last updated at Posted at 2025-01-15

はじめに

EFCoreの競合解決の流れが公式のドキュメントだけだとわかりにくいので、解説してほしいと言われたときのメモです。基本的には公式のドキュメントのコードのまま、実行結果のキャプチャやSQLを追記しました。

トランザクション分離レベルに関する内容を追記しました。

前準備

確認用のコンソールプロジェクトを作成します。
コマンドのパース用にConsoleAppFramework、データベース接続にPomelo.EntityFrameworkCore.MySql、ダミーデータ作成にBogusをインストールしています。

❯ dotnet new console -o DbConcurencyExceptionRecoverySample
❯ cd DbConcurencyExceptionRecoverySample
❯ dotnet new tool-manifest
❯ dotnet new gitignore
❯ dotnet add package ConsoleAppFramework
❯ dotnet add package Pomelo.EntityFrameworkCore.MySql --version 8.0.2
❯ dotnet add package Microsoft.EntityFrameworkCore.Design --version 8.0.12
❯ dotnet add package Bogus
❯ dotnet tool install dotnet-ef --version 8.0.12

とりあえずBogusを使ってEFCoreでダミーデータをMySQLに登録するサブコマンドと、それを一覧表示するサブコマンドを書きます。サンプルで用意したBlogテーブルには競合検知用のRowVersion列を追加しています。

Program.cs
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bogus;
using ConsoleAppFramework;
using Microsoft.EntityFrameworkCore;

var app = ConsoleApp.Create();

app.Add("drop", async () =>
{
    Console.WriteLine("drop... drop database");
    await using var dbContext = new MyDbContext();
    await dbContext.Database.EnsureDeletedAsync();
});

app.Add("init", async () =>
{
    Console.WriteLine("init... add sample data");
    await using var dbContext = new MyDbContext();
    await dbContext.AddRangeAsync(new Faker<Blog>()
        .RuleFor(p => p.Url, f => f.Internet.Url())
        .Generate(1000));
    await dbContext.SaveChangesAsync();
});

app.Add("show", async () =>
{
    Console.WriteLine("show... show all data");
    await using var dbContext = new MyDbContext();
    foreach (var blog in dbContext.Blogs)
    {
        Console.WriteLine($"BlogId: {blog.BlogId}, Url: {blog.Url}");
    }
});

app.Add("update", async (int id, string url) =>
{
    // 後で実装
});

app.Run(args);

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; } = null!;
    [Timestamp]
    public byte[] RowVersion { get; set; } = null!;
}

public class MyDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseMySql("server=localhost;port=3306;database=mydb;uid=root;pwd=root", ServerVersion.Parse("8.0"));
}

テスト用のデータベースを起動し、

❯ docker run -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password -e MYSQL_PASSWORD=root -e MYSQL_DATABASE=mydb mysql

データベースマイグレーションを登録して実行します。

❯ dotnet ef migrations add FirstMigration
❯ dotnet ef database update

データの登録(init)と表示(show)サブコマンドを実行して中身が入ったことを確認します。

❯ dotnet run init
init... add sample data

❯ dotnet run show
show... show all data
BlogId: 1, Url: http://forrest.biz
BlogId: 2, Url: http://sarina.org
BlogId: 3, Url: https://dan.com
...
BlogId: 998, Url: http://augustus.biz
BlogId: 999, Url: http://vida.info
BlogId: 1000, Url: http://lindsay.net

競合を確認する。

Program.csupdateサブコマンドを作り複数のウインドウで実行します。

Program.cs
app.Add("update", async (int id, string url) =>
{
    await using var dbContext = new MyDbContext();
    var blog = (await dbContext.Blogs.FindAsync(id))!;
    Console.WriteLine($"before {JsonSerializer.Serialize(blog)}");
    blog.Url = $"{url}: {DateTimeOffset.Now}";
    Console.WriteLine("Press any key to save changes");
    Console.ReadKey();
    await dbContext.SaveChangesAsync();
    Console.WriteLine($"after {JsonSerializer.Serialize(blog)}");
});

左側のウインドウでは更新が成功しましたが、右側のウインドウではMicrosoft.EntityFrameworkCore.DbUpdateConcurrencyExceptionが発生して更新が失敗したことを確認できます。
image.png

これは、Blogテーブルが持つRowVersionカラムをもとにEFCoreが同時実行を検知して、更新対象がすでに他のプロセスによって更新された後であることを検知したためです。詳細は下記のマイクロソフトの解説を確認ください。

競合を検知した場合どのようにふるまうかは場合によるのですが、例えばとにかく後勝ちにしたいといった場合は、次のようにデータベースの値を再取得した後に現在の値をかぶせて更新を再実行します。

今回の例では、競合検知時にすでにデータが削除されていた(GetDatabaseValuesの結果がnull)場合解決をスキップしていますが、場合によってほかの対処が必要になるかもしれません。

app.Add("update", async (int id, string url) =>
{
    await using var dbContext = new MyDbContext();
    var blog = (await dbContext.Blogs.FindAsync(id))!;
    Console.WriteLine($"before {JsonSerializer.Serialize(blog)}");
    blog.Url = $"{url}: {DateTimeOffset.Now}";
    Console.WriteLine("Press any key to save changes");
    Console.ReadKey();
    try
    {
        await dbContext.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        Console.WriteLine("restore concurrency...");
        // 競合が発生したレコードを列挙する(今回は1件しか入っていないはずだけれど)
        foreach (var entry in ex.Entries)
        {
            var b = entry.Entity as Blog;
            if (b == null) continue;
            
            // 最新の情報を取得
            var target = entry.GetDatabaseValues();
            var current = entry.CurrentValues;
            
            // すでに削除されている場合はスキップ
            if (target == null) continue;

            // 対象が存在した場合は、データベースの値にかぶせて更新
            target[nameof(blog.Url)] = current[nameof(blog.Url)];
            // ほかにもリカバリ対象があれば同じように更新する
            // 例
            // target[nameof(blog.Score)] = current[nameof(blog.Score)];
            // target[nameof(blog.Author)] = current[nameof(blog.Author)];
            
            entry.OriginalValues.SetValues(target);
        }
        // リカバリーしたデータを書き戻す
        await dbContext.SaveChangesAsync();
    }
    Console.WriteLine($"after {JsonSerializer.Serialize(blog)}");
});

image.png

裏でどのようなSQLが実行されたか確認してみましょう。今回はGenericHostではないのでDbContextに直にLogToオプションを設定後に改めて二つのウインドウで実行します。

public class MyDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options
            .UseMySql("server=localhost;port=3306;database=mydb;uid=root;pwd=root", ServerVersion.Parse("8.0"))
            .LogTo(Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information);
}

image.png

競合が発生した側のログを見ると、下記の順で解決が行われているのを確認できます。

  1. 更新対象の取得
  2. 更新(データベースへの反映)
  3. (競合の発生)
  4. 最新の情報を取得
  5. 更新(データベースへの反映)
❯ dotnet run update --id 1 --url test2

1. 更新対象の取得
info: 2025/01/15 10:48:01.979 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (43ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT `b`.`BlogId`, `b`.`RowVersion`, `b`.`Url`
      FROM `Blogs` AS `b`
      WHERE `b`.`BlogId` = @__p_0
      LIMIT 1
before {"BlogId":1,"Url":"test: 2025/01/15 10:42:29 \u002B09:00","RowVersion":"CN01BeF0q6Y="}
Press any key to save changes
\

2. 更新(データベースへの反映)
info: 2025/01/15 10:48:05.471 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p1='?' (DbType = Int32), @p2='?' (DbType = DateTime), @p0='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      UPDATE `Blogs` SET `Url` = @p0
      WHERE `BlogId` = @p1 AND `RowVersion` = @p2;
      SELECT `RowVersion`
      FROM `Blogs`
      WHERE ROW_COUNT() = 1 AND `BlogId` = @p1;

3. (競合の発生)
restore concurrency...

4. 最新の情報を取得
info: 2025/01/15 10:48:05.496 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT `b`.`BlogId`, `b`.`RowVersion`, `b`.`Url`
      FROM `Blogs` AS `b`
      WHERE `b`.`BlogId` = @__p_0
      LIMIT 1

5. 更新(データベースへの反映)
info: 2025/01/15 10:48:05.504 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@p1='?' (DbType = Int32), @p2='?' (DbType = DateTime), @p0='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      UPDATE `Blogs` SET `Url` = @p0
      WHERE `BlogId` = @p1 AND `RowVersion` = @p2;
      SELECT `RowVersion`
      FROM `Blogs`
      WHERE ROW_COUNT() = 1 AND `BlogId` = @p1;
      
after {"BlogId":1,"Url":"test2: 2025/01/15 10:48:02 \u002B09:00","RowVersion":"CN01Bqei9bw="}

読み取り一貫性

MySQLの場合、トランザクション分離レベルのデフォルトがRepeatableReadになっている影響で、トランザクション内でこの処理を実行すると別トランザクションで更新したデータを参照できず引き続きDbConcurrencyExceptionが発生します。

app.Add("update", async (int id, string url) =>
{
    await using var dbContext = new MyDbContext();
    await using var tsx = await dbContext.Database.BeginTransactionAsync();
    
    var blog = (await dbContext.Blogs.FindAsync(id))!;
    Console.WriteLine($"before {JsonSerializer.Serialize(blog)}");
    blog.Url = $"{url}: {DateTimeOffset.Now}";
    Console.WriteLine("Press any key to save changes");
    Console.ReadKey();
    try
    {
        await dbContext.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        Console.WriteLine("restore concurrency...");
        foreach (var entry in ex.Entries)
        {
            var b = entry.Entity as Blog;
            
            if (b == null) continue;
            // 競合の解決のためにデータベースから最新の値を取得しようとしているが、
            // トランザクション分離レベルがRepeatableReadであるため、
            // BeginTransaction後に他のトランザクションでコミットされたデータを読み取れない。
            var target = entry.GetDatabaseValues();
            var current = entry.CurrentValues;

            if (target == null) continue;
            target[nameof(blog.Url)] = current[nameof(blog.Url)];
            entry.OriginalValues.SetValues(target);
        }
        await dbContext.SaveChangesAsync();
    }
    await tsx.CommitAsync();
    Console.WriteLine($"after {JsonSerializer.Serialize(blog)}");
});

対応としては、トランザクション分離レベルをReadCommitted以下にするか、コネクションをもう一つ作りそちらからリカバリー用データを読むなどが考えられます。

トランザクション分離レベルをReadCommittedにして更新する。
    await using var dbContext = new MyDbContext();
    var isolatedLevel = IsolationLevel.ReadCommitted;
    await using var tsx = await dbContext.Database.BeginTransactionAsync(isolatedLevel);
    Console.WriteLine(isolatedLevel);

おわりに

バッチのリカバリはAll or Nothingですべてなかったことにするか、処理したところまでは確定し再実行時は処理済みをスキップなどが楽でよいですが、どうにか自動復旧させたいといった要望も多くあると思います。どうしても抜けが出るのでもう少し楽にならないですかね。。。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?