2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C#で確実にロールバックを管理する方法を考える~APで安全なデータベース操作を目指す

Posted at

はじめに

.NETのソースコードを仕事で日頃眺めている時、一つ疑問に感じたことがありました。それは、DB操作の障害発生時のシステムについてです。

というのも、通常AP側からDB操作をするときトランザクション処理を挟んでから、sqlConnectionを行います。しかし、何らかの要因によってのDB接続が失われた場合、C#側はどこまでトランザクション管理ができるのでしょうか?

つまり、想定外の状況でアプリケーション側からトランザクションを本当に適切にできているのか? ということです。

という訳で今回は、AP側(C#)で確実なDB操作目指す方法を、より安全にロールバックできる方法を実際に試しながら考えてみます。

とりあえず結論

なんのことか分からないと思いますが、僕が試した限り、
TransactionScopeが最も確実で簡単な実装だと感じます。

SqlTransactionは細かい制御時に使用するレベルなので、
実装に手間がかかりそうな感覚でした。

問題となるシナリオ

さて、本題に入っていきましょう。
まずは、問題提起です。

まず、こちらのソースコードをご覧ください。
以下は、EC サイトの注文処理を想定したコードです。

public async Task ProcessOrderAsync(Order order)
{
    using var connection = new SqlConnection(connectionString);
    await connection.OpenAsync();
    
    var transaction = connection.BeginTransaction();
    
    try
    {
        // 在庫を減らす
        await UpdateInventoryAsync(connection, transaction, order.ProductId, order.Quantity);
        
        // 注文を作成
        await CreateOrderAsync(connection, transaction, order);
        
        // 支払い処理(外部API呼び出し)
        await ProcessPaymentAsync(order.PaymentInfo); // ここで例外が発生したら?
        
        transaction.Commit();
    }
    catch (Exception)
    {
        transaction.Rollback(); // これだけで十分?
        throw;
    }
}

一見すると、トランザクション管理が行われていますが、
以下のような問題が潜在的にはらんでいます。

1. アプリケーション強制終了時の問題

支払いAPI呼び出し後、transaction.Commit()が実行される前にアプリケーションが予期せず終了した場合、在庫は減っているが注文レコードは作成されていない、という不整合状態になる可能性があります。

2. ネットワーク切断時の問題
データベースとの接続が途中で失われた場合、catchブロックのtransaction.Rollback()さえ実行できません。この場合、トランザクションの状態が不明になってしまいます。

3. 外部システム連携時の問題
支払いAPIの呼び出しは成功したが、その後の処理でエラーが発生した場合、支払いは完了しているのにデータベースの注文情報はロールバックされる、という不整合が生じる可能性があります。

と、ざっと問題を把握したので、より堅牢なトランザクション管理方法を考えてみましょう。

解決方法1: TransactionScope

TransactionScopeは.NETで提供されている頼もしいトランザクション管理の仕組みです。
先ほどの問題のあるコードをTransactionScopeを使って書き直してみましょう。

using System.Transactions

public async Task ProcessOrderAsync(Order order)
{
    using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
    
    // 在庫更新
    await UpdateInventoryAsync(order.ProductId, order.Quantity);
    
    // 注文作成
    await CreateOrderAsync(order);
    
    // 支払い処理
    await ProcessPaymentAsync(order.PaymentInfo);
    
    scope.Complete(); // 全て成功した場合のみコミット
    
    // usingスコープを抜ける際に自動的にコミット/ロールバックできるよ
}

TransactionScopeを使うことにより、以下の恩恵を受けられます。
1.自動ロールバック機能
scope.Complete()が呼ばれる前に例外が発生したり、アプリケーションが終了した場合、自動的にロールバックが実行されます。
つまり、逐一手動でのtry-catchやロールバック処理が不要になります。

2.シンプルな記述
try-catchなどを省略できるならば、複雑なトランザクション制御コードを書く必要がなります。
ビジネスロジックに全集中できます。また、ネストした複数のメソッド間でもトランザクションが自動的に共有されます。

解決方法2: SqlTransaction

解決方法と明記しているのに釘を刺してしまうのですが、基本的にこちらのやり方は細かい制御ができるという以外、あまり利点がないかなと感じました。

が、現実にはSqlTransactionを使っている現場多かったりするのですが...。

理由は、以下の実装をご覧ください👀。

public async Task ProcessOrderAsync(Order order)
{
    using var connection = new SqlConnection(connectionString);
    await connection.OpenAsync();
    
    using var transaction = connection.BeginTransaction();
    
    try
    {
        // データベース操作部分
        await UpdateInventoryAsync(connection, transaction, order.ProductId, order.Quantity);
        await CreateOrderAsync(connection, transaction, order);
        
        // 外部API呼び出し前にセーブポイント作成
        // これにより、支払い処理でエラーが発生した場合の部分ロールバックが可能
        transaction.Save("BeforePayment");
        
        try
        {
            // 外部システムとの連携
            await ProcessPaymentAsync(order.PaymentInfo);
            
            // 全て成功した場合のみコミット
            await transaction.CommitAsync();
        }
        catch (PaymentException paymentEx)
        {
            // 支払いエラーの場合は部分ロールバックのみ実行
            // 在庫情報と注文情報は保持し、支払い失敗の記録のみ追加
            transaction.Rollback("BeforePayment");
            
            await RecordPaymentFailureAsync(connection, transaction, order.Id, paymentEx.Message);
            await transaction.CommitAsync();
            
            throw new OrderProcessingException("支払い処理に失敗しましたが、注文情報は保存されました", paymentEx);
        }
    }
    catch (Exception)
    {
        // 想定外のエラーの場合は全てロールバック
        await transaction.RollbackAsync();
        throw;
    }
}

SqlTransactionを使う利点としては、以下が考えられます。

1.セーブポイント機能
部分的なロールバックが可能で、複雑なビジネスロジックに対応できます。

2.詳細な制御
トランザクションの開始・終了タイミングを細かく制御できます。

他にも、TransactionScopeよりもオーバーヘッドが少ないらしいので、パフォーマンスも高速なるようです(実際に検証していないため、予測レベルです)。

ですが、利点という利点は上記二つぐらいしか上げられないかなと思いました。

最もネックになるのは、実装の複雑さです。
手動でのトランザクション管理が行われるので、実装ミスが多くなる傾向にあります。実際この記事の元になった開発も、SqlTransactionで実装されていました。

接続切断時の対処法:アプリケーション側でできること、できないこと

さて、優先的に対応できる解決策を提示したうえで、外的要因から発する強制的な接続切断が発生したとき、どうすればいいでしょうか。
例えば、ネットワークの不安定さ、データベースサーバーのメンテナンス、アプリケーションサーバーの強制終了などです。

根本的な制約:制御できない領域の存在

まず、大前提理解しておく必要がある点は、何らかの形で接続が切れたとき、接続が切れた瞬間から、アプリケーション側はトランザクションを直接制御できなくなるということです。

例えば、以下の場所で接続が切れたとします。

// この状況でアプリケーションが強制終了されたら?
var transaction = connection.BeginTransaction();
await UpdateDataAsync(connection, transaction);
// ← ここでネットワーク切断やプロセス強制終了が発生
// transaction.Commit() が実行されない

この場合、アプリケーション側ではtransaction.Rollback()を実行することすらできません。
この時点で、DB側のトランザクション管理に依存することになってしまいます。

しかし、これは致命的な問題ではありません。
SQL Serverをはじめとするデータベース管理システムでは、未完了のトランザクションを自動的にロールバックする機能が、非常に優秀だからです。

しかし、今回のテーマはC#(AP側)でのロールバック管理です。
より早くAP側でより早く発見するにはどうすればいいか、いくつか対策を見てみましょう。

AP側対策1: ハートビート監視による早期検知

接続の異常を早期に検知し、適切に対処するためのハートビート機能を実装することができます。
ハートビートとは、接続が切れないために定期的に送受信する信号のことです。

public class TransactionWithHeartbeat : IDisposable
{
    private readonly Timer _heartbeatTimer;
    private readonly SqlConnection _connection;
    private readonly Action _onConnectionLost;
    
    public TransactionWithHeartbeat(SqlConnection connection, Action onConnectionLost)
    {
        _connection = connection;
        _onConnectionLost = onConnectionLost;
        
        // 30秒間隔でハートビートを送信
        _heartbeatTimer = new Timer(SendHeartbeat, null, TimeSpan.Zero, TimeSpan.FromSeconds(30));
    }
    
    private void SendHeartbeat(object state)
    {
        try
        {
            using var cmd = new SqlCommand("SELECT 1", _connection);
            cmd.ExecuteScalar();
            
            // 接続が正常であることをログに記録
            Logger.Debug("データベース接続は正常です");
        }
        catch (Exception ex)
        {
            // 接続エラーを検知した場合の処理
            Logger.Warning($"データベース接続エラーを検知: {ex.Message}");
            _onConnectionLost?.Invoke();
        }
    }
    
    public void Dispose()
    {
        _heartbeatTimer?.Dispose();
    }
}

この仕組みでは、接続の問題を早期に検知し、長時間にわたって不整合状態が続くことを防げます。
ただ、ハートビート送信中に接続が切れてしまう場合もあるので、完全な解決とはなりません。

AP側対策2: タイムアウト付きトランザクションによるリスク軽減

おそらく一番多い対応なのではないでしょうか(私の観測領域で)。
長時間のトランザクションを避けるため、適切なタイムアウト設定を行い、接続切断のリスクを軽減できます。

public async Task ProcessWithTimeoutAsync(Order order)
{
    // アプリケーション側でのタイムアウト制御
    using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
    
    // データベース側でのタイムアウト制御
    using var scope = new TransactionScope(
        TransactionScopeOption.Required,
        new TransactionOptions { Timeout = TimeSpan.FromMinutes(5) },
        TransactionScopeAsyncFlowOption.Enabled);
    
    try
    {
        await ProcessOrderAsync(order, cts.Token);
        scope.Complete();
        
        Logger.Information($"注文処理が正常に完了しました: OrderId={order.Id}");
    }
    catch (OperationCanceledException)
    {
        // タイムアウト発生時の処理
        Logger.Warning($"注文処理がタイムアウトしました: OrderId={order.Id}");
        
        // 必要に応じて補償処理や通知処理を実行
        await NotifyTimeoutAsync(order.Id);
        throw new OrderProcessingException("処理時間が制限を超過しました。しばらく時間をおいて再試行してください。");
    }
    catch (Exception ex)
    {
        Logger.Error($"注文処理でエラーが発生しました: OrderId={order.Id}, Error={ex.Message}");
        throw;
    }
}

明示的に時間を設定しておけば、DB側にトランザクション管理がわたることなくAP側で処理が終了します。

AP側対策3: 冪等性の確保による重複実行対策

一回も見たことない対策ですが、冪等性(同じ処理を何度実行しても同じ結果になる性質)の確保する対応もできるようです。

システムやユーザーが同じ処理を再実行する可能性を防ぐために、一意なキー(例では適当です)で識別します。

public async Task ProcessOrderIdempotentAsync(Order order)
{
    // 一意なキーで処理状況を管理
    var idempotencyKey = $"order_{order.Id}_{order.Version}_{DateTime.UtcNow:yyyyMMdd}";
    
    using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
    
    // 既に処理済みかチェック
    var processingStatus = await GetProcessingStatusAsync(idempotencyKey);
    if (processingStatus == ProcessingStatus.Completed)
    {
        Logger.Information($"注文は既に処理済みです: OrderId={order.Id}");
        return; // 重複実行を防ぐ
    }
    else if (processingStatus == ProcessingStatus.InProgress)
    {
        throw new InvalidOperationException("同じ注文が現在処理中です。しばらく時間をおいて確認してください。");
    }
    
    try
    {
        // 処理開始をマーク
        await MarkProcessingStartAsync(idempotencyKey, DateTime.UtcNow);
        
        // 実際のビジネス処理
        await ProcessOrderInternalAsync(order);
        
        // 処理完了をマーク
        await MarkProcessingCompleteAsync(idempotencyKey, DateTime.UtcNow);
        
        scope.Complete();
        
        Logger.Information($"注文処理が完了しました: OrderId={order.Id}, Key={idempotencyKey}");
    }
    catch (Exception ex)
    {
        // 処理失敗をマーク(再試行可能な状態にする)
        await MarkProcessingFailedAsync(idempotencyKey, ex.Message, DateTime.UtcNow);
        
        Logger.Error($"注文処理に失敗しました: OrderId={order.Id}, Error={ex.Message}");
        throw;
    }
}

まとめ

いかがでしたでしょうか。
C#でもロールバックを管理する方法はいくつかありましたが、
接続切断時の全ての問題を完全に防ぐことはできません。

大事なのは、システムの要件に沿って、問題が発生したときに適切に検知・対処できることが重要ですね。

実装の容易さと優先順位を考えるなら、冒頭に結論としてあげましたが、TransactionScopeの活用と、タイムアウト設定が最も有効だなと感じます。

そんなんめちゃくちゃありきたりな結論じゃないかと、ここまで読んでいただいた方なら言いたくもなるかもしれません。

しかし、普段なんとなく実装していたタイムアウト処理も、背景を知れば早期に問題を発見させるための手段なのだなと、再発見できます。

バイブコーディングが主流になりつつある昨今、こういう観点で実装を考えられたらより良い設計ができそうですね。

2
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?