ファントム リードとその解決方法 その 1

(目次: トランザクションのサンプル)

ファントム リード (Phantom Read) とは、
同一のトランザクション内でテーブルを複数回読み取ったときに、途中でレコードが増えてしまう現象のことです。
今回は、SQL Server におけるファントム リードの発生と解決方法について、実際のコードを示して説明します。

前提として、SQL Server におけるロック メカニズムやトランザクション分離レベルについての基礎知識が必要になります。
これらは SQL Server のロック管理 (@IT) で確認できます。

以下で示すサンプルでは同時実行制御のために自動トランザクション (TransactionScope クラス) を使用していますが、
手動トランザクションなどを使用しても同様です。
さらに、データ アクセスのために SqlCommand クラスを使用していますが、
型指定された TableAdapter などを使用しても同様です。

■ ステップ 1: Repeatable Read (→ファントム リード発生)

先にコードを示します。
コンソール アプリケーション プロジェクトを作成し、Program.cs に次のように記述します。
System.Transactions.dll への参照も必要です。


using System;
using System.Data.SqlClient;
using System.Threading.Tasks;
using System.Transactions;

namespace PhantomRead
{
    class Program
    {
        private const string NorthwindConnectionString = @"Data Source=.\SQLExpress;Initial Catalog=Northwind;Integrated Security=True";

        private const string SelectCommandText = "select count(1) from Categories";
        private const string InsertCommandText = "insert into Categories values (N’New Category’, null, null)";

        private static readonly TransactionOptions transactionOptions = new TransactionOptions { IsolationLevel = IsolationLevel.RepeatableRead };

        private static int phantomCount;

        static void Main(string[] args)
        {
            // 挿入と読み取りを並列に実行します。
            Parallel.Invoke(
                () =>
                {
                    for (int i = 0; i < 200; i++)
                    {
                        InsertCategory();
                    }
                },
                () =>
                {
                    for (int i = 0; i < 100; i++)
                    {
                        SelectCategoriesCount();
                    }
                }
            );
        }

        /// <summary>
        /// Northwind データベースの Categories テーブルの行を挿入します。
        /// </summary>
        private static void InsertCategory()
        {
            using (var scope = new TransactionScope(TransactionScopeOption.Required, transactionOptions))
            using (var connection = new SqlConnection(NorthwindConnectionString))
            {
                // データベース接続を開きます。
                connection.Open();

                // カテゴリを追加します。
                using (var command = new SqlCommand(InsertCommandText, connection))
                {
                    command.ExecuteNonQuery();
                }

                // コミットします。
                scope.Complete();
            }
        }

        /// <summary>
        /// Northwind データベースの Categories テーブルの行数を読み取ります。
        /// </summary>
        private static void SelectCategoriesCount()
        {
            using (var scope = new TransactionScope(TransactionScopeOption.Required, transactionOptions))
            using (var connection = new SqlConnection(NorthwindConnectionString))
            {
                // データベース接続を開きます。
                connection.Open();

                // カテゴリの数を読み取ります。
                using (var command = new SqlCommand(SelectCommandText, connection))
                {
                    int count1 = (int)command.ExecuteScalar();
                    int count2 = (int)command.ExecuteScalar();

                    if (count1 != count2)
                    {
                        Console.WriteLine("{0} = {1} ({2} 回目)", count1, count2, ++phantomCount);
                    }
                    else
                    {
                        Console.WriteLine("{0} = {1}", count1, count2);
                    }
                }
            }
        }
    }
}


今回は Northwind データベースの Categories テーブルを使用します。
最初は 8 レコードだけ登録されています。
また、CategoryID 列には IDENTITY が指定されています。

Categories テーブルのデータ

InsertCategory メソッドは、Categories テーブルのレコードを 1 件だけ追加します。

SelectCategoriesCount メソッドは、Categories テーブルの全レコード数を 2 回読み取って比較します。
トランザクション分離レベルを Repeatable Read に設定して、一連の処理を TransactionScope で囲みます。

そしてこの 2 つのメソッドを並列に実行します。
なお、並列処理には .NET Framework 4 で追加されたタスク並列ライブラリ (TPL) を使用しています。

ステップ 1 実行結果

実行結果は上の図のようになります。
SelectCategoriesCount メソッドを 100 回実行して 20 回ほどは同一トランザクション内で値が変化してしまっています。
これがファントム リードという現象です。
同一トランザクション内では、何度読み取っても同じ値にならなければ一貫性を満たしているとは言えません。

Repeatable Read の場合には、共有ロックの対象が行自身のみであるために、別のトランザクションから行の挿入ができてしまいます。

■ ステップ 2: Serializable (→解決)

トランザクション分離レベルを Serializable に変更します。


private static readonly TransactionOptions transactionOptions = new TransactionOptions { IsolationLevel = IsolationLevel.Serializable };


コードを上記のように 1 行だけ書き換えて実行します。

ステップ 2 実行結果

すると、今度は期待通りに動作します。
Serializable であれば範囲共有ロックを取得するため、別のトランザクションは行を挿入できなくなります。

このように、Serializable は行の挿入が関連する業務で重大な役割を持っており、
例えば採番や行数の制限が必要な場合などで利用されます。

バージョン情報
.NET Framework 4
SQL Server 2008, 2008 R2

参照
.NET エンタープライズ Web アプリケーション開発技術大全 〈Vol.5〉 トランザクション設計編
エンタープライズ技術大全(トランザクション設計編) – WikiWiki (上記書籍の要約)
SQL Server のロック管理 (@IT)

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト / 変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト / 変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト / 変更 )

Google+ フォト

Google+ アカウントを使ってコメントしています。 ログアウト / 変更 )

%s と連携中

%d人のブロガーが「いいね」をつけました。