【ASP.NET Core】SQL Server接続のリトライとトランザクションの共存
2024-01-12
azblob://2024/01/12/eyecatch/2024-01-12-csharp-coexistence-retry-and-transaction-000.jpg

トランザクションを張ったうえでSQL Server接続の再試行を検討する機会がありましたので、備忘録として記事にしてみたいと思います。

概要

リクエストの監視を行っていた時、たまに500エラーが起こっていることを確認しました。

      "SqlError 1": "Microsoft.Data.SqlClient.SqlError: Database 'your_database' on server 'your_server' is not currently available.  Please retry the connection later.  If the problem persists, contact customer support, and provide them the session tracing ID of 'xxxx'.",
      "SqlError 2": "Microsoft.Data.SqlClient.SqlError: A severe error occurred on the current command.  The results, if any, should be discarded."
 
どうやらDBに接続し直せば解決するようです。そこで今回は、リトライ処理を入れてみることにしました。

EnableRetryOnFailure

Startup.csのSQL Serverへ接続する項目にEnableRetryOnFailureを追記します。

C#    /// <summary>
    ///     オブジェクトの注入
    /// </summary>
    /// <param name="services"></param>
    private void DependencyInjection(IServiceCollection services)
    {
        // Databases
        services.AddDbContext<WriteDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("Write"),
                options =>
                {
                    options.AddRowNumberSupport();
                    options.CommandTimeout((int)TimeSpan.FromMinutes(4).TotalSeconds)
                        .EnableRetryOnFailure(
                            maxRetryCount: 10,
                            maxRetryDelay: TimeSpan.FromSeconds(30),
                            errorNumbersToAdd: null
                        );
                }));
    }

さあこれで一安心、動確してみよう、、、となりましたが、うまく動いてくれません。デバッグ出力を見るとこんなエラーが。

System.InvalidOperationException: The configured execution strategy 'xxxx' does not support user-initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.
設定された実行戦略「xxxx」はユーザーが開始したトランザクションをサポートしていません。'DbContext.Database.CreateExecutionStrategy()'によって返される実行戦略を使用して、トランザクション内のすべての操作を再試行可能な単位として実行してください。
該当のインタラクタを確認しに行くと、しっかりトランザクションが張られていました。(↑のドキュメントにも書いてありました、、、)
C#        await using (var tran = await _writeRepository.BeginTransactionAsync())
        {
            try
            {
                await _writeRepository.DeleteAsync(target, user);
                await tran.CommitAsync();
            }
            catch (Exception)
            {
                await tran.RollbackAsync();
                throw new CustomException(ExceptionType.InternalServerError);
            }
        }

トランザクション側の修正

まず、トランザクションとして実行するためのメソッドを用意しておきます。RetryStrategyTransaction.cs

C#using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace Test.Infrastructures.Repositories
{
    /// <summary>
    /// トランザクションの中でリトライを実行するためのクラス
    /// </summary>
    public class RetryStrategyTransaction
    {
        // ここに新しく作ったクラスで処理を行って、インタラクタの修正を減らす
        private readonly DbContext _context;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        public RetryStrategyTransaction(
            DbContext context)
        {
            _context = context;
            
        }

        /// <summary>
        /// トランザクションの中でコミットし実行するためのメソッド
        /// </summary>
        public async Task ExecuteAsTransactionAsync(Func<Task> action) 
        {
            var strategy = _context.Database.CreateExecutionStrategy();
            await strategy.Execute(async () => {
                using var tran = await _context.Database.BeginTransactionAsync();
                try 
                {
                    await action();
                    await tran.CommitAsync();
                }
                catch (Exception)
                {
                    await tran.RollbackAsync();
                    throw;
                }
            });
        }
    }
}

引数でインタラクタ内のアクションを受け取って、try内で実行しています。

CreateExecutionStrategyでラップすることで、内部のBeginTransactionAsyncの実行を可能にしています。CreateExecutionStrategyは実行戦略としてデータベースで指定でき、エラーを検出してコマンドを再試行するために必要なロジックがカプセル化されています。 (こちらに記載されてました)

GenericRepositoryにもメソッドを追記しておきます。

C#        /// <summary>
        /// トランザクションを張りつつリトライする
        /// </summary>
        /// <returns></returns>
        public RetryStrategyTransaction CreateRetryStrategyTransaction()
        {
            return new RetryStrategyTransaction(_context);
        }

IGenericRepositoryにもインタフェースを追記しておきます。

C#    	/// <summary>
    	/// トランザクションを張りリトライするためのインタフェース
    	/// </summary>
    	/// <returns></returns>
    	RetryStrategyTransaction CreateRetryStrategyTransaction();

最後に該当のインタラクタも修正しておきます。

C#        var tran = _writeRepository.CreateRetryStrategyTransaction();
        await tran.ExecuteAsTransactionAsync(async () =>
        {
            await _writeRepository.DeleteAsync(target, user);
        });

汎用リポジトリを介してロジックを切り出すことでよりスマートに実装できますね。動確すると、無事成功しました~

C#Microsoft.AspNetCore.Mvc.StatusCodeResult: Information: Executing StatusCodeResult, setting HTTP status code 204

終わりに

今回はSQL Serverへの再接続とトランザクションを共存させる方法を紹介しました。この実装に対するパフォーマンスへの影響やデータが破損しないかについて深掘りできなかったので、気が向いたらまた書こうと思います。そろそろ本気を出そうかと思っている藤野でした。