, , 2016年5月11日

ほんとにあった怖い話 SQLDatabase便利ですよね。マネージドなSQLServerです。
Failoverなどしてくれるため、SLAは99.99%です。 でもFailoverするときはどうしてもコネクションが切れます。
そのため、必ずDBアクセスにはリトライロジックを入れるのがクラウドDBを使うときの定石になってます。
しかし、コネクションが切れる瞬間はアプリケーション側からは完全に予測不能なために、「まさかそのタイミングで!?」みたいなところで切れてしまいます。 今回はこの前であった「そのタイミング」のお話です。

そのタイミング

ズバリ、Transaction Manager Requestとそのサーバーレスポンスの間にコネクションが切れるとすごいことになります。

Transaction Manager Requestはトランザクションの開始やコミット、ロールバックを要求するためのクライアントからサーバーへのリクエストです。

次のコードのtran.Commit()では、このTransaction Manager RequestでRequestType=TM_COMMIT_XACTのメッセージをサーバーに送り、そのレスポンスを待ちます。

using(var connection = new SqlConnection(connectionString))
{
    connection.Open();
    using (var tran = connection.BeginTransaction())
    {
        string sqlStr = "INSERT INTO [dbo].[Sample]([Text]) OUTPUT INSERTED.Id VALUES ('Sample')";
        var cmd = new SqlCommand(sqlStr, connection);
        cmd.Transaction = tran;
        try
        {
            var id = (int)cmd.ExecuteScalar();
            tran.Commit();
            Console.WriteLine($"Id = {id}");
        }
        catch (SqlException)
        {
            try
            {
                tran.Rollback();
            } catch (Exception rex)
            {
                Console.WriteLine(rex.Message);
                Console.WriteLine(rex.StackTrace);
            }
        }
    }
}

そのレスポンスが来るまでの間、つまり「そのタイミング」にコネクションが切れると、サーバー側はコミットを受け付け、完了させているのにクライアントはそれを受け取れない状態になります。そして、ロールバックしようにもコネクションが切れているのでロールバックできません。(仮にRollback要求が送れたとしてもコミットは完了しているのでロールバックできません。)上のコードの場合、コミットは失敗と判定され、ロールバックも失敗し例外を吐きますがDBにはレコードが挿入された状態になります。

ほんとにあった怖い話1

上記のコードでRollbackが失敗すると次の例外が発生します。

この SqlTransaction は完了しています。再度使用することはできません。
    場所 System.Data.SqlClient.SqlTransaction.ZombieCheck()
    場所 System.Data.SqlClient.SqlTransaction.Rollback()
    場所 ConsoleApplication4.Program.Main(String[] args)

ただし、注意が必要なのは、この例外は「そのタイミング」でなくともトランザクション処理中のどこかでコネクションが切れれば発生するありきたりの例外です。

つまり、ZombieCheckで例外が発生した=サーバー側でコミットは完了しているがクライアントは把握していない、という状態を示すわけではありません。レコードがあるるかもしれないし、ないかもしれません。

クリティカルな構成

さて、先ほどのタイミングでコネクションが切れると大変困るのですが、場合によっては困らないかもしれません。 クリティカルに効いてくる構成を考えてみます。

  • Insert
  • 自動採番

この2つの条件が重なった時ではないでしょうか。UpdateやDeleteの際にこの例外が発生したとしても、ユーザは画面を再表示させればすぐに消えてることがわかるはずです。 しかし、Insert && 自動採番 だとそうはいきません。IDが既知なら失敗時にレコードを確認できるかもしれませんが、自動採番だとその値がわからないので無理です。失敗してユーザがもう一回同じ処理をすると同じ内容でIDが違うレコードが出来上がることになります。受注システムなら、ユーザーが気づかずに重複した注文をしてしまう可能性があります。

これはInsertやUpdate、Deleteのどれでもいえることですが、コミットの後にアプリケーション側で処理が続く構成だと失敗した時点でその処理に進まないのでこれも困りものです。

回避方法を考える

割り切る

交通事故だと思って割り切る。ZombieCheckでの例外が飛んで来たらデータを確認する。

自動採番をやめる?

Guidをアプリケーション側で生成すれば回避できそうですが、Guidはユニークに近いだけで単純にIdがかぶってしまう可能性があります。これを避ける方法があれば使えるのかも…
まあ、なしですね。 動いてるアプリケーションの構成は変えられませんし。

先にInsertしてIdだけ確保しとく

これすると、レコードに有効かどうか判断するフラグを入れないといけないからなんかみっともない。 なし!

自動採番やめるのと同じでもう動いているものには使えないし。

TransactionScope

もしかして、System.Transactions.TransactionInDoubtExceptionがこの現象の正しい例外?説明的に近い気がすると周りで話題になってる。

不明なトランザクションで操作が試行されたときに、この例外がスローされます。 トランザクションの状態を判別できない場合、トランザクションがイン ダウトの状態です。 具体的には、トランザクションの最終結果がコミットまたは中止されると、その状態であるかどうかはわかりませんこのトランザクションにします。
https://msdn.microsoft.com/ja-jp/library/system.transactions.transactionindoubtexception(v=vs.110).aspx

BeginTransactionやめて、TransactionScope使えばこの例外をキャッチできるからこれで回避できる?

結論

まだ検討中。

おまけ

そのタイミングが起こすためのプロキシ作りました。Go製です。

インストール方法

PS>  go get github.com/iwate/tds-commit-destroyer/cmd/tds-commit-destroyer

使い方

PS>  tds-commit-destroyer -r {server-name}:1433
Proxying from :9433 to {server-name}:1433

後は、接続文字列のDataSourceをこのプロキシに切り替えます。EncryptFalseにしましょう。

"Data Source=localhost,9433;Initial Catalog=sample;Integrated Security=True;Connect Timeout=2;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"

CATEGORIES