SQL Server のストアドプロシージャをループから呼び出すとき、「この呼び出しは並列に動いている? それとも 1 回ずつきっちり順番に実行されている?」と迷うことは少なくありません。本記事では、T‑SQL の実行モデルを丁寧に分解しながら、「EXEC は同期か非同期か」「並列にしたいならどう設計すべきか」を、実用的なコード例とともに詳しく解説します。
SQL Server ストアドプロシージャの実行順序の基本
まず大前提として、SQL Server の T‑SQL バッチ(1 つのクエリウィンドウで流す一連の文)は、基本的に上から順番に逐次実行されます。同じセッション・同じ接続の中で、ある文が完了してから次の文に進む、というモデルです。
ストアドプロシージャもこのルールから外れません。T‑SQL のコード内で EXEC dbo.プロシージャ名や sp_executesqlを使ってプロシージャを呼び出した場合、その呼び出しは同期実行され、呼び出されたプロシージャが完了するまで制御が呼び出し元に戻りません。
「同期実行」と「非同期実行」の整理
| 種類 | 状態 | 特徴 |
|---|---|---|
| 同期実行 | 完了するまで呼び出し元は待機 | 処理順序が明確でデバッグしやすい。1 セッション内の T‑SQL は基本これ。 |
| 非同期実行 | 呼び出した後すぐ制御が戻る | 別プロセス / 別セッションで処理が進む。結果の受け取りやエラー処理を別途設計する必要。 |
多くの開発者が混乱しがちなポイントは、「クエリプランの並列実行」と「プロシージャの並列起動」を混同してしまうことです。後者は T‑SQL だけで簡単に行えるものではありません。
サンプルシナリオ:ループからストアドプロシージャを呼ぶ
よくあるケースとして、次のようなストアドプロシージャ構成を考えます。
dbo.A:呼び出し側。ループしながら毎回パラメータを変えてdbo.Bを呼び出す。dbo.B:実際の処理を行うプロシージャ。
質問の本質は次のようなものです。
- このループの各回で呼ばれる
dbo.Bは、並列で走るのか? - それとも、1 回の呼び出しが完了してから次に進む逐次(同期)実行なのか?
- もし逐次にしたい場合、特別な指定が必要なのか?
結論から言うと、何もしなくても逐次(同期)実行です。T‑SQL の EXEC 呼び出しが完了しない限り、次のループ反復には進みません。
安全なコード例
ループからストアドプロシージャを呼ぶ、素直で読みやすいコード例を示します。
-- 呼び出し側プロシージャ
CREATE OR ALTER PROCEDURE dbo.A
AS
BEGIN
SET NOCOUNT ON;
DECLARE @id int = 1;
WHILE (@id < 5)
BEGIN
-- この EXEC が終わるまで次の反復には進まない
EXEC dbo.B @id = @id;
SET @id += 1;
END
END
GO
-- 呼び出される側プロシージャ(例)
CREATE OR ALTER PROCEDURE dbo.B
@id int
AS
BEGIN
SET NOCOUNT ON;
-- ここで @id を使った何らかの処理を行う
INSERT INTO dbo.SampleLog(Id, ProcessedAt)
VALUES(@id, SYSDATETIME());
END
GO
このコードでは、dbo.B の 1 回の実行が完了してから次の @id に進むため、実行順序は 1 → 2 → 3 → 4 と保証されます。
なぜループ内のプロシージャ呼び出しは並列にならないのか
同じセッション・同じ接続の中では、SQL Server は 1 つのリクエスト(ステートメント)を処理し終えるまで次のリクエストに手をつけません。EXEC dbo.B も 1 つのステートメントとして扱われるため、完了までは次の文は実行されないのです。
クエリプランの並列化と「プロシージャ並列起動」の違い
SQL Server には「クエリプランの並列実行」という仕組みがあります。多コア CPU を活かして 1 つのクエリを複数スレッドで処理する機能です。しかしこれはあくまで1 つのステートメント内部の最適化であり、
- ループの 1 回目の
EXEC dbo.B - ループの 2 回目の
EXEC dbo.B
がそれぞれ別スレッドで同時に走る、という意味ではありません。
| 機能 | 対象 | 特徴 |
|---|---|---|
| クエリプランの並列化 | 単一の SELECT / INSERT など | 1 文の中を複数スレッドで処理。T‑SQL の実行順序自体は変わらない。 |
| プロシージャの並列起動 | 複数の EXEC 呼び出し | 別セッションや別プロセスから同時に呼び出す必要がある。T‑SQL だけでは自動では行われない。 |
「MAXDOP = 0 だから勝手に並列に動いているはず」と誤解されがちですが、それは単一クエリの内部の話であり、ストアドプロシージャの呼び出し順序には影響しません。
逐次実行を前提にした正しい書き方と注意点
プロシージャ呼び出しには素直に EXEC を使う
ストアドプロシージャを呼ぶだけなのに、わざわざ sp_executesql で動的 SQL を組み立てているコードを見かけることがあります。例えば次のような書き方です。
-- あまりおすすめしない例
DECLARE @sql nvarchar(100) = N'EXEC dbo.B @id';
EXEC sp_executesql @sql, N'@id int', @id = @id;
もちろんこれでも動作はしますが、読みづらく、権限やスキーマの問題が発生したときに原因が追いづらくなります。特別な理由(プロシージャ名自体を動的に変えるなど)がない限り、素直に EXEC dbo.B @id = @id; を使うことをおすすめします。
WHILE / BEGIN / END とセミコロンの位置
T‑SQL ではセミコロンは「文の終端」を表しますが、WHILE や BEGIN/END の直後にセミコロンを置くと構文的におかしくなり、期待しない動作を引き起こすことがあります。
次のような書き方は避けましょう。
-- NG 例
WHILE (@id < 5);
BEGIN
EXEC dbo.B @id = @id;
SET @id += 1;
END;
正しくは以下のように記述します。
-- OK 例
WHILE (@id < 5)
BEGIN
EXEC dbo.B @id = @id;
SET @id += 1;
END;
セミコロンはステートメントの末尾に置くのが基本です。迷った場合は「SELECT や SET の行末にだけ付ける」ぐらいにしておくと安全です。
エラーハンドリングとトランザクション設計
逐次実行でループを回す場合、途中でエラーが出たときにどう振る舞うかをきちんと設計しておく必要があります。代表的なパターンは以下の通りです。
- 1 回でもエラーが出たら全体をロールバックする
- エラーが出た行だけスキップし、ログに記録して処理を続行する
SQL Server では TRY...CATCH と SET XACT_ABORT ON を組み合わせるのが定番です。
CREATE OR ALTER PROCEDURE dbo.A
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON; -- エラー時はトランザクションを自動的に中断
DECLARE @id int = 1;
WHILE (@id < 5)
BEGIN
BEGIN TRY
BEGIN TRAN;
EXEC dbo.B @id = @id;
COMMIT TRAN;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRAN;
INSERT INTO dbo.ErrorLog
(
Id, ErrorNumber, ErrorMessage, ErrorTime
)
VALUES
(
@id,
ERROR_NUMBER(),
ERROR_MESSAGE(),
SYSDATETIME()
);
-- 必要に応じて処理続行か中断かを決める
-- 例: CONTINUE で次の @id へ
END CATCH;
SET @id += 1;
END
END
GO
このようにしておくことで、エラー発生時の挙動を一貫して管理できます。
「非同期・並列で処理したい」場合に使える 4 つの選択肢
T‑SQL 自体には「プロシージャを非同期で起動する」構文はありません。本当に並列・非同期にしたい場合は、外部の仕組みと組み合わせて実現します。代表的なアプローチを表にまとめます。
| 方式 | 概要 | 向いているケース |
|---|---|---|
| キュー & ワーカー(テーブル駆動) | 処理対象をキューテーブルに溜め、複数セッションが取り出して処理。 | 高負荷バッチ処理、再実行が必要な業務処理。 |
| Service Broker | SQL Server 組み込みのメッセージキューを使う本格派。 | 確実な配信・トランザクション整合性が重要な大規模システム。 |
| SQL Server Agent + sp_start_job | ジョブをバックグラウンド実行として起動。 | 定期バッチや、ある程度「投げっぱなし」で良い処理。 |
| アプリケーション層での並列化 | C# や Python 側でスレッド / タスク並列を行う。 | アプリからの呼び出しで柔軟な制御を行いたい場合。 |
おすすめ:キュー & ワーカー方式(テーブル駆動バッチ)
最も汎用的で運用しやすいのが、キュー & ワーカー方式です。おおまかな流れは次のとおりです。
- 処理対象(パラメータ)をキューテーブルに INSERT する。
- 複数のワーカーセッションが
SELECT ... WITH (READPAST, UPDLOCK)で 1 件ずつ取り出す。 - 取り出したパラメータで
dbo.Bを実行する。 - 成功 / 失敗のステータスをキューテーブルに記録する。
キューテーブルの例
CREATE TABLE dbo.JobQueue
(
JobId bigint IDENTITY(1,1) PRIMARY KEY,
ParamId int NOT NULL,
Status char(1) NOT NULL DEFAULT 'W', -- W:待ち, R:実行中, S:成功, F:失敗
TryCount int NOT NULL DEFAULT 0,
LastError nvarchar(4000) NULL,
CreatedAt datetime2(3) NOT NULL DEFAULT SYSDATETIME(),
UpdatedAt datetime2(3) NOT NULL DEFAULT SYSDATETIME()
);
ワーカー側のサンプル
CREATE OR ALTER PROCEDURE dbo.JobWorker
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
WHILE 1 = 1
BEGIN
DECLARE @JobId bigint, @ParamId int;
BEGIN TRAN;
-- まだ処理されていない 1 件をロックして取得
SELECT TOP (1)
@JobId = JobId,
@ParamId = ParamId
FROM dbo.JobQueue WITH (READPAST, UPDLOCK, ROWLOCK)
WHERE Status = 'W'
ORDER BY JobId;
IF @JobId IS NULL
BEGIN
ROLLBACK TRAN;
BREAK; -- 仕事がないので終了
END
-- 実行中に更新
UPDATE dbo.JobQueue
SET Status = 'R',
TryCount = TryCount + 1,
UpdatedAt = SYSDATETIME()
WHERE JobId = @JobId;
COMMIT TRAN;
BEGIN TRY
-- 実際の処理
EXEC dbo.B @id = @ParamId;
UPDATE dbo.JobQueue
SET Status = 'S',
UpdatedAt = SYSDATETIME()
WHERE JobId = @JobId;
END TRY
BEGIN CATCH
UPDATE dbo.JobQueue
SET Status = 'F',
LastError = ERROR_MESSAGE(),
UpdatedAt = SYSDATETIME()
WHERE JobId = @JobId;
END CATCH;
END
END
GO
このワーカーを複数セッション(複数の SQL Server Agent ジョブや PowerShell スクリプトなど)から起動すれば、キューに溜まった仕事が並列に処理されます。T‑SQL のループ自体は逐次でも、全体としては非同期・並列な処理フローを構築できます。
Service Broker を使った本格的な非同期処理
Service Broker は SQL Server に組み込まれたメッセージング基盤で、トランザクションと連動した信頼性の高い非同期処理を実現できます。設定手順はやや複雑ですが、
- メッセージの確実な配信
- トランザクション整合性
- キューの監視とスケーリング
などが一通り揃っているため、大規模システムでは現在も有力な選択肢です。簡単なバッチなら前述のキューテーブル方式で十分ですが、金融系やミッションクリティカルな領域では Service Broker の検討価値は高いでしょう。
SQL Server Agent ジョブを sp_start_job で起動
もっと手軽に「とにかくバックグラウンドで動かしたい」だけなら、msdb.dbo.sp_start_job で SQL Server Agent ジョブを起動する方法もあります。
EXEC msdb.dbo.sp_start_job @job_name = N'MyBackgroundJob';
この呼び出し自体はほぼ即座に返ってきて、ジョブは SQL Server Agent の管理下で実行されます。ジョブの中から dbo.B を呼び出すようにしておけば、アプリケーションからは「非同期に投げた」ような挙動になります。
ただし、実行結果の取得やエラー情報の収集は自前で設計する必要があるため、ジョブ履歴の参照や専用ログテーブルの設計などを合わせて行うのが現実的です。
アプリケーション層(C#/Python など)での並列化
クライアントアプリケーションからストアドプロシージャを呼び出しているなら、アプリ側で並列化するのも素直で分かりやすい方法です。
- C# の
Task.RunやParallel.ForEach - Python の
concurrent.futures - PowerShell のジョブ機能
などを使い、複数の接続から同時に EXEC dbo.B @id = ... を呼び出せば、SQL Server 側から見れば「複数セッションからの並行実行」となります。
この方法のメリットは、
- アプリケーションフレームワークの豊富な並列・非同期機能を活用できる
- DB 以外の外部サービスとの連携も同じコードで扱える
といった点です。特に Web アプリやバッチアプリが既に存在する場合は、DB だけで完結させることにこだわらず、アプリ側で制御した方がシンプルなことが多いです。
逐次実行でも高速化するための工夫
「並列にしたい」という欲求の多くは、実は「処理時間が遅い」という問題から来ています。そこでまずは、逐次実行のままでも速くできないかを検討するのが王道です。
行ループを集合指向 SQL に書き換える
典型的なアンチパターンは、「テーブルの各行に対してループし、そのたびにプロシージャを呼ぶ」という構造です。これを可能な限り「集合指向」に書き換えることで、劇的な高速化が望めます。
例として、次のような処理を考えます。
SourceTableの全行について、何らかの集計や変換を行いTargetTableに INSERT する。- 現在は、1 行ずつ
dbo.Bを呼び出している。
ループ版(遅くなりがちな例)は次のようになります。
DECLARE @Id int;
DECLARE cur CURSOR LOCAL FAST_FORWARD FOR
SELECT Id FROM dbo.SourceTable;
OPEN cur;
FETCH NEXT FROM cur INTO @Id;
WHILE @@FETCH_STATUS = 0
BEGIN
EXEC dbo.B @id = @Id; -- 1 行ずつ処理
FETCH NEXT FROM cur INTO @Id;
END
CLOSE cur;
DEALLOCATE cur;
これを、可能な限り 1 つの INSERT 文にまとめます。
INSERT INTO dbo.TargetTable (Id, Value, ProcessedAt)
SELECT
s.Id,
SomeCalculation = s.Col1 + s.Col2, -- 例: 計算処理
ProcessedAt = SYSDATETIME()
FROM dbo.SourceTable AS s;
もちろん、dbo.B の内部で複雑なロジックや複数テーブルへの更新を行っている場合、そのまま 1 文にまとめるのは難しいこともあります。しかし、以下のようなステップで段階的に集合指向へ寄せていくことは可能です。
dbo.Bの中身を SELECT/INSERT/UPDATE 単位に分解する。- 副問い合わせや JOIN を使って 1 文で書ける部分を抽出する。
- どうしても行単位でしか書けない部分だけを残す。
これだけでも、カーソル+EXEC ループより大幅に高速化できるケースが多くあります。
インデックスと統計情報の最適化
プロシージャの処理が遅い原因が、単にインデックス不足や統計情報の古さだった、ということも珍しくありません。例えば次の点をチェックしてみてください。
- WHERE 句や JOIN でよく使う列に適切なインデックスがあるか。
- インデックスが多すぎて、INSERT/UPDATE のたびに無駄な更新が走っていないか。
- 統計情報が古く、誤った実行計画を選んでいないか。
ループや並列化を疑う前に、まずは実行計画とインデックス設計を見直すことが、結果的に最短のチューニングになる場合が多いです。
実行順序を確認・検証する簡単なテクニック
「本当に逐次で動いているのか?」「並列になっていないか?」を確認したいときは、簡単なロギングや待機を入れてみると分かりやすくなります。
ログテーブルにタイムスタンプを書き出す
dbo.B の先頭でログテーブルに INSERT するようにしておくと、実行順序が目で確認できます。
CREATE TABLE dbo.ProcLog
(
LogId int IDENTITY(1,1) PRIMARY KEY,
ProcName sysname NOT NULL,
ParamId int NULL,
LoggedAt datetime2(3) NOT NULL DEFAULT SYSDATETIME()
);
GO
CREATE OR ALTER PROCEDURE dbo.B
@id int
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.ProcLog(ProcName, ParamId)
VALUES('dbo.B', @id);
-- 実際の処理
WAITFOR DELAY '00:00:02'; -- 擬似的に重い処理を再現
END
GO
この状態で dbo.A を実行し、SELECT * FROM dbo.ProcLog ORDER BY LogId; を見てみると、LogId と ParamId の順番が 1, 2, 3, 4 ときれいに並ぶはずです。もし並列実行されていれば、順番が乱れる可能性がありますが、単一セッションからのループではそれは起こりません。
よくある誤解・アンチパターン
「sp_executesql で呼ぶと非同期になる?」
なりません。 sp_executesql はあくまで「動的に組み立てた T‑SQL を実行する」ためのストアドプロシージャであり、実行モデルは通常のステートメントと変わりません。EXEC でも sp_executesql でも、同じセッション内では同期実行です。
トリガーを使えば非同期になる?
DML トリガーは、対象の INSERT/UPDATE/DELETE と同じトランザクション内で同期実行されます。トリガー内で重い処理を行うと元の UPDATE なども遅くなってしまうため、「非同期化したい」という目的には適しません。非同期にしたい処理は、やはりキューや外部ワーカーへ切り出すのが王道です。
WAITFOR DELAY を多用する
デバッグ用途として短時間の WAITFOR DELAY を仕込むのは有効ですが、本番ロジックに大量の WAITFOR を入れると、セッションが無駄に占有され、同時接続数やスループットに悪影響を与えます。「時間を空けて処理したい」場合も、ジョブスケジューラやキューを活用する方がスケーラブルです。
まとめ:SQL Server のストアドプロシージャ実行順序を正しく理解する
最後に、本記事のポイントを整理します。
- T‑SQL の
EXECやsp_executesqlによるストアドプロシージャ呼び出しは同期実行。 - ループの中でプロシージャを呼び出すと、その 1 回の実行が終わってから次の反復に進むため、逐次実行になる。
- クエリプランの「並列実行」は、あくまで 1 文の内部処理であり、T‑SQL の文順序やプロシージャ呼び出しの並列化とは別物。
- どうしても非同期・並列にしたい場合は、キュー & ワーカー、Service Broker、SQL Server Agent ジョブ、アプリケーション層の並列化といった外部の仕組みを活用する。
- まずは行ループやカーソルを疑い、集合指向 SQL への書き換え、インデックス最適化などで根本的な高速化を図る。
ストアドプロシージャの実行順序は、一見地味なトピックですが、パフォーマンスチューニングやスケーラビリティ設計に直結する重要なポイントです。「いつ・どこで・何が並列になっているのか」を正しく理解しておくことで、無用なトラブルを避け、より堅牢で速い SQL Server アプリケーションを設計できるようになります。

コメント