SQL Server のカーソルを使うべきタイミングと書き方(業務SE現場の判断軸)

みなさんこんにちは!ヒロポンです!

今回は業務SE現場でガチで判断に詰まりやすいやつ!!の話。

「先輩が古いストアドの中で DECLARE CURSOR してベタ書きしてる、これ俺が引き継ぎで保守する時、書き直すべきっすか??」って迷ったこと、ないっすか??

俺も SQL Server 2016 時代の流通系の基幹システム保守で、カーソルで書かれた8分かかるバッチストアドを SET ベースに書き直したら2秒程度まで短縮できた経験があって、「お、これは全部書き換えだな」って一瞬思ったんですよね。でも別の現場で、行ごとに動的 SQL を組まないといけない仕様のストアドに当たって、それは SET ベースじゃどうやっても書けないってのも経験した。

つまり「カーソル=悪」じゃなく、カーソルが許される場面と避けるべき場面の判断軸を持っておかないと、現場で詰みます。この記事では業務SE目線でその判断軸と、書くなら避けては通れない FAST_FORWARD READ_ONLY LOCAL の意味、ハマりポイントを T-SQL コード4本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。

3行で結論:

  • 使うべき場面は3つだけ:行ごと動的SQL/行ごと外部連携/CTE・ウィンドウで書けない複雑ロジック
  • 避けるべき場面は5つ:一括 UPDATE/INSERT・集計・ランキング・自己結合・条件分岐 UPDATE(全部 SET ベースで速い)
  • 書くなら DECLARE CURSOR ... LOCAL FAST_FORWARD READ_ONLY で固定、付けないと KEYSET / GLOBAL / 更新可能の重いやつが作られる
目次

カーソルが許される3つの場面

(避けるべき5場面と SET ベース書き換え例は次の H2「カーソルを避けるべき5つの場面」で全部出すので、まずこの3場面だけ先に押さえてください)

「セットベースで書け」って唱える人は多いんだけど、業務系の現場ではどうしても SET ベースで書けない処理が一定割合で残るんですよね。具体的にはこの3つ:

1. 行ごとに動的 SQL を組む必要がある

メタデータ走査系。sys.tables を回して、各テーブルに対して EXEC sp_executesql N'SELECT COUNT(*) FROM ' + @table_name のような動的 SQL を組む処理。これは行ごとにテーブル名が変わるので、SET ベースでは表現できない。

2. 行ごとに外部システムに連携してその結果で次の行が変わる

バッチ系で、各レコードに対して外部 API や別 DB へ問い合わせて、その応答内容で次の行の処理が変わるパターン。SQL Server 単独では完結しない手続き的な依存関係があると、SET ベースに落とし込めない。

3. CTE / WINDOW 関数で表現できない複雑な行間ロジック

「前の行の値を参照して、ある条件を満たしたらフラグを立てて、その立て方で2つ後の行の処理が変わる」みたいな、ループ前提の状態遷移を含む処理。LAG()LEAD() で済むなら SET ベース、済まないならカーソル、という線引き。

ここに当てはまらないなら、原則 SET ベースで書き直す方向で考えるのが業務SE現場の正解。

カーソルを避けるべき5つの場面(書き換え後の SET ベース例)

逆に、カーソルで書かれてたら9割書き直し対象な5パターン。レビューで見つけたら原則リファクタです。

1. 一括 UPDATE / INSERT

-- ❌ アンチパターン: カーソルで1行ずつ UPDATE
DECLARE @id INT, @flag CHAR(1);
DECLARE c CURSOR FOR SELECT id, flag FROM target_table WHERE status = 0;
OPEN c;
FETCH NEXT FROM c INTO @id, @flag;
WHILE @@FETCH_STATUS = 0
BEGIN
    UPDATE target_table SET flag = 'Y' WHERE id = @id;
    FETCH NEXT FROM c INTO @id, @flag;
END;
CLOSE c; DEALLOCATE c;

-- ✅ SET ベース: 一発
UPDATE target_table SET flag = 'Y' WHERE status = 0;

カーソル版は10万行で5分かかってたのが、SET ベースで1秒以下になる。書き換えると体感が違いすぎて笑うやつ。

2. 集計(SUM / COUNT / AVG)

-- ❌ カーソル+変数累積
DECLARE @total DECIMAL(18,2) = 0;
-- ... カーソル省略 ... SET @total = @total + @amount;

-- ✅ GROUP BY 一発
SELECT category, SUM(amount) AS total FROM sales_summary GROUP BY category;

集計でカーソル使ってるストアドを見つけたら、SQL の入門書1ページ分の話で書き直せる。

3. ランキング(ROW_NUMBER / RANK)

-- ✅ ウィンドウ関数で一発
SELECT
    customer_id,
    purchase_date,
    amount,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY purchase_date DESC) AS rn
FROM purchase_history;

「顧客ごとに最新の購入を取りたい」みたいな処理をカーソルでやってる現場、業務系には未だにあります。ROW_NUMBER() 1発に書き換えると数十倍速くなる。

4. 自己結合の代替

「前月との比較」「前日との差分」をカーソルで2周回してる処理は、LAG() か自己 JOIN で書ける。SQL Server 2012 以降なら LAG() 一択。

5. 条件分岐 UPDATE

-- ✅ CASE WHEN 一発
UPDATE customer_master
SET grade = CASE
    WHEN total_amount >= 1000000 THEN 'A'
    WHEN total_amount >= 500000  THEN 'B'
    ELSE 'C'
END;

カーソルで条件分岐 UPDATE してるパターンも、CASE WHEN で全部書き直せます。

カーソルの正しい書き方(DECLARE 〜 DEALLOCATE のテンプレ)

カーソルが本当に必要だと判断したら、書き方は1パターンに揃えるのが事故防止の第一歩。テンプレはこれ:

-- ✅ カーソル基本テンプレ
DECLARE @table_name SYSNAME;
DECLARE @sql NVARCHAR(MAX);

DECLARE meta_cursor CURSOR LOCAL FAST_FORWARD READ_ONLY FOR
    SELECT name FROM sys.tables WHERE schema_id = SCHEMA_ID('dbo');

OPEN meta_cursor;
FETCH NEXT FROM meta_cursor INTO @table_name;

WHILE @@FETCH_STATUS = 0
BEGIN
    SET @sql = N'SELECT ''' + @table_name + N''' AS tbl, COUNT(*) AS cnt FROM dbo.' + QUOTENAME(@table_name);
    EXEC sp_executesql @sql;

    FETCH NEXT FROM meta_cursor INTO @table_name;
END;

CLOSE meta_cursor;
DEALLOCATE meta_cursor;

このテンプレを業務系チームに置いておくと、新人がカーソル書く時に全員このコピペから入るので、書き方のバラつきが激減する。

ポイントは:

  1. LOCAL でストアド内スコープに閉じる(後述)
  2. FAST_FORWARD で前進専用最適化
  3. READ_ONLY で更新しない宣言(性能が桁違い)
  4. WHILE @@FETCH_STATUS = 0 のループ条件は丸暗記
  5. CLOSEDEALLOCATE の2段で締める

ん?普通に動的 SQL だけで書けばよくない??って思うかもだけど、メタデータを順に走査して各々に違う SQL を組む処理は、まさにカーソルが正解の領域なんすよね。

性能落とし穴 — FAST_FORWARD READ_ONLY LOCAL の意味

ここがカーソル使う上で一番効くところ。指定なしで DECLARE c CURSOR FOR SELECT ... と書くと、SQL Server は重いカーソル種類をデフォルトで作ります。

修飾子 意味 付けないとどうなる
LOCAL ストアドのスコープ内だけで有効 デフォルトは GLOBAL になり、セッション全体で名前衝突リスク
FAST_FORWARD 前進専用+読み取り専用最適化 KEYSET / DYNAMIC カーソルが作られて重い
READ_ONLY 更新不可(カーソル経由で UPDATE/DELETE しない) 更新可能カーソルが作られて行ロックが増える
STATIC tempdb にコピーして読む(行数固定) データ変更が見えてしまう+整合性問題

業務系の通常用途なら LOCAL FAST_FORWARD READ_ONLY を全部付ける のが基本になります。これだけで体感性能が数倍違うので、付け忘れた古いストアドを直すだけで現場のバッチが目に見えて速くなるケースが多いと覚えてください。

-- ❌ 重いデフォルト(GLOBAL / KEYSET / 更新可能)
DECLARE c CURSOR FOR SELECT id FROM big_table;

-- ✅ 軽量カーソル(LOCAL / FAST_FORWARD / READ_ONLY)
DECLARE c CURSOR LOCAL FAST_FORWARD READ_ONLY FOR SELECT id FROM big_table;

逆向きの FETCH PRIOR を使いたい場合は SCROLL、カーソル経由で更新したい場合は READ_ONLY を外す、という風に機能を1つずつ明示的に ON にするのが原則になります。

C# 側との連携 — DataReader でクライアント側ループに寄せる選択肢

業務系の場合、SQL Server 内のカーソルじゃなく、C# 側に SqlDataReader でループを持っていく選択肢もあります。

// ✅ C# 側でループする(DataReader 版)
using (var conn = new SqlConnection(_connectionString))
using (var cmd = new SqlCommand("SELECT id, name FROM target_table WHERE status = 0", conn))
{
    conn.Open();
    using (var reader = cmd.ExecuteReader())
    {
        while (reader.Read())
        {
            var id = reader.GetInt32(0);
            var name = reader.GetString(1);
            // 1行ごとに別 API を叩く・整形する等の手続き処理
            await ExternalApi.NotifyAsync(id, name);
        }
    }
}

判断軸:

  • SQL Server 内で完結する処理 → T-SQL カーソル(DB 側ループ)
  • アプリ側で整形・外部 API 連携が絡む → C# DataReader(クライアント側ループ)
  • 行数が万〜十万単位で、ネットワーク往復が問題になる → DB 側に寄せる

「クライアント側ループはネットワーク往復が増える」って点を意識しないと、行数が増えた時に詰まる。SQL の中で全部できるなら DB 側、外との連携が絡むならアプリ側、っていう優先順位っす。

ハマりポイント3つ(実体験)

1. DEALLOCATE 漏れでカーソル名衝突した(30分溶かした)

GLOBAL カーソル(LOCAL 指定なし)で書かれた古いストアドを修正した時、CLOSE だけ書いて DEALLOCATE を忘れて、2回目の呼び出しで「カーソル名が既に存在する」エラーで詰まった。30分溶かした。それ以来、カーソルは全部 LOCAL 指定で書くルールに変えました。

2. @@FETCH_STATUS チェック忘れで無限ループ(半日デバッガで追った)

WHILE @@FETCH_STATUS = 0 の代わりに WHILE 1 = 1 で書いて、ループ内の BREAK 条件を間違えて、カーソルが行末到達後も無限に空 FETCH を繰り返すバッチを書いてしまった事件。本番リリース後に「夜間バッチが朝になっても終わらない」って報告が来て半日デバッガで追った。それ以来、@@FETCH_STATUS のチェックは丸暗記の定型句になってます。

3. トランザクション内カーソルの長期ロック(夕方の運用報告で気づいた)

カーソルを BEGIN TRANSACTION の中で開いて、各行で外部連携を待つストアドを書いた時、外部連携の応答が遅延した結果、トランザクション全体が長時間維持されて他のセッションがブロックされた。夕方の運用報告で「画面のレスポンスが落ちてる」って報告が上がってきて、ようやく気づいた。トランザクション内カーソルは短時間処理だけってのを学んだ事件です。

著者の現場メモ — 業務系チームでのルール化

流通系の基幹システム保守チームで、過去ストアドを grep -i "DECLARE.*CURSOR" . でひっかけたら80箇所近く出てきたんですよね。書き方がバラバラで、LOCAL FAST_FORWARD READ_ONLY 付いてるやつ、付いてないやつ、DEALLOCATE 漏れてるやつ、@@FETCH_STATUS チェック忘れてるやつ、全部入り。

んで、後輩と一緒に 3行ルール にまとめた:

  1. 新規カーソル禁止(書きたい時はレビューで議論、原則 SET ベース)
  2. 既存カーソルを修正する時は LOCAL FAST_FORWARD READ_ONLY を付ける + テンプレに揃える
  3. トランザクション内で外部連携を待つカーソルは禁止(外で完結させる)

このルール化で、レビュー段階で「これ SET ベースで書ける」って指摘が10倍出やすくなった。カーソル書く前に1回は SET ベースで書けないか考えるだけで、現場の処理時間がいい感じに短縮されるので、業務系チームには結構おすすめのルールっす。

C# 7.3 + .NET Framework 4.7.2 + SQL Server 2016 のレガシー業務系って、モダンな構文(CTE・ウィンドウ関数)が普通に使える環境なんだけど、書き方がカーソル時代から進化してないコードベースって本当に多い。書き方の更新だけで保守工数が下がる実例だと思ってます。

まとめ

状況 推奨アプローチ
一括 UPDATE/INSERT・集計・ランキング・自己結合・条件分岐 UPDATE SET ベース(カーソル禁止)
行ごと動的 SQL・行ごと外部連携・CTE で書けない複雑ロジック カーソル LOCAL FAST_FORWARD READ_ONLY
アプリ側整形・外部 API 連携 C# SqlDataReader でクライアント側ループ
通常の業務系新規開発 カーソル新規追加は禁止(既存修正時は軽量化)
性能改善 DEALLOCATE 漏れ・GLOBAL カーソル・@@FETCH_STATUS 抜けを潰す

T-SQL CURSOR は「最後の手段」だが、業務系現場では未だに残る場面が一定割合ある。書くなら書き方を1パターンに揃えて、テンプレからコピペで運用する。SET ベースで書ける処理をカーソルで書いてる古いストアドを見つけたら、書き直すだけで処理時間が桁違いに短くなることが多いので、保守工数の改善ネタとしても効きます。

よくある質問

Q1. カーソルって全部 SET ベースに書き換えるべきですか?

A. 全部ではないです。行ごとに動的SQLを組むとき、行ごとに外部システム連携が絡むとき、CTE/ウィンドウ関数で表現できない複雑な行間ロジックのとき、この3つは現実的にカーソルが残る。それ以外(一括 UPDATE/INSERT、集計、ランキング、条件分岐 UPDATE)は SET ベースで書き直した方が10倍以上速いことがほとんどです。

Q2. FAST_FORWARD READ_ONLY LOCAL は全部つけて大丈夫?

A. 通常の用途ならほぼ全部つけて OK です。FAST_FORWARD は前進専用の最適化、READ_ONLY は更新しない宣言、LOCAL はストアド内のスコープに閉じる意味。逆向きに FETCH PRIOR したい・カーソル経由で UPDATE したい・GLOBAL に共有したい場合だけ外せばいい。指定なしで DECLARE すると重い KEYSET / GLOBAL / 更新可能カーソルが作られます。

Q3. @@FETCH_STATUS のチェックを忘れたらどうなりますか?

A. 条件次第で無限ループになります。FETCH NEXT が失敗(行末到達 = -1)したのに WHILE 条件で気づかず再 FETCH を繰り返すパターン。WHILE @@FETCH_STATUS = 0 が定石なので、ループの直後に置いてください。最初の FETCH を WHILE の前に1回打つのも基本です。

Q4. DEALLOCATE を忘れるとどうなりますか?

A. セッション内で同じカーソル名を再 DECLARE しようとした時に「カーソルが既に存在する」エラーで詰まります。さらに GLOBAL カーソルだとセッション間でも名前衝突する。CLOSEDEALLOCATEBEGIN TRY ... END TRY BEGIN CATCH ... END CATCH の中で漏れなく実行する書き方が鉄板です。

Q5. C# 側に DataReader でループするのと SQL Server 側でカーソル回すの、どっちがいい?

A. アプリ側でデータ整形・別 API 連携が絡むなら C# DataReader、SQL Server 内で完結する処理ならカーソル、というのが基本の分け方。ただしクライアント側ループはネットワーク往復が増えるので、行数が多いと遅くなる。「SQL 内で完結できるか」をまず疑って、できるなら SET ベース、できないなら CURSOR、それでも厳しい複雑処理だけ DataReader、の優先順位で考えるといい感じに収まります。

ここまでで T-SQL カーソルの判断軸・書き方・ハマりどころは押さえた。SQL Server 業務系の隣接トピックも貼っておきます。

関連記事

以上!

同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!

この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次