【C#】EF Coreでオプティミスティック同時実行制御(排他制御)を行う方法

目次

概要

Entity Framework Core (EF Core) で、複数のユーザーが同時に同じデータを更新した際の「後勝ち(上書き)」を防ぐ実装パターンです。 在庫管理や座席予約など、整合性が重要なデータに対し、[Timestamp] 属性を用いた行バージョン管理を行うことで、競合時に DbUpdateConcurrencyException を発生させます。

仕様(入出力)

  • 入力: 商品在庫ID、変更後の在庫数。
  • 出力: 正常更新時は完了メッセージ。競合発生時は例外を捕捉し、競合した値(DBの現在値)を表示。
  • 前提: .NET 6.0以上。
    • ※本コードは InMemory データベースを使用していますが、実運用では SQL Server 等の rowversion 対応DBを想定しています。

基本の使い方

エンティティの byte[] プロパティに [Timestamp] 属性を付与し、SaveChangesAsynctry-catch で囲んで実行します。

public class Stock
{
    public int Id { get; set; }
    public int Quantity { get; set; }
    
    [Timestamp] // 自動更新されるバージョン管理列
    public byte[] RowVersion { get; set; }
}

// 保存処理
try
{
    await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    // 競合発生時の処理
}

コード全文

ここでは「倉庫管理システム」において、担当者Aと担当者Bが同時に同一商品の在庫数を変更しようとして競合が発生するシナリオを提示します。

外部ライブラリとして Microsoft.EntityFrameworkCore.InMemory が必要です。

dotnet add package Microsoft.EntityFrameworkCore.InMemory
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

public class Program
{
    public static async Task Main()
    {
        var options = new DbContextOptionsBuilder<InventoryDbContext>()
            .UseInMemoryDatabase("WarehouseDb")
            .Options;

        // --- 1. 初期データの準備 ---
        using (var context = new InventoryDbContext(options))
        {
            context.ProductStocks.Add(new ProductStock 
            { 
                Id = 101, 
                ProductName = "Gaming Mouse G-500", 
                Quantity = 50 
            });
            await context.SaveChangesAsync();
        }

        // --- 2. 競合(コンフリクト)の再現 ---
        Console.WriteLine("--- 在庫更新処理を開始 ---");

        // 担当者Aが在庫データを取得(画面表示中)
        using (var contextA = new InventoryDbContext(options))
        using (var contextB = new InventoryDbContext(options))
        {
            var stockA = await contextA.ProductStocks.FindAsync(101);
            Console.WriteLine($"[担当者A] 取得: {stockA.ProductName} / 在庫: {stockA.Quantity}");

            // ここで担当者Bが割り込んで在庫を出庫(更新)してしまう
            var stockB = await contextB.ProductStocks.FindAsync(101);
            stockB.Quantity = 40; // 10個出庫
            await contextB.SaveChangesAsync();
            Console.WriteLine($"[担当者B] 更新完了: 在庫を 40 に変更しました。");

            // 担当者Aは古い情報を元に入庫処理を行おうとする
            stockA.Quantity = 60; // 50個だと思っているので +10 して 60 に設定
            Console.WriteLine($"[担当者A] 保存を試行 (在庫 60 へ更新)...");

            try
            {
                // ここで例外が発生する(担当者Aが持っているRowVersionが古いため)
                await contextA.SaveChangesAsync();
                Console.WriteLine("[担当者A] 保存成功");
            }
            catch (DbUpdateConcurrencyException ex)
            {
                Console.WriteLine("--------------------------------------------------");
                Console.WriteLine("【エラー】保存失敗:他のユーザーによりデータが更新されています。");
                Console.WriteLine("--------------------------------------------------");
                
                // 競合内容の確認
                var entry = ex.Entries[0];
                var databaseValues = await entry.GetDatabaseValuesAsync();
                
                var dbQty = (int)databaseValues["Quantity"];
                Console.WriteLine($"自分の入力値: {stockA.Quantity}");
                Console.WriteLine($"DBの現在値  : {dbQty}");
                Console.WriteLine("※画面をリロードして最新の在庫数を確認してください。");
            }
        }
    }
}

// エンティティ定義
public class ProductStock
{
    public int Id { get; set; }
    public string ProductName { get; set; } = string.Empty;
    public int Quantity { get; set; }

    // 同時実行制御用のトークン
    // SQL Serverでは rowversion 型、更新時に自動的に値が変わる
    [Timestamp]
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

// DbContext定義
public class InventoryDbContext : DbContext
{
    public InventoryDbContext(DbContextOptions<InventoryDbContext> options)
        : base(options) { }

    public DbSet<ProductStock> ProductStocks => Set<ProductStock>();
}

カスタムポイント

  • Timestamp属性の代替: データベース側に専用の型がない場合、[ConcurrencyCheck] 属性を Quantity プロパティ自体に付与することで、その値が変更されていた場合に競合とみなす制御も可能です。
  • 自動解決ロジック: 在庫の増減(Quantity += 10)のような処理であれば、例外発生時に ReloadAsync で最新値を取り直し、再度加算を行って保存するリトライロジックを組むこともあります。
  • 非表示列: RowVersion はバイナリデータでありユーザーに見せる必要はないため、DTO(データ転送用オブジェクト)へのマッピング時には除外するか、Base64文字列として隠しフィールドに保持させます。

注意点

  1. ステートレスなWebアプリ: Web API等では、取得時の RowVersion をクライアント側に渡し、更新リクエスト時にその RowVersion を送り返してもらう必要があります。リクエストに含まれる RowVersion をエンティティにセットしてから SaveChangesAsync を呼ぶことで排他制御が成立します。
  2. 全更新のリスク: 競合を無視して Client Wins(自分の入力を優先)とする実装にする場合、相手の更新内容(今回の例だと担当者Bの出庫処理)を無かったことにしてしまうため、業務要件と照らし合わせて慎重に設計してください。
  3. InMemoryの制限: テスト用データベースによっては [Timestamp] の自動更新が完全にエミュレートされない場合があります。その場合は SaveChanges をオーバーライドして手動でバイト列を更新するコードを追加する必要があります。

応用

競合発生時のリトライ処理(加算ロジックの場合)

在庫の「上書き」ではなく「変動」を扱いたい場合、最新値を取得し直して計算をやり直すパターンです。

int retryCount = 0;
while (retryCount < 3)
{
    try
    {
        await context.SaveChangesAsync();
        break; // 成功したらループを抜ける
    }
    catch (DbUpdateConcurrencyException ex)
    {
        // 最新の値をDBから再取得
        var entry = ex.Entries.Single();
        await entry.ReloadAsync();

        // 業務ロジックを再適用(例:現在の在庫に対して+10する)
        var stock = (ProductStock)entry.Entity;
        stock.Quantity += 10; 
        
        retryCount++;
    }
}

まとめ

エラー発生時は単に失敗させるだけでなく、最新値をユーザーに提示するか、自動でリトライするかを業務要件に応じて決定してください。

[Timestamp] 属性を使用することで、データベースレベルでの厳密な排他制御を低コストで実装できます。

商品在庫や金銭データなど、整合性が最優先されるデータには必ず適用すべきパターンです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次