【PL/SQL】カーソルFORループをBULK化する判断基準|実測と安全な分割処理

【PL/SQL】カーソルFORループ最適化とFETCH制御のベストプラクティス PL/SQL

カーソルFORループを見つけるたびに、機械的に BULK COLLECTFORALLへ変更する必要はありません。Oracleは単純な結果セットを1回走査する用途でカーソルFORループを推奨しており、コードが短く、結果全体をコレクションへ複製しない利点があります。

一方、ループ内で大量のINSERT・UPDATE・DELETEを1行ずつ実行すると、PL/SQLエンジンからSQLエンジンへの呼び出し回数が増えます。この場合は、フェッチよりも行単位DMLをFORALLへまとめる効果が大きくなります。この記事では、BULK化の判断を感覚ではなく実測で行い、安全な分割処理と再実行まで設計する方法を解説します。

先に結論

  • 読み取りながら軽いPL/SQL処理を1回行うだけなら、カーソルFORループを第一候補にします。
  • ループ内で大量のDMLを実行する場合は、BULK COLLECTより先にFORALLの適用を検討します。
  • LIMITは固定の正解値ではなく、経過時間、PGA、UNDO、ロック時間を計測して決めます。
  • チャンクコミットだけでは再開できません。処理状態または安定したキーをチェックポイントとして保存します。
  • 並列ワーカーは取得・確保・実処理を分離し、FOR UPDATEカーソルを閉じてからコミットします。

カーソルの基本構文や属性は PL/SQLカーソル完全ガイド、BULK COLLECTとFORALLの網羅的な構文は PL/SQLバルク処理完全ガイドを参照してください。本記事は、両者を切り替える判断と運用設計に焦点を当てます。

スポンサーリンク

カーソルFORループを一律に遅いと判断しない

カーソルFORループはOPEN、FETCH、CLOSEを自動化し、例外時にもカーソルを閉じます。明示的な1行FETCHと同じ見た目でも、現在のOracle Databaseではコンパイラ最適化の影響を受けるため、「必ず1行ごとに高額な往復が発生する」と断定できません。

性能問題になりやすいのは、ループの中から別のSQLやDMLを何度も呼び出す処理です。まずSQLだけで集合処理へ書き換えられないかを確認し、書き換えられない業務ロジックが残る場合にバルク処理を検討します。

  • FORループを維持:1回走査、少量データ、軽い計算、早期EXIT、可読性を優先
  • SQLへ集約:単純な更新、集計、順位、前後比較、存在判定
  • FORALLを適用:大量行に同じDMLを繰り返す
  • BULK COLLECT LIMITを適用:まとまり単位の検証、外部連携、メモリ上限の制御が必要

最初にループを集合SQLへ置き換えられないか確認する

各行へ同じ条件で更新を行うだけなら、ループやコレクションは不要です。単一UPDATEは、行ごとにPL/SQLへ戻さずデータベース内で処理を完結できます。

replace-loop-with-set-update.sql
BEGIN
  -- 行ごとのIFとUPDATEを、条件付きUPDATEへ集約する
  UPDATE app.orders o
     SET o.status = 'ON_HOLD',
         o.updated_at = SYSTIMESTAMP
   WHERE o.status = 'PENDING'
     AND EXISTS (
           SELECT 1
             FROM app.customers c
            WHERE c.customer_id = o.customer_id
              AND o.amount > c.credit_limit
         );

  DBMS_OUTPUT.PUT_LINE('updated=' || SQL%ROWCOUNT);
  COMMIT;
END;
/

前行との比較や累積値も、LAGLEADSUM ... OVERなどの分析関数で表現できる場合があります。PL/SQLへ移す前に、SQLで処理できる範囲を広げることが最も効果的な最適化です。

カーソルFORループとBULK処理を同じ条件で実測する

BULK化の効果は、対象件数、行幅、ループ内処理、キャッシュ状態で変わります。候補コードを同じセッション・同じデータで複数回実行し、初回のウォームアップを除いて比較します。次の例は読み取り処理の比較用で、どちらが必ず速いと決めるものではありません。

benchmark-cursor-vs-bulk.sql
SET SERVEROUTPUT ON

DECLARE
  TYPE t_order_tab IS TABLE OF app.orders%ROWTYPE;
  v_rows       t_order_tab;
  v_started    PLS_INTEGER;
  v_checksum   NUMBER := 0;

  CURSOR c_orders IS
    SELECT *
      FROM app.orders
     WHERE status = 'PENDING'
     ORDER BY order_id;
BEGIN
  v_started := DBMS_UTILITY.GET_TIME;
  FOR r IN c_orders LOOP
    v_checksum := v_checksum + NVL(r.amount, 0);
  END LOOP;
  DBMS_OUTPUT.PUT_LINE(
    'cursor_for_loop_cs=' || (DBMS_UTILITY.GET_TIME - v_started)
  );

  v_checksum := 0;
  v_started := DBMS_UTILITY.GET_TIME;
  OPEN c_orders;
  LOOP
    FETCH c_orders BULK COLLECT INTO v_rows LIMIT 500;
    EXIT WHEN v_rows.COUNT = 0;

    FOR i IN 1 .. v_rows.COUNT LOOP
      v_checksum := v_checksum + NVL(v_rows(i).amount, 0);
    END LOOP;
  END LOOP;
  CLOSE c_orders;

  DBMS_OUTPUT.PUT_LINE(
    'bulk_limit_cs=' || (DBMS_UTILITY.GET_TIME - v_started)
  );
END;
/
測定時の注意

DBMS_UTILITY.GET_TIMEは100分の1秒単位です。1回の差ではなく、複数回の中央値を比較します。実運用では経過時間だけでなく、セッションPGA、論理読取り、CPU時間、更新時のUNDOと待機イベントも確認します。

BULK COLLECTとLIMITでメモリ使用量を制御する

全件を一度にBULK COLLECTすると、結果セット全体をPGAへ保持します。明示カーソルから一定件数ずつ取得し、同じコレクションを反復利用すると、PGAの増加を抑えながら後段のFORALLへ渡せます。

bulk-collect-limit-forall.sql
DECLARE
  TYPE t_order_tab IS TABLE OF app.orders%ROWTYPE;
  v_rows      t_order_tab;
  v_limit     CONSTANT PLS_INTEGER := 500;

  CURSOR c_target IS
    SELECT *
      FROM app.orders
     WHERE status = 'PENDING'
       AND amount >= 1000
     ORDER BY order_id;
BEGIN
  OPEN c_target;
  LOOP
    FETCH c_target BULK COLLECT INTO v_rows LIMIT v_limit;
    EXIT WHEN v_rows.COUNT = 0;

    -- PL/SQLでしか表現しにくい検証を、この配列に対して実施する
    FOR i IN 1 .. v_rows.COUNT LOOP
      v_rows(i).status := 'REVIEWED';
    END LOOP;

    FORALL i IN 1 .. v_rows.COUNT
      UPDATE app.orders
         SET status = v_rows(i).status,
             updated_at = SYSTIMESTAMP
       WHERE order_id = v_rows(i).order_id;
  END LOOP;
  CLOSE c_target;

  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    IF c_target%ISOPEN THEN
      CLOSE c_target;
    END IF;
    ROLLBACK;
    RAISE;
END;
/

この例は処理全体を1トランザクションにしています。単にLIMITごとにCOMMITを入れると、途中失敗時にどこから再開するかが曖昧になります。トランザクションを分割する場合は、次節のチェックポイントを合わせて設計します。

LIMITは実測で決める

500や1000は開始値にすぎません。行にCLOBが含まれる、複数の配列を保持する、検証処理が重いといった条件では小さくします。小さすぎるとフェッチとFORALLの回数が増えるため、段階的に変更して測定します。

measure-session-pga.sql
SELECT
  n.name,
  s.value
FROM v$sesstat s
JOIN v$statname n
  ON n.statistic# = s.statistic#
WHERE s.sid = SYS_CONTEXT('USERENV', 'SID')
  AND n.name IN (
    'session pga memory',
    'session pga memory max',
    'CPU used by this session',
    'session logical reads'
  )
ORDER BY n.name;

計測ビューを参照するには環境に応じた権限が必要です。LIMITを100、500、1000などへ変え、処理時間とPGA最大値の両方が許容範囲に入る値を選びます。

SAVE EXCEPTIONSは原子性の方針を決めて使う

SAVE EXCEPTIONSは失敗した反復を記録し、残りのDMLを継続します。ただし、その後にROLLBACKすれば成功分も取り消されます。成功分を確定し失敗分だけ再試行するのか、チャンク全体を取り消すのかを先に決めます。

次の例は、成功した更新を残し、失敗した入力だけを再試行テーブルへ登録する部分成功方式です。

forall-save-exceptions-partial-success.sql
DECLARE
  TYPE t_id_tab IS TABLE OF app.orders.order_id%TYPE
    INDEX BY PLS_INTEGER;
  TYPE t_status_tab IS TABLE OF app.orders.status%TYPE
    INDEX BY PLS_INTEGER;

  v_ids       t_id_tab;
  v_statuses  t_status_tab;
  e_bulk_errors EXCEPTION;
  PRAGMA EXCEPTION_INIT(e_bulk_errors, -24381);
BEGIN
  v_ids(1) := 1001;
  v_statuses(1) := 'DONE';
  v_ids(2) := 1002;
  v_statuses(2) := 'DONE';

  BEGIN
    FORALL i IN INDICES OF v_ids SAVE EXCEPTIONS
      UPDATE app.orders
         SET status = v_statuses(i),
             updated_at = SYSTIMESTAMP
       WHERE order_id = v_ids(i);
  EXCEPTION
    WHEN e_bulk_errors THEN
      FOR j IN 1 .. SQL%BULK_EXCEPTIONS.COUNT LOOP
        INSERT INTO app.order_retry_queue(
          order_id,
          error_code,
          error_message,
          retry_status,
          created_at
        ) VALUES (
          v_ids(SQL%BULK_EXCEPTIONS(j).ERROR_INDEX),
          SQL%BULK_EXCEPTIONS(j).ERROR_CODE,
          SQLERRM(-SQL%BULK_EXCEPTIONS(j).ERROR_CODE),
          'WAITING',
          SYSTIMESTAMP
        );
      END LOOP;
  END;

  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    RAISE;
END;
/

チャンク全体の一貫性が必要なら、ORA-24381を捕捉した時点でROLLBACKし、チャンク単位で再実行します。失敗行の特定方法と疎なコレクションの扱いは、FORALL SAVE EXCEPTIONSで失敗行を個別ログ化する方法で詳しく解説しています。

チャンクコミットにはチェックポイントを持たせる

ORDER BYとLIMITだけでは、再実行時に同じチャンク境界へ戻れる保証はありません。処理対象テーブルへ状態を持たせるか、別の実行管理表へ最後に完了した安定キーを記録します。

checkpoint-by-stable-key.sql
DECLARE
  v_run_id       NUMBER := 2026060601;
  v_last_id      app.batch_run_checkpoint.last_order_id%TYPE;
  v_next_last_id app.orders.order_id%TYPE;
BEGIN
  SELECT last_order_id
    INTO v_last_id
    FROM app.batch_run_checkpoint
   WHERE run_id = v_run_id
   FOR UPDATE;

  SELECT MAX(order_id)
    INTO v_next_last_id
    FROM (
      SELECT order_id
        FROM app.orders
       WHERE order_id > v_last_id
         AND status = 'READY'
       ORDER BY order_id
       FETCH FIRST 500 ROWS ONLY
    );

  IF v_next_last_id IS NOT NULL THEN
    UPDATE app.orders
       SET status = 'DONE',
           updated_at = SYSTIMESTAMP
     WHERE order_id > v_last_id
       AND order_id <= v_next_last_id
       AND status = 'READY';

    UPDATE app.batch_run_checkpoint
       SET last_order_id = v_next_last_id,
           updated_at = SYSTIMESTAMP
     WHERE run_id = v_run_id;

    COMMIT;
  ELSE
    -- 対象がなくても、チェックポイント行のロックを解放する
    COMMIT;
  END IF;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    RAISE;
END;
/
キー範囲方式の前提

処理中に過去のキーを持つ対象行が追加される場合、この方式では取りこぼす可能性があります。開始時刻やスナップショット上限キーを実行管理表へ保存し、今回の処理対象範囲を固定してください。

SKIP LOCKEDによる並列ワーカーは取得と実処理を分ける

FETCH FIRSTFOR UPDATEはOracle 19cで併用できません。また、WHERE CURRENT OFは直前にFETCHした名前付きFOR UPDATEカーソルだけを参照でき、FORALL内でサブクエリの代わりには使えません。

並列ワーカーでは、FOR UPDATE SKIP LOCKEDカーソルから一定件数をBULK FETCHし、カーソルを閉じてから取得した主キーを状態更新します。確保をCOMMITした後に重い実処理を行えば、長時間ロックを避けられます。

claim-jobs-with-skip-locked.sql
DECLARE
  TYPE t_job_id_tab IS TABLE OF app.jobs.job_id%TYPE;
  v_job_ids    t_job_id_tab;
  v_worker_id  VARCHAR2(64) := SYS_CONTEXT('USERENV', 'SESSION_USER')
                              || ':' || SYS_CONTEXT('USERENV', 'SID');
  v_limit      CONSTANT PLS_INTEGER := 200;
  v_cursor_open BOOLEAN := FALSE;

  CURSOR c_claim IS
    SELECT job_id
      FROM app.jobs
     WHERE status = 'READY'
       AND ROWNUM <= v_limit
     FOR UPDATE SKIP LOCKED;
BEGIN
  OPEN c_claim;
  v_cursor_open := TRUE;
  FETCH c_claim BULK COLLECT INTO v_job_ids LIMIT v_limit;
  CLOSE c_claim;
  v_cursor_open := FALSE;

  IF v_job_ids.COUNT = 0 THEN
    COMMIT;
    RETURN;
  END IF;

  FORALL i IN 1 .. v_job_ids.COUNT
    UPDATE app.jobs
       SET status = 'CLAIMED',
           claimed_by = v_worker_id,
           claimed_at = SYSTIMESTAMP
     WHERE job_id = v_job_ids(i);

  COMMIT;

  -- 重い実処理はロック解放後に行う。
  -- 完了時は job_id と claimed_by の両方を条件にしてDONEへ更新する。
EXCEPTION
  WHEN OTHERS THEN
    IF v_cursor_open THEN
      CLOSE c_claim;
    END IF;
    ROLLBACK;
    RAISE;
END;
/

ワーカー停止に備え、一定時間を超えたCLAIMED行をREADYへ戻す監視処理も必要です。確保後の実処理は冪等にし、同じjob_idが再投入されても結果が壊れないようにします。分離レベルや再実行時の見え方は、トランザクション分離レベル別の一貫性テストも参考になります。

FOR UPDATEカーソルを開いたままCOMMITしない

FOR UPDATEカーソルを開いた状態でCOMMITするとロックが解放され、その後のFETCHでORA-01002が発生します。複数チャンクを処理する場合は、チャンクごとにカーソルを開き、取得後に閉じてからCOMMITする構造にします。

PIPELINED関数はBULK処理の代用品ではない

PIPELINED関数は、結果を呼出し元へ順次返し、下流SQLと組み合わせるための仕組みです。行単位のPIPE ROWを使うため、単純にBULK COLLECTより速いわけではありません。中間結果全体を返却前に保持したくない場合や、SQLから表として参照したい場合に選びます。

pipelined-function-purpose.sql
CREATE TYPE order_amount_obj AS OBJECT(
  order_id NUMBER,
  amount   NUMBER
);
/
CREATE TYPE order_amount_tab AS TABLE OF order_amount_obj;
/

CREATE OR REPLACE FUNCTION stream_large_orders
  RETURN order_amount_tab PIPELINED
IS
BEGIN
  FOR r IN (
    SELECT order_id, amount
      FROM app.orders
     WHERE status = 'NEW'
       AND amount >= 1000
     ORDER BY order_id
  ) LOOP
    PIPE ROW(order_amount_obj(r.order_id, r.amount));
  END LOOP;
  RETURN;
END;
/

本番投入前の確認項目

  • カーソルFORループ版とBULK版を同じデータ・同じセッション条件で複数回測定した
  • ループ内DMLを集合SQLまたはFORALLへ変更できないか確認した
  • LIMIT変更時の経過時間とPGA最大値を記録した
  • SAVE EXCEPTIONS後に成功分を残すか全体を戻すか決めた
  • チャンクコミット時のチェックポイントと対象範囲を固定した
  • FOR UPDATEカーソルを閉じてからCOMMITしている
  • ワーカー停止後のCLAIMED行を回収できる
  • 同じ業務キーを再実行しても結果が重複しない

まとめ

カーソルFORループは、単純な1回走査に適した読みやすい構文です。見つけたらすぐBULK化するのではなく、ループ内のSQL呼び出し回数と対象件数を測定し、集合SQL、FORALL、BULK COLLECT LIMITの順に改善余地を検討します。

BULK化では速度だけでなく、PGA、エラー時の原子性、再開点、ロック時間を設計する必要があります。並列処理ではSKIP LOCKEDで短時間に対象を確保し、カーソルを閉じてCOMMITしてから実処理へ進みます。実測と再実行設計をセットにすることで、読みやすさを失わずに安全なスループット改善ができます。