ROBOT PAYMENT TECH-BLOG

株式会社ROBOT PAYMENTのテックブログです

TransactionScope の外で DB 接続を開いていませんか? ~ 同期処理と非同期処理の競合でデータの二重登録が起きた話 ~

こんにちは、ペイメントシステム課の大倉です。

外部サービスと連携する機能の開発中に、同じ取引に対してデータが2件登録されてしまうという事象に遭遇しました。(なお、本番運用前に改修済みです)

原因は「排他制御が甘かった」といった話ではなく、TransactionScope の生成と DB 接続オープンの順序によるものでした。

C# / .NET でトランザクションを扱っている方であれば誰でも踏み得る内容だと思いますので、調査から対応までをまとめておきます。

動作環境

  • C# / .NET 8
  • Entity Framework Core(DbContext)+ Dapper
  • SQL Server
  • System.Transactions.TransactionScope

前提:同じデータを作りうる経路が2つある

外部サービスに処理を委譲するタイプの連携では、処理結果が次の2つの経路で戻ってくることがよくあります。

  • ① 同期リダイレクト:利用者のブラウザが外部サービスから自社サイトへリダイレクトで戻ってくる
  • ② 非同期 Webhook:外部サービスのサーバーから自社サーバーへ、サーバー間で結果が通知される

①はブラウザを閉じられると届きません。②はブラウザの状態に依存せず、サーバー間で通知されます。ただし、いつ届くかは外部サービス次第です。

システム上、期待するデータは1件であるため、「どちらか先に到達したほうがデータを作る(先勝ち)」という制御にしていました。

先勝ちの実装としては、テーブルのキーとなるカラムに一意制約を張り、INSERTで重複キー違反が発生したら「後着」とみなして何もしない、という作りにしていました。

起きたこと

①と②がほぼ同時に実行されたケースで、次のような状態になりました。

INSERTの実行順序 テーブル 登録件数
1 取引テーブル 2件
2 結果受信テーブル(一意制約あり) 1件

一意制約を張っていた結果受信テーブルは1件の登録で済んでいますが、取引テーブルは2件登録されています。

つまり「重複キー違反が発生して後着を弾く」という動きはできていたのに、それまでに実行された INSERT がロールバックされていなかった わけです。

原因:TransactionScope の生成とDB接続オープンの順番

このシステムでは EF Core の DbContext を器として使いつつ、実際のクエリは取り出した接続を明示的に Open して Dapper で実行しています。 そのため、一度開いた接続は Dispose するまで開いたままで、EF Core 標準のクエリパイプラインとは接続の開閉タイミングが異なります。

該当箇所のコードは、簡略化するとこうなっていました。

public ResultResponseDto ReceiveResult(long responseId)
{
    using var dbContextData = _contextFactoryData.CreateDbContext();

    // バリデーションやマスタ参照(ここで初めて DB 接続がオープンされる)
    ValidateAndGetMaster(dbContextData, responseId);

    try
    {
        using (var transactionScope = new TransactionScope())
        {
            // トランザクション管理されていない接続で INSERT している
            Insert_取引テーブル(dbContextData, responseId);
            Insert_結果受信テーブル(dbContextData, responseId);
            transactionScope.Complete();
        }
    }
    catch (Exception ex) when (SqlExceptionHelper.IsDuplicateKeyViolation(ex))
    {
        // 後着とみなして何もしない
    }

    // (メール通知・レスポンス返却)
}

TransactionScope は、そのスコープ内をアンビエントトランザクション(Transaction.Current)で覆います。

ポイントは、接続がこのトランザクションに参加するのは、接続を Open したタイミングだけ だということです。 裏を返せば、スコープを生成する「前」に開かれた接続は、スコープの中で使ってもトランザクション管理の対象になりません。

つまり重要なのは、DbContext を生成した位置そのものではなく、物理接続が Open されるタイミングです。

上記のコードでは、TransactionScope を生成する前に行っているバリデーションやマスタ参照の時点で接続が Open され、そのまま開き続けています。 そのため、スコープの内側で実行しているつもりの INSERT が、実際には 1文ごとに自動コミット されていました。

一意制約違反で例外が飛んでも、それ以前にコミット済みの INSERT は当然戻りません。 結果として「後着が途中まで書いたデータが残る」という、中途半端な状態が発生していました。

対応:生成順を入れ替え、更新用の接続を分ける

対応はシンプルで、「TransactionScope を生成してから、更新用の DB 接続をオープンする」だけです。

public ResultResponseDto ReceiveResult(long responseId)
{
    // 参照用の DbContext(トランザクション対象外でよい)
    using var dbContextData = _contextFactoryData.CreateDbContext();

    // バリデーションやマスタ参照
    ValidateAndGetMaster(dbContextData, responseId);

    try
    {
        using (var transactionScope = new TransactionScope())
        using (var updDbContextData = _contextFactoryData.CreateDbContext())  // スコープ内で生成(最初のDBアクセスで Open されエンリスト)
        {
            // トランザクション管理された接続で INSERT する
            Insert_取引テーブル(updDbContextData, responseId);
            Insert_結果受信テーブル(updDbContextData, responseId);
            transactionScope.Complete();
        }
    }
    catch (Exception ex) when (SqlExceptionHelper.IsDuplicateKeyViolation(ex))
    {
        // 後着とみなして何もしない
    }

    // (メール通知・レスポンス返却)
}

これで一意制約違反が起きた場合は関連する INSERT がすべてロールバックされ、「先勝ちしたほうのデータだけが1件残る」という当初の意図どおりの挙動になりました。

なお、スコープ内では更新用以外の DbContext を使わないように注意が必要です。 別の DbContext をスコープ内で使うと2本目の接続がエンリストされ、.NET 8 では分散トランザクションへの昇格で例外になります。

もし順序を入れ替えても挙動が変わらない場合は、接続文字列を確認してみてください。 Enlist=false を指定していると、TransactionScope の内側でオープンした接続であっても自動では参加しません(既定は true です)。この場合は SqlConnection.EnlistTransaction(Transaction.Current) で明示的に参加させます。

あわせて注意したいこと

TransactionScope にはもうひとつ有名な落とし穴があります。

スコープ内で await する場合、既定では非同期の継続にアンビエントトランザクションが引き継がれない、という点です。

async メソッドの中でスコープ内に await を書くのであれば、必ず TransactionScopeAsyncFlowOption.Enabled を指定します。

using (var transactionScope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
using (var updDbContextData = _contextFactoryData.CreateDbContext())
{
    await Insert_取引テーブル_Async(updDbContextData, responseId);
    await Insert_結果受信テーブル_Async(updDbContextData, responseId);
    transactionScope.Complete();
}

指定を忘れると、await の後で開いた接続がトランザクション外になったり、スコープを抜けるタイミングで例外になったりします。 今回の事象と症状がよく似ているので、あわせて確認しておくと安心です。

おわりに

TransactionScope は using で囲むだけでトランザクションを張れる便利な仕組みですが、「囲めば効く」わけではなく「囲んだ中で開いた接続にだけ効く」という点は意識しておく必要があります。

似たような「同期の戻り」と「非同期の通知」を併用する連携は決済に限らず多いと思いますので、どなたかの参考になれば幸いです。



We are hiring!!

ROBOT PAYMENTでは一緒に働く仲間を募集しています!!!


www.robotpayment.co.jp