コンカレンシーの競合の処理

ヒント

この記事のサンプルは GitHub で確認できます。

ほとんどのシナリオ内では、データベースは複数のアプリケーション インスタンスによって同時に使用され、それぞれが互いに独立してデータに対して変更を加えます。 同じデータが同時に変更されると、不整合やデータ破損が発生する場合があります。たとえば、2 つのクライアントが同じ行の中の、何かしらの方法で関連付けられている別々の列を変更する場合などです。 このページでは、このような同時変更に直面しても、ご利用のデータの整合性を確保するメカニズムについて説明します。

オプティミスティック コンカレンシー

EF Core では "オプティミスティック同時実行制御" を実装します。これは、コンカレンシーの競合が比較的まれであることを前提としています。 データを事前にロックしてからのみ変更に進む "ペシミスティック" アプローチとは対照的に、オプティミスティック同時実行制御ではロックをかけませんが、そのデータがクエリの実行後に変更された場合、そのデータ変更が失敗するように調整します。 このコンカレンシー失敗はアプリケーションに報告されます。そのアプリケーションは、新しいデータに対してその操作全体を可能な限り、もう一度試すことで適切に処理します。

EF Core 内では、オプティミスティック同時実行制御は、プロパティを "コンカレンシー トークン" として構成することによって実装されます。 このコンカレンシー トークンは、他のプロパティと同様に、エンティティにクエリが実行される際に読み込まれ、追跡されます。 それから、SaveChanges() の中で更新または削除操作が実行されると、データベース上のコンカレンシー トークンの値が、EF Core によって読み取られた元の値と比較されます。

このしくみを理解するために、SQL Server を使用していると仮定して、特殊な Version プロパティを持つ典型的な Person エンティティ型を定義しましょう。

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

これによって SQL Server 内では、行が変更されるたびにデータベース内で自動的に変更されるコンカレンシー トークンが構成されます (詳しくは以下をご参照ください)。 この構成を用意して、単純な更新操作で何が起きるかを調べてみましょう。

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. 最初のステップ内では、Person がこのデータベースから読み込まれます。これにはコンカレンシー トークンが含まれます。これでこのトークンは、残りのプロパティと共に、EF によって通常どおりに追跡されます。
  2. それから、この Person インスタンスを何らかの方法で変更します (FirstName プロパティを変更します)。
  3. 次に、この変更を永続化するように EF Core へ指示をします。 コンカレンシー トークンが構成されているため、EF Core は次の SQL をこのデータベースに送信します。
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

この WHERE 句内の PersonId に加えて、EF Core が Version にも条件を追加したことにご注意ください。これにより、クエリを実行したその瞬間から Version 列が変更されていない場合にのみ、この行は変更されます。

通常の ("オプティミスティック" の) 場合、同時更新は起こらず、UPDATE は正常に完了し、行は変更されます。データベースは、1 行が想定どおりに UPDATE の影響を受けたことを EF Core に報告します。 ただし、同時更新が発生した場合、この UPDATE は一致する行を見つけることができず、影響を受けた行は 0 行だと報告します。 その結果、EF Core の SaveChanges()DbUpdateConcurrencyException をスローします。アプリケーションはこれをキャッチして、適切に処理する必要があります。 これを行う手法については下の、「コンカレンシーの競合の解決」で詳しく説明されています。

上記の例では、既存のエンティティへの "更新" について説明しました。 EF は、同時に変更された行を "削除" しようとした場合にも DbUpdateConcurrencyException をスローします。 ただし、エンティティを追加する場合にこの例外がスローされることはありません。一方で、同じキーを含む複数の行が挿入されている場合、データベースは実際に一意制約違反を起こす場合があります。この結果、プロバイダー固有の例外がスローされ、DbUpdateConcurrencyException はスローされません。

ネイティブ データベースで生成されたコンカレンシー トークン

上記のコード内では、[Timestamp] 属性を使用してプロパティを SQL Server の rowversion 列にマップしました。 その行が更新されると rowversion は自動的に変更されるため、その行全体を保護する最小限の作業量のコンカレンシー トークンとして、とても便利です。 SQL Server の rowversion 列をコンカレンシー トークンとして構成するには、次のように行います。

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

上記の rowversion 型は SQL Server 固有の機能です。自動更新コンカレンシー トークンの設定に関する詳細はデータベースによって異なり、一部のデータベースではこれらがまったくサポートされていません (SQLite など)。 その正確な詳細については、ご利用のプロバイダーのドキュメントをご確認ください。

アプリケーションで管理されるコンカレンシー トークン

データベースでコンカレンシー トークンを自動的に管理する代わりに、アプリケーション コード内でコンカレンシー トークンを管理することができます。 これにより、ネイティブの自動更新の型が存在しないデータベース上 (SQLite など) で、オプティミスティック同時実行制御を使用することができます。 ただし、SQL Server 上でも、アプリケーションで管理されるコンカレンシー トークンは、どの列への変更でトークンの再生成を引き起こすかについて、正確なきめ細かい制御を提供することができます。 たとえば、キャッシュされたまたは重要ではない何かの値を含むプロパティがあり、そのプロパティへの変更についてはコンカレンシーの競合をトリガーしたくない場合があるかもしれません。

次に、GUID プロパティをコンカレンシー トークンとして構成します。

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }

    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

このプロパティはデータベースで生成されないため、変更を永続化するたびに、アプリケーション内でこのプロパティを割り当てる必要があります。

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
context.SaveChanges();

新しい GUID 値を常に割り当てる場合は、SaveChanges インターセプターを使用してこれを行うことができます。 ただし、コンカレンシー トークンを手動で管理する利点の 1 つは、コンカレンシー トークンが再生成されるタイミングを正確に制御して、不必要なコンカレンシーの競合を回避できることです。

コンカレンシーの競合の解決

ご利用のコンカレンシー トークンの設定方法にかかわらず、オプティミスティック同時実行制御を実装するには、コンカレンシーの競合が発生して DbUpdateConcurrencyException がスローされるケースを、ご利用のアプリケーションで適切に処理する必要があります。これは "コンカレンシーの競合の解決" と呼ばれます。

オプションの 1 つは、競合する変更が原因による更新の失敗を、単純にユーザーへ通知することです。そのユーザーはそれからその新しいデータを読み込んで、もう一度試すことができます。 または、ご利用のアプリケーションが自動更新を実行している場合は、そのデータにもう一度クエリを実行してから、単純にすぐループしてもう一度試すことができます。

コンカレンシーの競合を解決する、より洗練された方法は、保留中の変更をデータベース内の新しい値と "マージ" することです。 マージされる値の正確な詳細はアプリケーションによって異なります。そしてそのプロセスは、両方の値のセットが表示されたユーザー インターフェイスによって指示する場合があります。

コンカレンシーの競合を解決するために使用可能な 3 つの設定値を次に示します。

  • 現在の値は、アプリケーションがデータベースへの書き込みを試行している値です。
  • 元の値は、何らかの編集が行われる前に、データベースから取得された元の値です。
  • データベース値は、データベースに現在格納されている値です。

コンカレンシーの競合を処理する一般的な方法は、次のとおりです。

  1. SaveChanges の間に DbUpdateConcurrencyException をキャッチします。
  2. DbUpdateConcurrencyException.Entries を使用して、影響を受けるエンティティに対する新しい変更の設定を準備します。
  3. コンカレンシー トークンの元の値を更新して、データベースの現在の値を反映します。
  4. 競合が発生しなくなるまで、プロセスを再試行します。

次の例では、Person.FirstNamePerson.LastName は、コンカレンシー トークンとして設定されています。 保存する値を選択するためのアプリケーション固有のロジックを含める場所に、// TODO: コメントがあります。

using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = context.People.Single(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";

// Change the person's name in the database to simulate a concurrency conflict
context.Database.ExecuteSqlRaw(
    "UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");

var saved = false;
while (!saved)
{
    try
    {
        // Attempt to save changes to the database
        context.SaveChanges();
        saved = true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            if (entry.Entity is Person)
            {
                var proposedValues = entry.CurrentValues;
                var databaseValues = entry.GetDatabaseValues();

                foreach (var property in proposedValues.Properties)
                {
                    var proposedValue = proposedValues[property];
                    var databaseValue = databaseValues[property];

                    // TODO: decide which value should be written to database
                    // proposedValues[property] = <value to be saved>;
                }

                // Refresh original values to bypass next concurrency check
                entry.OriginalValues.SetValues(databaseValues);
            }
            else
            {
                throw new NotSupportedException(
                    "Don't know how to handle concurrency conflicts for "
                    + entry.Metadata.Name);
            }
        }
    }
}

コンカレンシー制御に対する分離レベルの使用

コンカレンシー トークンを使用したオプティミスティック同時実行制御が、同時変更に対してデータの整合性を確保する唯一の方法というわけではありません。

整合性を確保するメカニズムの 1 つは、"REPEATABLE READ" トランザクション分離レベルです。 ほとんどのデータベースにおいてこのレベルは、トランザクションが後続の同時実行アクティビティの影響を受けることなく、そのトランザクションが開始された際と同じようにデータベース内のデータを参照することを保証します。 上記の基本的なサンプルでは、何らかの方法で更新するために Person へクエリを実行する際に、そのデータベースはこのトランザクションが完了するまで、他のトランザクションがそのデータベースの行に干渉しないようにする必要があります。 ご利用のデータベースの実装に応じて、これは次の 2 つの方法のいずれかで実行されます。

  1. その行に対してクエリが実行されると、ご利用のトランザクションはその行に対して共有ロックを入手します。 その行を更新しようとしている外部のトランザクションは、ご利用のトランザクションが完了するまでブロックされます。 これはペシミスティック ロックの一種であり、SQL Server の "REPEATABLE READ" 分離レベルによって実装されます。
  2. ロックする代わりに、データベースは外部トランザクションによるその行の更新を許可しますが、ご利用のトランザクションでその行を更新しようとすると、コンカレンシーの競合が発生したことを示す "シリアル化" エラーを起こします。 これはオプティミスティック ロックの一種であり (EF のコンカレンシー トークン機能とは異なり)、SQL Server の SNAPSHOT 分離レベルによって、また PostgreSQL の REPEATABLE READ 分離レベルによって実装されます。

"SERIALIZABLE" な分離レベルは、REPEATABLE READ と同じ保証を提供する (および追加の保証を加える) ため、上記に関しては同じ方法で機能する点にご注意ください。

より高い分離レベルを使用してコンカレンシーの競合を管理する方がより単純で、コンカレンシー トークンが必要ありません。また、他の利点もあります。たとえば、REPEATABLE READ では、トランザクション内のクエリ全体で常に同じデータがご利用のトランザクションで参照され、不整合が回避されます。 ただし、このアプローチには欠点があります。

まず、ご利用のデータベース実装でロックを使用して分離レベルを実装する場合、その同じ行を変更しようとする他の複数のトランザクションを、トランザクション全体でブロックする必要があります。 これは同時実行のパフォーマンスに悪影響を及ぼす場合があります (ご利用のトランザクションは短くしましょう)。EF のメカニズムでは例外がスローされ、代わりに再試行が強制されますが、これもパフォーマンスに影響があることにご注意ください。 これは、SQL Server の REPEATABLE READ レベルには適用されますが、(クエリが実行された行をロックしない) SNAPSHOT レベルには適用されません。

さらに重要なのは、このアプローチでは、トランザクションがすべての操作にまたがる必要がある点です。 たとえば、ユーザーに対して詳細を表示するために Person へクエリを実行し、次にそのユーザーが変更を加えるのを待つ場合、そのトランザクションはおそらく長い時間存続する必要があります (ほとんどの場合、そのような状況は避ける必要があります)。 そのため、このメカニズムは通常、含まれるすべての操作がすぐに実行され、トランザクションが (その期間を長くする可能性がある) 外部の入力に依存しない場合に適しています。

その他のリソース

競合検出を含む ASP.NET Core のサンプルについては、「EF Core での競合の検出」を参照してください。