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仲間いたら、どんどんシェア待ってるぜ!!

執筆者

バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る

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

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

コメント

コメントする

CAPTCHA


目次