適用対象:Azure SQL データベース
Azure SQL Managed Instance
Azure SQL Database と Azure SQL Managed Instance に対する操作は、バッチ処理で行うことによりアプリケーションのパフォーマンスとスケーラビリティを大幅に高めることができます。 その利点を理解するために、Azure SQL Database または Azure SQL Managed Instance のデータベースに対する要求を逐次処理で行った場合とバッチ処理で行った場合とを比較するサンプル テストの結果をこの記事の冒頭でいくつか取り上げます。 その後、Azure アプリケーションでバッチ処理を正しく使用するための手法、シナリオ、考慮事項について説明します。
Azure SQL Database と Azure SQL Managed Instance でバッチ処理が重要な理由
リモート サービスの呼び出しをバッチ処理で行う手法は、パフォーマンスとスケーラビリティを高める戦略としてよく知られています。 リモート サービスとの対話には、シリアル化、ネットワーク転送、逆シリアル化など固定的な処理コストが発生します。 ばらばらに存在する多数のトランザクションを 1 つのバッチにまとめることで、こうしたコストを最小限に抑えることができます。
この記事では、バッチ処理のさまざまな方法とシナリオを紹介します。 これらの戦略は、SQL Server を使用するオンプレミス アプリケーションでも重要ですが、Azure SQL Database と Azure SQL Managed Instance におけるバッチ処理の使用を強調する理由はいくつかあります。
- 特に、同じ Microsoft Azure データセンターの外部から Azure SQL Database または Azure SQL Managed Instance にアクセスする場合は、Azure SQL Database と Azure SQL Managed Instance へのアクセスにネットワーク待機時間が長くなる可能性があります。
- Azure SQL Database と Azure SQL Managed Instance のマルチテナント特性は、データ アクセス層の効率が、データベース全体のスケーラビリティと密接に関係することを意味しています。 設定されているクォータを使用量が超えた場合の対応として、Azure SQL Database と Azure SQL Managed Instance は、スループットを抑えたり、スロットル例外を発生させたりすることができます。 バッチ処理などによって効率を向上させると、こうした制限の範囲内で行える作業が増えます。
- 複数のデータベース (シャーディング) を使ったアーキテクチャでもバッチ処理は有効です。 この場合も、それぞれのデータベース ユニットとのやり取りにおける効率の良さが、全体的なスケーラビリティの重要な要因となります。
Azure SQL Database または Azure SQL Managed Instance を使用する利点の 1 つは、データベースのホストとなるサーバーを管理する必要がないということです。 しかし、このように管理されたインフラストラクチャの存在は、データベースの最適化に対するアプローチも変えなければならないことを意味します。 この場合、データベース ハードウェアやネットワーク インフラストラクチャの強化に頼ることはできません。 これらの環境は Microsoft Azure によって制御されます。 利用者側で制御できる主な領域は、アプリケーションと Azure SQL Database または Azure SQL Managed とのやり取りです。 バッチ処理は、そうした最適化手法の一つとなります。
この記事の冒頭では、Azure SQL Database または Azure SQL Managed を使った .NET アプリケーション用のさまざまなバッチ処理手法について検討します。 最後の 2 つのセクションでは、バッチ処理のガイドラインとシナリオを取り上げます。
バッチ処理の戦略
この記事に記載されている時間測定の結果について
注
結果はベンチマークではなく、 相対的なパフォーマンスを示すものです。 計時結果は、10 回以上のテスト ランの平均値に基づいています。 空のテーブルへの挿入操作を計測対象としています。 これらのテストは V12 より前に測定されており、 DTU ベースの購入モデル または 仮想コア購入モデルを使用して V12 データベースで発生する可能性のあるスループットに必ずしも対応しているとは限りません。 それでもバッチ処理手法の相対的なメリットに大きな違いはないと考えられます。
トランザクション
バッチ処理を検証するための足掛かりとして、トランザクションに触れるのは奇妙に思えるかもしれません。 しかし、クライアント側でトランザクションを使用することにより、パフォーマンス向上につながるバッチ処理の効果がサーバー側に生まれます。 トランザクションはわずか数行のコードで追加できるので、シーケンシャルな操作のパフォーマンスを簡単に高めることができます。
次の C# コードには、単純なテーブルに対する一連の挿入操作と更新操作が記述されています。
List<string> dbOperations = new List<string>();
dbOperations.Add("update MyTable set mytext = 'updated text' where id = 1");
dbOperations.Add("update MyTable set mytext = 'updated text' where id = 2");
dbOperations.Add("update MyTable set mytext = 'updated text' where id = 3");
dbOperations.Add("insert MyTable values ('new value',1)");
dbOperations.Add("insert MyTable values ('new value',2)");
dbOperations.Add("insert MyTable values ('new value',3)");
次の ADO.NET コードは、以上の操作を連続実行します。
using (SqlConnection connection = new SqlConnection(CloudConfigurationManager.GetSetting("Sql.ConnectionString")))
{
conn.Open();
foreach(string commandString in dbOperations)
{
SqlCommand cmd = new SqlCommand(commandString, conn);
cmd.ExecuteNonQuery();
}
}
このコードを最適化するにはどうすればよいでしょうか。一番の方法は、クライアント側でこれらの操作をバッチ処理する何らかの機構を実装することです。 ただし、トランザクション内の一連の呼び出しをラップするだけで、このコードのパフォーマンスを向上させる簡単な方法があります。 トランザクションを使用するのと同じコードを次に示します。
using (SqlConnection connection = new SqlConnection(CloudConfigurationManager.GetSetting("Sql.ConnectionString")))
{
conn.Open();
SqlTransaction transaction = conn.BeginTransaction();
foreach (string commandString in dbOperations)
{
SqlCommand cmd = new SqlCommand(commandString, conn, transaction);
cmd.ExecuteNonQuery();
}
transaction.Commit();
}
実際には、両方の例にトランザクションが使われています。 1 つ目の例では、個々の呼び出しがそれぞれ暗黙的なトランザクションとなっています。 2 つ目の例では、トランザクションを明示的に指定して、その中にすべての呼び出しを投入しています。 write-ahead トランザクション ログのドキュメントによれば、トランザクションがコミットされたときにログ レコードがディスクにフラッシュされます。 したがって、より多くの呼び出しをトランザクションに含めることで、その分、トランザクションがコミットされるまでの間、トランザクション ログへの書き込みを先送りすることができます。 実際には、サーバーのトランザクション ログへの書き込みのバッチ処理を有効にしています。
以下の表は、いくつかのアドホック テストの結果です。 同じ連続挿入の実行結果をトランザクションを使った場合と使わなかった場合とで比較しています。 総合的な評価を行うために、テストは 2 種類行いました。1 つ目のテストは、ノート PC からリモートで Microsoft Azure 内のデータベースに対して実行しています。 2 つ目のテストは、同じ Microsoft Azure データセンター (米国西部) に存在するクラウド サービスとデータベースから実行しています。 以下の表は、トランザクションを使った場合と使わなかった場合の連続挿入にかかる時間をミリ秒単位で示しています。
オンプレミスから Azure へ:
操作 | トランザクションなし (ミリ秒) | トランザクションあり (ミリ秒) |
---|---|---|
1 | 130 | 402 |
10 | 1208 | 1226 |
100 | 12662 | 10395 |
1000 | 128852 | 102917 |
Azure から Azure へ (同じデータ センター) :
操作 | トランザクションなし (ミリ秒) | トランザクションあり (ミリ秒) |
---|---|---|
1 | 21 (二十一) | 26 |
10 | 220 | 56 |
100 | 2145 | 341 |
1000 | 21479 | 2756 |
注
結果はベンチマークではありません。 「この記事に記載されている時間測定の結果について」をご覧ください。
前掲のテスト結果によれば、単一の操作をトランザクションに投入した場合、実際にはパフォーマンスが低下しています。 しかし、1 つのトランザクションに含める操作の数を増やすにつれ、パフォーマンスの向上が顕著に見られるようになりました。 すべての操作を同じ Microsoft Azure データセンター内で実行したときにも、パフォーマンスの違いが顕著に表れています。 Microsoft Azure データセンターの外部から Azure SQL Database または Azure SQL Managed を使用した場合に生じる待機時間の増加から、トランザクションを使用することで得られるパフォーマンスの向上が見えにくくなっています。
トランザクションを使うことによってパフォーマンスは向上しますが、それでも トランザクションと接続のベスト プラクティスには従ってください。 トランザクションはできるだけ短く保ち、作業が完了したらデータベース接続を閉じるようにしてください。 前の例のように using ステートメントを使うことで、後続のコード ブロックの完了時に接続が確実にクローズされます。
先ほどの例からわかるように、ローカル トランザクションは、2 つの行で任意の ADO.NET コードに追加することができます。 挿入、更新、削除の操作を連続して行うコードは、トランザクションを導入することで手軽にパフォーマンスを高めることができます。 しかし処理速度を最大限に高めるために、クライアント側のバッチ処理 (テーブル値パラメーターなど) を活かしたコードの改善を検討してください。
ADO.NET におけるトランザクションの詳細については、 ADO.NET のローカル トランザクションに関するページを参照してください。
テーブル値パラメーター
テーブル値パラメーターは、Transact-SQL のステートメント、ストアド プロシージャ、関数の中で、ユーザー定義テーブル型をパラメーターとして使用する際に使用します。 これはクライアント側のバッチ処理手法で、テーブル値パラメーターに複数行のデータを含めて送信することができます。 テーブル値パラメーターを使用するにはまず、テーブル型を定義します。 次の Transact-SQL ステートメントでは、 MyTableType
という名前のテーブル型を作成します。
CREATE TYPE MyTableType AS TABLE
( mytext TEXT,
num INT );
コードでは、テーブルの種類とまったく同じ名前と型を持つ DataTable
を作成します。 この DataTable
をテキスト クエリまたはストアド プロシージャ呼び出しのパラメーターに渡します。 この手法を使った例を次に示します。
using (SqlConnection connection = new SqlConnection(CloudConfigurationManager.GetSetting("Sql.ConnectionString")))
{
connection.Open();
DataTable table = new DataTable();
// Add columns and rows. The following is a simple example.
table.Columns.Add("mytext", typeof(string));
table.Columns.Add("num", typeof(int));
for (var i = 0; i < 10; i++)
{
table.Rows.Add(DateTime.Now.ToString(), DateTime.Now.Millisecond);
}
SqlCommand cmd = new SqlCommand(
"INSERT INTO MyTable(mytext, num) SELECT mytext, num FROM @TestTvp",
connection);
cmd.Parameters.Add(
new SqlParameter()
{
ParameterName = "@TestTvp",
SqlDbType = SqlDbType.Structured,
TypeName = "MyTableType",
Value = table,
});
cmd.ExecuteNonQuery();
}
前の例では、 SqlCommand
オブジェクトはテーブル値パラメーターの行を挿入 @TestTvp
。 以前に作成した DataTable
オブジェクトは、 SqlCommand.Parameters.Add
メソッドを使用してこのパラメーターに割り当てられます。 これらの挿入操作を 1 回の呼び出しでバッチ処理すれば、挿入操作を逐次的に実行した場合と比べ、大幅にパフォーマンスを高めることができます。
先ほどの例をさらに改良するには、テキスト ベースのコマンドをストアド プロシージャに置き換えます。 次の Transact-SQL コマンドは、テーブル値パラメーター SimpleTestTableType
受け取るストアド プロシージャを作成します。
CREATE PROCEDURE [dbo].[sp_InsertRows]
@TestTvp as MyTableType READONLY
AS
BEGIN
INSERT INTO MyTable(mytext, num)
SELECT mytext, num FROM @TestTvp
END
GO
次に、前のコード例の SqlCommand
オブジェクト宣言を次のように変更します。
SqlCommand cmd = new SqlCommand("sp_InsertRows", connection);
cmd.CommandType = CommandType.StoredProcedure;
テーブル値パラメーターのパフォーマンスは、他のバッチ処理手法と同等か、優れている場合がほとんどです。 テーブル値パラメーターは、多くの場合、他のオプションよりも柔軟性が高いため、推奨されます。 たとえば、SQL 一括コピーなど、他の手法では、新しい行の挿入しか認められていません。 一方、テーブル値パラメーターを使用すると、どの行が更新で、どの行が挿入であるかを、ストアド プロシージャのロジックを使って調べることができます。 このテーブル型に変更を加え、行ごとの操作の種類 (挿入、更新、削除) を示す "Operation" 列を追加することもできます。
以下の表は、テーブル値パラメーターの使用に関するアドホック テストの結果です (単位はミリ秒)。
操作 | オンプレミスから Azure へ (ミリ秒) | 同じ Azure データセンター (ミリ秒) |
---|---|---|
1 | 124 | 32 |
10 | 131 | 二十五 |
100 | 3:38 | 51 |
1000 | 2615 | 382 |
1万 | 23830 | 3586 |
注
結果はベンチマークではありません。 「この記事に記載されている時間測定の結果について」をご覧ください。
バッチ処理によるパフォーマンスの上昇が一目で確認できます。 前のシーケンシャル テストでは、1,000 件の操作がデータセンター外で 129 秒、データセンター内から 21 秒かかりました。 ただし、テーブル値パラメーターを使用すると、1,000 の操作はデータセンターの外部で 2.6 秒、データセンター内で 0.4 秒しかかからありません。
テーブル値パラメーターの詳細については、 テーブル値パラメーターに関するページを参照してください。
SQL 一括コピー
データベースに大量のデータを挿入する手段としては、SQL 一括コピーを使った方法もあります。 .NET アプリケーションでは、 SqlBulkCopy
クラスを使用して一括挿入操作を実行できます。
SqlBulkCopy
は、コマンド ライン ツール、 Bcp.exe
、または Transact-SQL ステートメント ( BULK INSERT
) と同様に機能します。 次のコード例は、ソース DataTable
(テーブル)の行をコピー先のテーブル ( MyTable
) に一括コピーする方法を示しています。
using (SqlConnection connection = new SqlConnection(CloudConfigurationManager.GetSetting("Sql.ConnectionString")))
{
connection.Open();
using (SqlBulkCopy bulkCopy = new SqlBulkCopy(connection))
{
bulkCopy.DestinationTableName = "MyTable";
bulkCopy.ColumnMappings.Add("mytext", "mytext");
bulkCopy.ColumnMappings.Add("num", "num");
bulkCopy.WriteToServer(table);
}
}
テーブル値パラメーターよりも一括コピーの方が好ましいケースもいくつかあります。 Table-Valued パラメーターと BULK INSERT
操作の比較表は、Table-Valued パラメーターに関する記事を参照してください。
次のアドホック テスト結果は、 SqlBulkCopy
を使用したバッチ処理のパフォーマンスをミリ秒単位で示しています。
操作 | オンプレミスから Azure へ (ミリ秒) | 同じ Azure データセンター (ミリ秒) |
---|---|---|
1 | 433 | 五十七 |
10 | 441 | 32 |
100 | 636 | 53 |
1000 | 2535 | 341 |
1万 | 21605 | 2737 |
注
結果はベンチマークではありません。 「この記事に記載されている時間測定の結果について」をご覧ください。
より小さいバッチ サイズでは、テーブル値パラメーターの使用が SqlBulkCopy
クラスを上回りました。 ただし、 SqlBulkCopy
1,000 行と 10,000 行のテストでは、テーブル値パラメーターよりも 12 から 31% 高速に実行されました。 テーブル値パラメーターと同様に、 SqlBulkCopy
はバッチ処理された挿入に適したオプションです。特に、バッチ処理されていない操作のパフォーマンスと比較した場合です。
ADO.NET での一括コピーの詳細については、一括コピー操作に関するページを参照してください。
複数行パラメーター化 INSERT ステートメント
バッチ サイズが小さいときは、複数行を挿入する包括的なパラメーター化 INSERT ステートメントを作成するのも一つの方法です。 その手法を次のコード例に示します。
using (SqlConnection connection = new SqlConnection(CloudConfigurationManager.GetSetting("Sql.ConnectionString")))
{
connection.Open();
string insertCommand = "INSERT INTO [MyTable] ( mytext, num ) " +
"VALUES (@p1, @p2), (@p3, @p4), (@p5, @p6), (@p7, @p8), (@p9, @p10)";
SqlCommand cmd = new SqlCommand(insertCommand, connection);
for (int i = 1; i <= 10; i += 2)
{
cmd.Parameters.Add(new SqlParameter("@p" + i.ToString(), "test"));
cmd.Parameters.Add(new SqlParameter("@p" + (i+1).ToString(), i));
}
cmd.ExecuteNonQuery();
}
この例の意図は基本的な概念の紹介です。 現実には、必要なエンティティをループで処理しながら、クエリ文字列とコマンド パラメーターを同時に構築するのが一般的です。 クエリ パラメーターは合計 2,100 個に制限されているため、この方法で処理できる行の合計数が制限されます。
次の表は、このタイプの挿入ステートメントのパフォーマンスを示すアドホック テストの結果です (単位はミリ秒)。
操作 | テーブル値パラメーター (ミリ秒) | 単一ステートメントでの挿入 (ミリ秒) |
---|---|---|
1 | 32 | 20 |
10 | 30 | 二十五 |
100 | 33 | 51 |
注
結果はベンチマークではありません。 「この記事に記載されている時間測定の結果について」をご覧ください。
この方法は、100 行未満のバッチの場合に若干高速になる場合があります。 改善の幅は小さいものの、アプリケーションで想定される状況によっては、この手法で高い効果が期待できる場合もあります。
データアダプター
DataAdapter
クラスを使用すると、DataSet
オブジェクトを変更し、変更をINSERT
、UPDATE
、およびDELETE
操作として送信できます。 この方法で DataAdapter
を使用している場合は、個別の操作ごとに個別の呼び出しが行われていることに注意することが重要です。 パフォーマンスを向上させるには、一度にバッチ処理する操作の数に対して UpdateBatchSize
プロパティを使用します。 詳細については、 DataAdapter を使用したバッチ操作の実行に関するページを参照してください。
エンティティ・フレームワーク(Entity Framework)
Entity Framework Core ではバッチ処理がサポートされます。
XML
完全性を高めるためには、バッチ処理戦略として XML について説明することが重要であると感じています。 ただし XML の使用には、他の手法に勝るメリットはなく、むしろ、デメリットがいくつかあります。 このアプローチは、テーブル値パラメーターと似ていますが、ストアド プロシージャに渡すのはユーザー定義テーブルではなく XML ファイルまたは XML 文字列です。 ストアド プロシージャが、そうして受け取ったコマンドを解析します。
このアプローチにはいくつかのデメリットがあります。
- XML の操作は煩雑でエラーが生じやすい。
- データベースで XML を解析する作業は CPU に負担がかかる。
- テーブル値パラメーターよりも低速である場合が多い。
このような理由から、バッチ クエリに XML を使用することはお勧めしません。
バッチ処理の考慮事項
Azure SQL Database アプリケーションと Azure SQL Managed Instance アプリケーションでバッチ処理を使用する場合の指針を以降のセクションで詳しく取り上げます。
トレードオフ
アーキテクチャによっては、パフォーマンスと回復性のトレードオフがバッチ処理に伴うこともあります。 たとえば、使用中のロールが不意にダウンしたとします。 失ったデータが 1 行である場合、バッチ処理の対象となる未送信の行が大量に失われるより、その影響は小さくなります。 指定した時間枠で行をデータベースに送信する前に行をバッファー処理すると、リスクが高くなります。
このトレードオフを踏まえて、バッチ処理の対象にする操作の種類を判断してください。 重要性の低いデータの方が、バッチ処理は積極的に行うことができます (バッチ サイズを大きくする、時間枠を長くする)。
バッチ サイズ
私たちが行ったテストでは、大きなバッチを小さなまとまりに分割するメリットはほとんどありませんでした。 実際、このように細かく分割した場合、1 つの大きなバッチで送信するときに比べ、パフォーマンスが低下するケースも往々にしてありました。 たとえば、1,000 行を挿入するシナリオを考えてみましょう。 次の表は、テーブル値パラメーターを使用して、小さなバッチに分割されたときに 1,000 行を挿入するのにかかる時間を示しています。
バッチ サイズ | イテレーション | テーブル値パラメーター (ミリ秒) |
---|---|---|
1000 | 1 | 347 |
5:00 | 2 | 3:55 |
100 | 10 | 465 |
50 | 20 | 630 |
注
結果はベンチマークではありません。 「この記事に記載されている時間測定の結果について」をご覧ください。
1,000 行に最適なパフォーマンスは、それらを一度に送信することです。 ここには示していませんが、他のテストで、10,000 行を 2 つの 5,000 行ずつのバッチに分割したとき、わずかながらパフォーマンスが向上したケースもありました。 ただし、これらのテストに用いたテーブルのスキーマは比較的単純であるため、調査結果を検証するためには、実際のデータとバッチ サイズに基づいてテストを実行する必要があります。
もう 1 つ考慮すべき要因は、バッチ全体が大きくなりすぎると、Azure SQL Database または Azure SQL Managed Instance が処理を抑制し始め、バッチのコミットを拒否する可能性がある点です。 最適な結果を得るには、特定のシナリオをテストして、理想的なバッチ サイズがあるかどうかを判断します。 バッチ サイズを実行時に構成できるようにし、パフォーマンスやエラーに応じてすばやく調整できるようにします。
最終的には、バッチのサイズとバッチ処理に伴うリスクとのバランスを図ることになります。 一過性のエラーやロールの障害が発生する場合は、操作を再試行することによる影響や、まとまったデータが失われることの重大性を考慮に入れてください。
並列処理
バッチ サイズを小さくしながらも、複数のスレッドを使ってその処理を実行した場合はどうなるのでしょうか。 今回のテストの範囲では、やはり、バッチ サイズを小さくしてマルチスレッドで処理した場合、概して大きなバッチ サイズのまま実行したときよりもパフォーマンスが低くなりました。 次のテストでは、1 つ以上の並列バッチに 1,000 行を挿入しようとします。 同時に処理するバッチを増やしていったとき、パフォーマンスが実際に低下するようすを示しています。
バッチ サイズ [反復回数] | 2 スレッド (ミリ秒) | 4 スレッド (ミリ秒) | 6 スレッド (ミリ秒) |
---|---|---|---|
1000 [1] | 277 | 3:15 | 266 |
500 [2] | 5:48 | 278 | 256 |
250 [4] | 405 | 329 | 265 |
100 [10] | 488 | 4:39 | 391 |
注
結果はベンチマークではありません。 「この記事に記載されている時間測定の結果について」をご覧ください。
並列処理に起因するパフォーマンスの低下には、いくつかの理由が考えられます。
- ネットワーク呼び出しが 1 回で済まず、同時に複数回発生する。
- 1 つのテーブルに対して複数の操作が実行されるために競合やブロックが生じやすい。
- マルチスレッド化に関連したオーバーヘッドが伴う。
- 並列処理のメリットよりも複数の接続を開くコストの方が大きい。
別のテーブルまたはデータベースを対象とする場合は、この戦略でパフォーマンスの向上を確認できます。 そのような場合は、データベースのシャーディングやフェデレーションが候補となります。 シャーディングでは、複数のデータベースを使用し、個々のデータベースに異なるデータをルーティングします。 小さなバッチをそれぞれ異なるデータベースに送れば、並列処理によって操作の効率が高まる可能性はあります。 ただし、パフォーマンスの向上は、ソリューションでデータベース シャーディングを使用する決定の基礎として使用するのに十分ではありません。
設計によっては、バッチ サイズを小さくしたうえで並列実行することにより、負荷のかかったシステムにおける要求のスループットが向上する場合があります。 この場合、1 つの大きなバッチを処理する方が高速ですが、複数のバッチを並列で処理する方が効率的な場合があります。
並列実行を使用する場合、最大数のワーカー スレッドを使うべきかどうか、よく検討してください。 スレッド数は少ない方が、競合が少なく実行時間が短い場合もあります。 また、対象データベースの接続とトランザクションの両方に関して、新たにかかる負荷も考慮する必要があります。
関連するパフォーマンスの要因
データベースのパフォーマンスに関する一般的な指針は、バッチ処理にも当てはまります。 たとえば、大きなプライマリ キーや多数の非クラスター化インデックスが存在するテーブルでは、挿入操作のパフォーマンスが低下します。
テーブル値パラメーターでストアド プロシージャを使用する場合は、プロシージャの先頭にあるコマンド SET NOCOUNT ON
を使用できます。 通常、プロシージャで処理された行数が返されますが、このステートメントを記述することで、その出力を抑制することができます。 ただし、テストでは、 SET NOCOUNT ON
の使用に影響がなかったか、パフォーマンスが低下しました。 テスト ストアド プロシージャは、テーブル値パラメーターから 1 つの INSERT
コマンドを使用して単純でした。 このステートメントを使用すると、より複雑なストアド プロシージャのメリットが得られる可能性があります。 ただし、ストアド プロシージャに SET NOCOUNT ON
を追加すると、パフォーマンスが自動的に向上することを想定しないでください。 その効果を理解するには、 SET NOCOUNT ON
ステートメントの有無にかかわらずストアド プロシージャをテストします。
バッチ処理のシナリオ
以降のセクションでは、アプリケーションのシナリオを 3 つ想定して、テーブル値パラメーターの使い方を説明します。 1 つ目のシナリオでは、バッファリングとバッチ処理の連携方法を紹介しています。 2 つ目は、マスター/詳細の操作を 1 回のストアド プロシージャ呼び出しで実行することによってパフォーマンスを高めるシナリオです。 最後のシナリオでは、"UPSERT" 操作におけるテーブル値パラメーターの使い方を紹介します。
バッファリング
バッチ処理には明確な候補となる状況がいくつか存在しますが、処理の先送りによってバッチ処理を有効活用できるケースは少なくありません。 しかし処理を先送りすれば、予期しない障害が発生した場合にデータが失われる危険性もそれだけ大きくなります。 このリスクを理解し、その結果を考慮することが重要です。
たとえば、個々のユーザーのナビゲーション履歴を追跡する Web アプリケーションがあるとします。 ページ要求があるたびに、アプリケーションがデータベースを呼び出し、ユーザーのページ ビューを記録します。 しかし、ユーザーのナビゲーション アクティビティをバッファリングし、そのデータをまとめてデータベースに送れば、パフォーマンスとスケーラビリティを高めることができます。 データベースの更新は、経過時間やバッファー サイズ、またはその両方によってトリガーすることができます。 たとえば、ルールでは、20 秒後またはバッファーが 1,000 項目に達したときにバッチを処理することを指定できます。
以下のコード例は、監視クラスによって生成され、バッファーに格納されたイベントを Reactive Extensions (Rx) を使って処理しています。 バッファーがいっぱいになるか指定した時間が経過したら、テーブル値パラメーターを使って、ユーザー データのバッチをデータベースに送信します。
次の NavHistoryData
クラスは、ユーザー ナビゲーションの詳細をモデル化します。 ユーザー ID、アクセス URL、アクセス時刻など基本的な情報は、このクラスによって保持されます。
public class NavHistoryData
{
public NavHistoryData(int userId, string url, DateTime accessTime)
{ UserId = userId; URL = url; AccessTime = accessTime; }
public int UserId { get; set; }
public string URL { get; set; }
public DateTime AccessTime { get; set; }
}
NavHistoryDataMonitor
クラスは、ユーザー ナビゲーション データをデータベースにバッファリングする役割を担います。
RecordUserNavigationEntry
イベントを発生させて応答するメソッド ("OnAdded"
) が含まれています。 Rx を使用し、このイベントに基づいて、監視可能なコレクションを作成するコンストラクターのロジックを示したのが以下のコードです。 その後、 Buffer
メソッドを使用して、この監視可能なコレクションをサブスクライブします。 オーバーロードは、バッファーを 20 秒ごとまたは 1,000 エントリごとに送信することを指定します。
public NavHistoryDataMonitor()
{
var observableData =
Observable.FromEventPattern<NavHistoryDataEventArgs>(this, "OnAdded");
observableData.Buffer(TimeSpan.FromSeconds(20), 1000).Subscribe(Handler);
}
イベント ハンドラーは、バッファーに格納されたすべての項目を特定のテーブル値の型に変換し、バッチ処理のストアド プロシージャにその型を渡します。 次のコードは、 NavHistoryDataEventArgs
クラスと NavHistoryDataMonitor
クラスの両方の完全な定義を示しています。
public class NavHistoryDataEventArgs : System.EventArgs
{
public NavHistoryDataEventArgs(NavHistoryData data) { Data = data; }
public NavHistoryData Data { get; set; }
}
public class NavHistoryDataMonitor
{
public event EventHandler<NavHistoryDataEventArgs> OnAdded;
public NavHistoryDataMonitor()
{
var observableData =
Observable.FromEventPattern<NavHistoryDataEventArgs>(this, "OnAdded");
observableData.Buffer(TimeSpan.FromSeconds(20), 1000).Subscribe(Handler);
}
イベント ハンドラーは、バッファーに格納されたすべての項目を特定のテーブル値の型に変換し、バッチ処理のストアド プロシージャにその型を渡します。 次のコードは、 NavHistoryDataEventArgs
クラスと NavHistoryDataMonitor
クラスの両方の完全な定義を示しています。
public class NavHistoryDataEventArgs : System.EventArgs
{
if (OnAdded != null)
OnAdded(this, new NavHistoryDataEventArgs(data));
}
protected void Handler(IList<EventPattern<NavHistoryDataEventArgs>> items)
{
DataTable navHistoryBatch = new DataTable("NavigationHistoryBatch");
navHistoryBatch.Columns.Add("UserId", typeof(int));
navHistoryBatch.Columns.Add("URL", typeof(string));
navHistoryBatch.Columns.Add("AccessTime", typeof(DateTime));
foreach (EventPattern<NavHistoryDataEventArgs> item in items)
{
NavHistoryData data = item.EventArgs.Data;
navHistoryBatch.Rows.Add(data.UserId, data.___URL, data.AccessTime);
}
using (SqlConnection connection = new SqlConnection(CloudConfigurationManager.GetSetting("Sql.ConnectionString")))
{
connection.Open();
SqlCommand cmd = new SqlCommand("sp_RecordUserNavigation", connection);
cmd.CommandType = CommandType.StoredProcedure;
cmd.Parameters.Add(
new SqlParameter()
{
ParameterName = "@NavHistoryBatch",
SqlDbType = SqlDbType.Structured,
TypeName = "NavigationHistoryTableType",
Value = navHistoryBatch,
});
cmd.ExecuteNonQuery();
}
}
}
このバッファリング クラスを使用するために、アプリケーションは静的な NavHistoryDataMonitor
オブジェクトを作成します。 ユーザーがページにアクセスするたびに、アプリケーションは NavHistoryDataMonitor
を呼び出します。
RecordUserNavigationEntry
メソッド それらのエントリをまとめてデータベースに送信する処理は、バッファリングのロジックによって行われます。
マスター/詳細
テーブル値パラメーターは、単純な INSERT
シナリオに役立ちます。 しかし複数のテーブルが関係する一括挿入については、そう簡単にはいきません。 その典型的な例が "マスター/詳細" のシナリオです。 マスター テーブルは、プライマリ エンティティを識別します。 エンティティに関するその他のデータは、1 つまたは複数の詳細テーブルに格納されます。 この場合、詳細テーブルと一意のマスター エンティティとの関係が外部キー リレーションシップによって強制的に適用されます。
PurchaseOrder
テーブルとそれに関連付けられているOrderDetail
テーブルの簡略化されたバージョンを検討してください。 次の Transact-SQL は、PurchaseOrder
、OrderID
、OrderDate
、CustomerID
の 4 つの列を含むStatus
テーブルを作成します。
CREATE TABLE [dbo].[PurchaseOrder](
[OrderID] [int] IDENTITY(1,1) NOT NULL,
[OrderDate] [datetime] NOT NULL,
[CustomerID] [int] NOT NULL,
[Status] [nvarchar](50) NOT NULL,
CONSTRAINT [PrimaryKey_PurchaseOrder]
PRIMARY KEY CLUSTERED ( [OrderID] ASC ));
それぞれの注文は、少なくとも 1 つの商品購入を含んでいます。 この情報は、 PurchaseOrderDetail
テーブルにキャプチャされます。 次の Transact-SQL では、PurchaseOrderDetail
、OrderID
、OrderDetailID
、ProductID
、UnitPrice
の 5 つの列を含むOrderQty
テーブルが作成されます。
CREATE TABLE [dbo].[PurchaseOrderDetail](
[OrderID] [int] NOT NULL,
[OrderDetailID] [int] IDENTITY(1,1) NOT NULL,
[ProductID] [int] NOT NULL,
[UnitPrice] [money] NULL,
[OrderQty] [smallint] NULL,
CONSTRAINT [PrimaryKey_PurchaseOrderDetail] PRIMARY KEY CLUSTERED
( [OrderID] ASC, [OrderDetailID] ASC ));
OrderID
テーブルのPurchaseOrderDetail
列は、PurchaseOrder
テーブルからの順序を参照する必要があります。 この制約を強制的に適用する外部キーの定義は次のようになります。
ALTER TABLE [dbo].[PurchaseOrderDetail] WITH CHECK ADD
CONSTRAINT [FK_OrderID_PurchaseOrder] FOREIGN KEY([OrderID])
REFERENCES [dbo].[PurchaseOrder] ([OrderID]);
テーブル値パラメーターを使用するには、対象となる各テーブルについてユーザー定義テーブル型が必要です。
CREATE TYPE PurchaseOrderTableType AS TABLE
( OrderID INT,
OrderDate DATETIME,
CustomerID INT,
Status NVARCHAR(50) );
GO
CREATE TYPE PurchaseOrderDetailTableType AS TABLE
( OrderID INT,
ProductID INT,
UnitPrice MONEY,
OrderQty SMALLINT );
GO
そのうえで、これらの型のテーブルを受け取るストアド プロシージャを定義します。 このプロシージャによって、アプリケーションは一連の注文と注文明細をローカルから 1 回の呼び出しでバッチ処理することができます。 次の Transact-SQL は、この注文処理の例に使用されるストアド プロシージャの宣言全体です。
CREATE PROCEDURE sp_InsertOrdersBatch (
@orders as PurchaseOrderTableType READONLY,
@details as PurchaseOrderDetailTableType READONLY )
AS
SET NOCOUNT ON;
-- Table that connects the order identifiers in the @orders
-- table with the actual order identifiers in the PurchaseOrder table
DECLARE @IdentityLink AS TABLE (
SubmittedKey int,
ActualKey int,
RowNumber int identity(1,1)
);
-- Add new orders to the PurchaseOrder table, storing the actual
-- order identifiers in the @IdentityLink table
INSERT INTO PurchaseOrder ([OrderDate], [CustomerID], [Status])
OUTPUT inserted.OrderID INTO @IdentityLink (ActualKey)
SELECT [OrderDate], [CustomerID], [Status] FROM @orders ORDER BY OrderID;
-- Match the passed-in order identifiers with the actual identifiers
-- and complete the @IdentityLink table for use with inserting the details
WITH OrderedRows As (
SELECT OrderID, ROW_NUMBER () OVER (ORDER BY OrderID) As RowNumber
FROM @orders
)
UPDATE @IdentityLink SET SubmittedKey = M.OrderID
FROM @IdentityLink L JOIN OrderedRows M ON L.RowNumber = M.RowNumber;
-- Insert the order details into the PurchaseOrderDetail table,
-- using the actual order identifiers of the master table, PurchaseOrder
INSERT INTO PurchaseOrderDetail (
[OrderID],
[ProductID],
[UnitPrice],
[OrderQty] )
SELECT L.ActualKey, D.ProductID, D.UnitPrice, D.OrderQty
FROM @details D
JOIN @IdentityLink L ON L.SubmittedKey = D.OrderID;
GO
この例では、ローカルに定義された @IdentityLink
テーブルには、新しく挿入された行の実際の OrderID
値が格納されます。 これらの順序識別子は、OrderID
および @orders テーブル値パラメーターにおける一時的な @details 値とは異なります。 このため、@IdentityLink
テーブルは、OrderID
パラメーターの@orders値を、OrderID
テーブル内の新しい行の実際のPurchaseOrder
値に接続します。 この手順の後、 @IdentityLink テーブルは、外部キー制約を満たす実際の OrderID
を使用して、注文の詳細の挿入を容易にすることができます。
このストアド プロシージャは、コードから使用することも、他の Transact-SQL の呼び出しから使用することもできます。 コード例については、この記事のテーブル値パラメーターのセクションを参照してください。 次の Transact-SQL は、 sp_InsertOrdersBatch
を呼び出す方法を示しています。
declare @orders as PurchaseOrderTableType;
declare @details as PurchaseOrderDetailTableType;
INSERT @orders
([OrderID], [OrderDate], [CustomerID], [Status])
VALUES(1, '1/1/2013', 1125, 'Complete'),
(2, '1/13/2013', 348, 'Processing'),
(3, '1/12/2013', 2504, 'Shipped');
INSERT @details
([OrderID], [ProductID], [UnitPrice], [OrderQty])
VALUES(1, 10, $11.50, 1),
(1, 12, $1.58, 1),
(2, 23, $2.57, 2),
(3, 4, $10.00, 1);
EXEC sp_InsertOrdersBatch @orders, @details;
このソリューションでは、各バッチで 1 から始まる一連の OrderID
値を使用できます。 これらの一時的な OrderID
値はバッチ内のリレーションシップを記述しますが、実際の OrderID
値は挿入操作の時点で決定されます。 先ほどの例で同じステートメントを繰り返し実行し、一意の注文をデータベースに生成することができます。 このため、このバッチ処理手法を用いるときは、注文の重複を防止するためのコードまたはデータベース ロジックを追加することを検討してください。
この例は、複雑なデータベース操作 (マスター/詳細操作など) であっても、テーブル値パラメーターを使用すればバッチ処理で対応できることを示しています。
UPSERT
バッチ処理のシナリオをもう 1 つ紹介します。既存の行の更新と新しい行の挿入を同時に実行するというものです。 この操作を "UPSERT" (update + insert) と呼ぶことがあります。 この作業には、INSERT と UPDATE を別々に呼び出して実行するよりも、MERGE ステートメントを使った方法が適切な場合があります。 MERGE ステートメントは、挿入と更新の両方の操作を 1 回の呼び出しで実行することができます。 MERGE ステートメントのロック機構は、個別の INSERT および UPDATE ステートメントとは異なる動作をします。 運用環境にデプロイする前に、特定のワークロードをテストします。
テーブル値パラメーターと MERGE ステートメントとを組み合わせることによって、更新と挿入を実行できます。 たとえば、EmployeeID、FirstName、LastName、SocialSecurityNumber の列を含んだ単純な Employee テーブルがあるとします。
CREATE TABLE [dbo].[Employee](
[EmployeeID] [int] IDENTITY(1,1) NOT NULL,
[FirstName] [nvarchar](50) NOT NULL,
[LastName] [nvarchar](50) NOT NULL,
[SocialSecurityNumber] [nvarchar](50) NOT NULL,
CONSTRAINT [PrimaryKey_Employee] PRIMARY KEY CLUSTERED
([EmployeeID] ASC ))
この例では、SocialSecurityNumber が一意であることを利用して、複数の従業員の MERGE を実行できます。 まず、ユーザー定義テーブル型を作成します。
CREATE TYPE EmployeeTableType AS TABLE
( Employee_ID INT,
FirstName NVARCHAR(50),
LastName NVARCHAR(50),
SocialSecurityNumber NVARCHAR(50) );
GO
次に、MERGE ステートメントで更新と挿入を行うコードを記述するか、ストアド プロシージャを作成します。 次の例では、EmployeeTableType 型のテーブル値パラメーター @employees に対して MERGE ステートメントを使っています。 @employees テーブルの内容はここには表示されません。
MERGE Employee AS target
USING (SELECT [FirstName], [LastName], [SocialSecurityNumber] FROM @employees)
AS source ([FirstName], [LastName], [SocialSecurityNumber])
ON (target.[SocialSecurityNumber] = source.[SocialSecurityNumber])
WHEN MATCHED THEN
UPDATE SET
target.FirstName = source.FirstName,
target.LastName = source.LastName
WHEN NOT MATCHED THEN
INSERT ([FirstName], [LastName], [SocialSecurityNumber])
VALUES (source.[FirstName], source.[LastName], source.[SocialSecurityNumber]);
詳細については、MERGE ステートメントのドキュメントと例を参照してください。 INSERT 操作と UPDATE 操作を個別に使用した複数のステップから成るストアド プロシージャで同じ作業を実行することもできますが、MERGE ステートメントの方が効率的です。 直接 MERGE ステートメントを使った Transact-SQL の呼び出しをデータベース コードで作成することもできます。そうすれば、INSERT 用と UPDATE 用の 2 つのデータベース呼び出しは必要ありません。
推奨事項のまとめ
この記事で説明したバッチ処理の推奨事項は以下のとおりです。
- Azure SQL Database アプリケーションと Azure SQL Managed Instance アプリケーションのパフォーマンスとスケーラビリティを高めるには、バッファリングとバッチ処理を使用する。
- バッチ処理/バッファリングと回復性とのトレードオフを考える。 ロールの障害発生時、業務上重要なデータのバッチが未処理となっているときに、そのデータが一度に失われてしまうリスクの方が、バッチ処理によって得られるパフォーマンスのメリットよりも大きい可能性があります。
- 待機時間を軽減するために、データベースに対するすべての呼び出しをできるだけ単一のデータセンターの範囲内に維持する。
- 単一バッチによる処理手法を使う場合、パフォーマンスと柔軟性が最も高いのはテーブル値パラメーター。
- 挿入操作の処理速度を最大限に高めるには、以下に示す一般的なガイドラインに従いつつ、実際のシナリオに合わせてテストする。
-
< 100 行の場合は、パラメーター化された 1 つの
INSERT
コマンドを使用します。 - < 1,000 行の場合は、テーブル値パラメーターを使用します。
-
>= 1000 行の場合は、
SqlBulkCopy
を使用します。
-
< 100 行の場合は、パラメーター化された 1 つの
- 更新操作と削除操作に関しては、テーブル値パラメーターを使用する。テーブル パラメーター内の行ごとに適切な操作を判断するストアド プロシージャのロジックを使用します。
- バッチ サイズのガイドライン:
- アプリケーションとビジネスの要件に見合った最大バッチ サイズを使用する。
- バッチ サイズを大きくすることによるパフォーマンス上のメリットと、一時的または致命的な障害が発生することによって生じるリスクとのバランスを考える。 再試行を余儀なくされたり、まとまったデータが失われたりした場合の影響を考慮してください。
- 最大バッチ サイズをテストして、Azure SQL Database または Azure SQL Managed Instance が拒否しないことを確認します。
- バッチ処理を制御する構成設定 (バッチ サイズ、バッファリングの時間枠など) を作成する。 これらの設定によって柔軟性を確保することができます。 運用環境でクラウド サービスをデプロイし直すことなく、バッチ処理の動作を変更することが可能です。
- 1 つのデータベース内の単一テーブルに対してはバッチの並列実行を回避する。 単一のバッチを分割して複数のワーカー スレッドに割り当てる場合、テストを実行して理想的なスレッド数を特定してください。 未指定のままスレッド数が限界を超えると、パフォーマンスが逆に低下します。
- その他さまざまなシナリオでバッチ処理を実装するための手段として、バッファー サイズや経過時間によって作動するバッファリングを検討する。
関連コンテンツ
- Azure SQL Database でパフォーマンスのためにアプリケーションとデータベースを調整する
- エラスティック プールを利用した Azure SQL Database 内の複数のデータベースの管理およびスケーリング