カーソルFORループを見つけるたびに、機械的に BULK COLLECT と FORALLへ変更する必要はありません。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へ戻さずデータベース内で処理を完結できます。
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;
/
前行との比較や累積値も、LAG、LEAD、SUM ... OVERなどの分析関数で表現できる場合があります。PL/SQLへ移す前に、SQLで処理できる範囲を広げることが最も効果的な最適化です。
カーソルFORループとBULK処理を同じ条件で実測する
BULK化の効果は、対象件数、行幅、ループ内処理、キャッシュ状態で変わります。候補コードを同じセッション・同じデータで複数回実行し、初回のウォームアップを除いて比較します。次の例は読み取り処理の比較用で、どちらが必ず速いと決めるものではありません。
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へ渡せます。
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の回数が増えるため、段階的に変更して測定します。
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すれば成功分も取り消されます。成功分を確定し失敗分だけ再試行するのか、チャンク全体を取り消すのかを先に決めます。
次の例は、成功した更新を残し、失敗した入力だけを再試行テーブルへ登録する部分成功方式です。
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だけでは、再実行時に同じチャンク境界へ戻れる保証はありません。処理対象テーブルへ状態を持たせるか、別の実行管理表へ最後に完了した安定キーを記録します。
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 FIRSTとFOR UPDATEはOracle 19cで併用できません。また、WHERE CURRENT OFは直前にFETCHした名前付きFOR UPDATEカーソルだけを参照でき、FORALL内でサブクエリの代わりには使えません。
並列ワーカーでは、FOR UPDATE SKIP LOCKEDカーソルから一定件数をBULK FETCHし、カーソルを閉じてから取得した主キーを状態更新します。確保をCOMMITした後に重い実処理を行えば、長時間ロックを避けられます。
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から表として参照したい場合に選びます。
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してから実処理へ進みます。実測と再実行設計をセットにすることで、読みやすさを失わずに安全なスループット改善ができます。

