Start Debugging

Fix: EF Core の並行 INSERT で発生する 23505: duplicate key value violates unique constraint

ハンドラーの「確認してから挿入」はアトミックではありません。SqlState 23505 の PostgresException を捕捉するか、全体を 1 つの INSERT ... ON CONFLICT 文にまとめてください。EnableRetryOnFailure は役に立ちません。

ハンドラーが「このメールアドレスはすでに存在するか」を問い合わせ、何も見つからないので挿入します。負荷がかかると 2 つのリクエストが同時にそれを行い、どちらも何も見つけられず、Postgres は敗者をインデックスの時点で 23505 として拒否します。一意インデックスはバグではなく、バグを捕まえた唯一の仕組みです。修正方法は 2 つあります。読み取りと書き込みを 1 つの INSERT ... ON CONFLICT 文にまとめて両者の間の隙間をなくすか、素朴な挿入をそのまま残し、内部例外が SqlState == PostgresErrorCodes.UniqueViolationPostgresException である DbUpdateException を捕捉して、勝者が書き込んだ行を読み直すかです。EnableRetryOnFailure に頼ってはいけません。Npgsql の一時的エラー検出器は 23505 に対して false を返すため、回復性のレイヤーは例外をそのままあなたに渡してきます。

検証方法についての補足です。このマシンにある SDK は .NET 10.0.302 だけで、Postgres サーバーは動いていません。そのため以下の内容は Npgsql 10.0.3、Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3、Microsoft.EntityFrameworkCore 10.0.4 に対してオフラインで確認しました (定数の値、一時的例外の検出器、生成される SQL、change tracker の状態)。加えてサーバー側の挙動は PostgreSQL 18 のドキュメントに基づいています。Npgsql プロバイダー 11.0 は執筆時点でまだプレビューであり、その 11.0 リリースノート にはエラーマッピング、SaveChanges のバッチ処理、再試行検出器の変更は挙がっていないため、内容はすべて EF Core 11 とプロバイダー 11.0 にも当てはまります。このマシンでの実行ではなくサーバーのドキュメントに基づく記述については、その旨を明記します。

エラーの実際の姿

Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
 ---> Npgsql.PostgresException (0x80004005): 23505: duplicate key value violates unique constraint "IX_Users_Email"

DETAIL: Key ("Email")=(ada@example.com) already exists.
   at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(...)
   at Npgsql.NpgsqlDataReader.NextResult(...)
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(...)
   at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(...)

このブロックには注意して読むべき点が 2 つあります。

制約名がどの障害なのかを教えてくれます。IX_Users_Email は自分で宣言した一意インデックスなので、これはアプリケーションレベルの競合状態です。代わりに PK_Users と表示されている場合は、ほぼ確実に identity シーケンスがずれており、これはまったく別の問題です。後述します。

DETAIL: の行が完全に欠けていることもあります。Npgsql の接続文字列パラメーター Include Error Detail は既定で false です (検証済み: Npgsql 10.0.3 で new NpgsqlConnectionStringBuilder("Host=h;Database=d").IncludeErrorDetailFalse を返します)。詳細テキストには衝突したキーの値が含まれ、それはしばしば個人データだからです。値が必要な開発環境では Include Error Detail=true を追加し、キーがログに残ることを許容できないなら本番では無効のままにしてください。

なぜこれが起きるのか

支配的な原因、そして「負荷がかかったときだけ起きる」に一致するのは、確認とそれに続く挿入が、間に隙間のある 2 つの文だということです。READ COMMITTED トランザクションの中には、別のセッションがその隙間に挿入するのを妨げるものは何もありません。インデックスの一意性チェック に関する PostgreSQL のドキュメントは、相手のセッションがまだコミットしていないときにサーバーが何をするかをこう説明しています。“If a conflicting row has been inserted by an as-yet-uncommitted transaction, the would-be inserter must wait to see if that transaction commits.” 相手がロールバックすれば競合はなく、あなたの挿入は進みます。相手がコミットすれば 23505 を受け取ります。このエラーがまとまって発生し、リクエストが 1 つしか飛んでいない開発者のノート PC では決して再現しないのは、そのためです。

同じ SQLSTATE を生む原因が他に 2 つあり、並行処理のコードを書く前に除外しておく価値があります。

最小限の再現

// .NET SDK 10.0.302, EF Core 10.0.4, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3
public class User
{
    public int Id { get; set; }
    public string Email { get; set; } = "";
    public string Name { get; set; } = "";
}

protected override void OnModelCreating(ModelBuilder mb)
    => mb.Entity<User>().HasIndex(u => u.Email).IsUnique();

このモデルは Npgsql プロバイダーからちょうど次の DDL を生成します (db.Database.GenerateCreateScript()、オフラインで実行):

CREATE TABLE "Users" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    "Email" text NOT NULL,
    "Name" text NOT NULL,
    CONSTRAINT "PK_Users" PRIMARY KEY ("Id")
);

CREATE UNIQUE INDEX "IX_Users_Email" ON "Users" ("Email");

そして、競合に負けるハンドラーがこちらです。

// Racy: the gap between AnyAsync and SaveChangesAsync is unguarded.
public async Task<User> RegisterAsync(string email, string name, CancellationToken ct)
{
    if (await db.Users.AnyAsync(u => u.Email == email, ct))
        throw new EmailTakenException(email);

    var user = new User { Email = email, Name = name };
    db.Users.Add(user);
    await db.SaveChangesAsync(ct);   // 23505 when a second request got here first
    return user;
}

この 3 つの文をトランザクションで包んでも解決しません。トランザクションが与えるのは原子性であって相互排他ではありませんし、既定は READ COMMITTED です。分離レベルを上げても解決しません。一部のシナリオで返る SQLSTATE が変わるだけで、競合そのものは消えません。PostgreSQL の シリアライズ失敗の扱い のページはこのパターンを正面から扱っており、格納済みのキーを調べたあとに起きる一意キー違反について “is effectively a serialization failure, but the server will not detect it as such because it cannot see the connection between the inserted value and the previous reads.” と述べています。

修正 1: ON CONFLICT で 1 文にする

まず手を伸ばすべき修正がこれです。INSERT ... ON CONFLICT は 1 つの文なので誰かが割り込んで挿入する隙間がなく、競合の解決はサーバーのインデックス挿入パスの内側で行われます。

厄介なのは行を取り戻すことです。ON CONFLICT DO NOTHING は競合時に何も返しません。INSERT のドキュメント は、RETURNING が返すのは正常に挿入または更新された行だけだと述べています。そのため id を知る必要のある get-or-create では、行に触れて RETURNING の対象にするために、自己代入を伴う DO UPDATE を使います。

// EF Core 10.0.4 / Npgsql 10.0.3. Same code compiles unchanged on EF Core 11.
public async Task<int> GetOrCreateUserIdAsync(string email, string name, CancellationToken ct)
{
    var ids = await db.Database.SqlQuery<int>($"""
        INSERT INTO "Users" ("Email", "Name")
        VALUES ({email}, {name})
        ON CONFLICT ("Email") DO UPDATE SET "Email" = EXCLUDED."Email"
        RETURNING "Id" AS "Value"
        """).ToListAsync(ct);

    return ids.Single();
}

このスニペットでは 4 つの細部が要になっています。

  1. AS "Value" スカラー型に対する SqlQuery<T>Value という名前の列を読みます。別名がないと、コンパイルエラーではなく列が見つからないという実行時エラーになります。
  2. 補間された箇所は連結ではなくパラメーターです。 このクエリに対する ToQueryString()VALUES (@p0, @p1) を出力し、値は別に報告されます。つまり通常のインジェクションの懸念はここには当てはまりません。
  3. ToListAsync を使い、FirstOrDefaultAsync は決して使わないこと。 EF Core は生の SQL を調べ、SELECT ではない文の上に合成することを拒否します。LINQ 演算子を 1 つでも足すと InvalidOperationException: 'FromSql' or 'SqlQuery' was called with non-composable SQL and with a query composing over it. がスローされます。生成される SQL を確認しているときに NpgsqlQuerySqlGenerator でまさにこれに当たりました。先にリストをマテリアライズしてから選んでください。
  4. EXCLUDED は挿入しようとしている行です。 SET "Email" = EXCLUDED."Email" は意図的に何も変えない書き込みで、その唯一の目的は競合した行を RETURNING の対象にすることです。

id を本当に必要としないなら、ON CONFLICT ("Email") DO NOTHING を選んで書き込みの増幅を避けてください。自己代入版は新しい行バージョンを書き、xmax を進め、重複のたびに BEFORE UPDATE トリガーをすべて発火させます。

ドキュメントが明示しているもう 1 つの制約があります。ON CONFLICT DO UPDATE は 1 つの文の中で同じ既存行に二度触れることはなく、VALUES のリストに同じキーが 2 回含まれているとカーディナリティ違反 (21000) を発生させます。送信前に C# 側でバッチの重複を除去してください。

修正 2: 楽観的に挿入し、23505 を捕捉して読み直す

挿入がより大きな作業単位の奥に埋まっていて、生の SQL に書き換えるのが現実的でない場合は、インデックスをロック代わりにして敗北を処理します。

// EF Core 10.0.4 / Npgsql 10.0.3
public async Task<User> RegisterAsync(string email, string name, CancellationToken ct)
{
    var user = new User { Email = email, Name = name };
    db.Users.Add(user);

    try
    {
        await db.SaveChangesAsync(ct);
        return user;
    }
    catch (DbUpdateException ex)
        when (ex.InnerException is PostgresException
              {
                  SqlState: PostgresErrorCodes.UniqueViolation,
                  ConstraintName: "IX_Users_Email"
              })
    {
        // Someone else won. This context is poisoned: the entity is still Added.
        await using var fresh = await factory.CreateDbContextAsync(ct);
        return await fresh.Users.SingleAsync(u => u.Email == email, ct);
    }
}

PostgresErrorCodes.UniqueViolation は文字列 "23505" です (Npgsql 10.0.3 で検証済み)。マジックストリングより定数のほうが優れています。ConstraintName でも絞り込んでください。SqlState: "23505" だけの catch ブロックは、ずれたシーケンスに起因する主キーの衝突まで平気で飲み込み、データ破損のシグナルを静かな誤答に変えてしまいます。

新しいコンテキストが重要であり、これがこのパターンがスコープ付きの DbContext ではなく IDbContextFactory<T> と組み合わさる理由です。スコープ付きのコンテキストを注入してその上で再試行すると、同じ Added のエンティティを再送することになり、同じ例外を受け取ります。これは上で change tracker について確認した挙動そのものです。シングルトンサービスから DbContext を解決している 場合も同様です。

なぜ EnableRetryOnFailure はここで役に立たないのか

これは、すでに接続の回復性を追加していて、それがこのケースも守ってくれると考えている人がつまずく点です。守ってくれません。プロバイダー 10.0.3 の Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlTransientExceptionDetector に対して、プロバイダー自身の検出器をリフレクションで直接呼び出しました。

ShouldRetryOn(23505) = False     unique_violation
ShouldRetryOn(23503) = False     foreign_key_violation
ShouldRetryOn(40001) = True      serialization_failure
ShouldRetryOn(40P01) = True      deadlock_detected
ShouldRetryOn(53300) = True      too_many_connections
ShouldRetryOn(57P03) = True      cannot_connect_now
ShouldRetryOn(08006) = True      connection_failure

PostgresException.IsTransient も一致します。23505 では False4000140P01 では True です。この分類は正しいものです。本物の重複を盲目的に再試行しても、いつまでも失敗し続けるだけだからです。ただしそれは、再試行があなたの側の責任になるということでもあります。この操作にとって重複が何を意味するのかを判断できるレイヤーで行ってください。手動のトランザクションの周りに独自の実行戦略を追加する場合は、その途中で出会う 実行戦略はユーザーが開始したトランザクションをサポートしない というエラーに注意してください。

修正 3: get-or-create が複数の文にまたがるときのアドバイザリーロック

操作が本当に 1 文にまとめられないこともあります。テナントを作り、次にスキーマの行を作り、次に既定設定の行を作る必要があり、それを実行できる呼び出し元は 1 つだけ、というような場合です。テーブルではなくキーでシリアライズしてください。

// EF Core 10.0.4 / Npgsql 10.0.3
await using var tx = await db.Database.BeginTransactionAsync(ct);

// Held until the transaction commits or rolls back. No explicit unlock.
await db.Database.ExecuteSqlAsync(
    $"SELECT pg_advisory_xact_lock(hashtext({email}))", ct);

var existing = await db.Users.SingleOrDefaultAsync(u => u.Email == email, ct);
if (existing is not null) { await tx.CommitAsync(ct); return existing; }

db.Users.Add(new User { Email = email, Name = name });
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);

pg_advisory_xact_lock はトランザクションの終了時に自動的に解放されます。これはまさに欲しい性質です。finally ブロックの書き忘れでリークすることがありません。注意点が 2 つあります。hashtext は 32 ビットの値を返すため、異なるキー同士が衝突して不必要に直列化されることがあります。これはパフォーマンスの問題であって、正しさの問題ではありません。そしてこれは、すべての書き込み側がロックを取る場合にのみ機能します。一意インデックスはいずれにせよ残しておいてください。ロックを忘れたコードパスに対する最後の砦です。

同じように見えて違うもの

単独では成功する挿入がバッチでは失敗する。 EF Core は複数の保留中の挿入を 1 回のラウンドトリップにまとめ、1 つのトランザクションの中で実行します。そのためバッチのどこか 1 か所の重複が、追加したすべての行をロールバックさせます。DbUpdateException.Entries はサーバーが拒否したエンティティを教えてくれます。残りは手つかずですが、同時に未保存でもあります。数千行を挿入しているなら、これは別の書き込み経路に切り替える理由の 1 つです。その計測は EF Core 11 と Dapper の一括挿入比較 で行いました。

失敗のたびに id が飛び続ける。 想定どおりであり、直せません。シーケンス関数のドキュメント は明確です。“the value obtained by nextval is not reclaimed for re-use if the calling transaction later aborts.” さらに ON CONFLICT にも特に言及しています。nextval の呼び出しを含むタプルは、競合が検出される前に計算されるからです。重複の試行のたびに id が 1 つ焼かれます。キーがユーザーから見えていて欠番が許容できないなら、答えは欠番のないシーケンスではなく別のキー戦略です。データベースシーケンスからの主キー生成 を参照してください。

あり得ないと思っていた null 許容列での重複。 標準の一意インデックスは NULL 値を互いに異なるものとして扱うため、その列が NULL の行はいくつでも存在できます。本当に最大 1 行にしたいなら、PostgreSQL 15 以降は CREATE UNIQUE INDEX ... ON "Users" ("ExternalId") NULLS NOT DISTINCT をサポートしています。Npgsql プロバイダー 11.0 は既定の最小ターゲットを PostgreSQL 16 に引き上げるため、現行プロバイダーが既定で対象とするサーバーであれば利用できます。

ON CONFLICT が “there is no unique or exclusion constraint matching the ON CONFLICT specification” で失敗する。 競合ターゲットは列のリストではなくインデックスの推論です。一意インデックスが部分インデックス (WHERE "DeletedAt" IS NULL) なら、述語も繰り返す必要があります: ON CONFLICT ("Email") WHERE "DeletedAt" IS NULL DO NOTHING。あるいは ON CONFLICT ON CONSTRAINT "IX_Users_Email" で制約を直接指定すれば、推論を完全に回避できます。

これは並行 INSERT ではなく並行 UPDATE である。 2 つの呼び出し元が新規作成ではなく既存の行を変更しているなら、23505 は適切な道具ではなく、必要なのは並行性トークンです。これは別の例外を伴う別の仕組みで、rowversion トークンによる楽観的並行性制御 で扱っています。

テストで証明する

本番の負荷でしか現れない競合状態は、シングルスレッドのインメモリプロバイダーでは回帰テストにできません。実サーバーと 2 本の接続が必要です。Postgres のコンテナーを起動し、IDbContextFactory<T> から 2 つのコンテキストを解決し、同じ TaskCompletionSource のゲートから両方の挿入を放って、同じ瞬間にインデックス上で競わせます。ハンドラーが正しければ、両方のタスクが同じ id を返し、どちらも例外を投げません。この構成と偽のバッキングストアとのトレードオフは WebApplicationFactory と Testcontainers の比較 にまとめてあります。

身につける価値のある習慣は、ここまでのどのコードよりも小さなものです。DbUpdateException を捕捉したら、それが何を意味するかを決める前に SqlStateConstraintName を見てください。自分で設計した一意インデックス上の 23505 は、データモデルが仕事をして、ある呼び出し元が競合に負けたと知らせてくれている状態です。主キー上の 23505 は、たいていテーブルそのものに問題があるとデータベースが伝えている状態です。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る