PL/SQLで「1万件のUPDATEが5分かかる」「10万件の集計バッチが夜間枠に収まらない」——こうした性能問題の9割は1行ずつDML(Row-by-Row)をバルク処理(BULK COLLECT+FORALL)に書き換えるだけで10〜100倍高速化します。Oracle開発者が最初に身につけるべき性能チューニング技術です。
性能差の本質はPL/SQLエンジンとSQLエンジン間のコンテキストスイッチ。1行ずつUPDATEすると1万件で1万回の切替が発生しますが、FORALLなら1回の切替で1万件処理できます。これを知っているかどうかだけで、開発者としての戦闘力が大きく変わります。
入門記事の多くは構文例で終わりますが、本記事では実務で差がつくBULK COLLECT LIMITでメモリ制御、RECORD型での複数列取得、FORALL 3構文(1..N/INDICES OF/VALUES OF)、RETURNING BULK COLLECT、SQL%BULK_ROWCOUNT属性、Cursor FOR Loopの暗黙BULK(10g+)、実測ベンチマーク、MERGE文との比較まで徹底解説します。関連は【PL/SQL】FORALLとSAVE EXCEPTIONSでバルクDMLのエラーを個別処理する方法/【PL/SQL】ループ処理完全ガイド/【PL/SQL】カーソル完全ガイドも併読推奨。
この記事で学べること
- バルク処理が速い理由:コンテキストスイッチ削減の本質
BULK COLLECTの基本とLIMIT 1000でのメモリ制御- 複数列・RECORD型/%ROWTYPEでのBULK COLLECT
FORALLの3構文:1..N/INDICES OF/VALUES OFFORALL SAVE EXCEPTIONSで失敗行だけスキップRETURNING BULK COLLECTでDML結果を取得SQL%BULK_ROWCOUNTで各イテレーションの影響行数取得- Cursor FOR Loopの暗黙BULK COLLECT(10g+)で自動最適化
- 実測ベンチマーク:1行ずつ vs バルクで何倍速くなるか
- MERGE文 vs FORALLの選定
- 実務10パターン(移行/集計/クリーンアップ等)
- アンチパターン7選(LIMIT無しBULK/巨大コレクション等)
- 30秒クイックリファレンス:バルク処理の型
- なぜバルク処理は速いのか:コンテキストスイッチの本質
- BULK COLLECT:SELECT結果を一括取得
- BULK COLLECT LIMIT:メモリ制御と分割処理
- FORALL:DMLを一括実行
- FORALLの3構文:1..N/INDICES OF/VALUES OF
- FORALL SAVE EXCEPTIONS:失敗行だけスキップ
- RETURNING BULK COLLECTで処理結果を取得
- SQL%BULK_ROWCOUNT:各イテレーションの影響行数
- Cursor FOR Loopの暗黙BULK COLLECT(Oracle 10g+)
- MERGE文 vs FORALL:使い分け
- 実務10パターン
- アンチパターン7選
- よくある質問
- 関連記事
- まとめ
30秒クイックリファレンス:バルク処理の型
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE;
v_ids t_ids;
BEGIN
-- ① SELECTで一括取得
SELECT id BULK COLLECT INTO v_ids
FROM users WHERE status = 'pending';
-- ② FORALLで一括DML
FORALL i IN 1..v_ids.COUNT
UPDATE users SET status = 'active' WHERE id = v_ids(i);
COMMIT;
END;
/
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE;
v_ids t_ids;
CURSOR cur IS SELECT id FROM users WHERE status = 'pending';
BEGIN
OPEN cur;
LOOP
FETCH cur BULK COLLECT INTO v_ids LIMIT 1000;
EXIT WHEN v_ids.COUNT = 0;
FORALL i IN 1..v_ids.COUNT
UPDATE users SET status = 'active' WHERE id = v_ids(i);
COMMIT; -- 1000件ごとにCOMMIT(undo縮小)
END LOOP;
CLOSE cur;
END;
3つの黄金律:①LIMIT 1000〜10000で分割(メモリ保護)、②コミットも分割(undo領域枯渇防止)、③事前にv_ids.COUNTで空チェック(FORALL空で例外)。この3点を押さえれば実務のバルク処理で事故りません。
なぜバルク処理は速いのか:コンテキストスイッチの本質
-- ❌ 遅い:Cursor FOR Loop内で1行ずつUPDATE FOR rec IN (SELECT id FROM users WHERE status = 'pending') LOOP UPDATE users SET status = 'active' WHERE id = rec.id; -- ↑ PL/SQL→SQLの切替(コンテキストスイッチ) END LOOP; -- 1万件なら1万回のコンテキストスイッチ -- 1回のスイッチは数μsだが、1万回では数秒〜数十秒のオーバーヘッド
-- ⭕ 速い:FORALLで一括 FORALL i IN 1..v_ids.COUNT UPDATE users SET status = 'active' WHERE id = v_ids(i); -- コンテキストスイッチは1回のみ -- 1万件のDMLがSQLエンジン側で一括処理される
PL/SQLエンジンとSQLエンジンの2層構造
OracleはPL/SQLエンジン(制御フロー)とSQLエンジン(データ操作)の2つのエンジンを持ち、DMLを実行するたびに切り替わります。FOR LOOP+1行DMLはN回のスイッチ、FORALLなら1回のスイッチで複数DMLという本質的な違いがあります。同様にSELECTのBULK COLLECT INTOは1回のSQL呼び出しで全行取得します。
実測ベンチマーク(参考値)
BULK COLLECT:SELECT結果を一括取得
単一列
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE;
v_ids t_ids;
BEGIN
SELECT id BULK COLLECT INTO v_ids
FROM users WHERE status = 'active';
DBMS_OUTPUT.PUT_LINE('取得件数: ' || v_ids.COUNT);
-- 普通のFOR LOOPで走査
FOR i IN 1..v_ids.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(v_ids(i));
END LOOP;
END;
複数列(個別コレクション)
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE;
TYPE t_emails IS TABLE OF users.email%TYPE;
v_ids t_ids;
v_emails t_emails;
BEGIN
SELECT id, email BULK COLLECT INTO v_ids, v_emails
FROM users WHERE status = 'active';
FOR i IN 1..v_ids.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(v_ids(i) || ': ' || v_emails(i));
END LOOP;
END;
RECORD型で複数列を1つのコレクションに
DECLARE
TYPE t_user_rec IS RECORD (
id users.id%TYPE,
email users.email%TYPE,
status users.status%TYPE
);
TYPE t_users IS TABLE OF t_user_rec;
v_users t_users;
BEGIN
SELECT id, email, status BULK COLLECT INTO v_users
FROM users WHERE status = 'active';
FOR i IN 1..v_users.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(v_users(i).id || ': ' || v_users(i).email);
END LOOP;
END;
%ROWTYPE(最も簡潔)
DECLARE
TYPE t_users IS TABLE OF users%ROWTYPE;
v_users t_users;
BEGIN
SELECT * BULK COLLECT INTO v_users
FROM users WHERE status = 'active';
-- 全列にアクセス可能
FOR i IN 1..v_users.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(v_users(i).id || ': ' || v_users(i).email);
END LOOP;
END;
選び方
- 単一列の値だけ必要→単純なTABLE OF 型
- 2〜3列の組み合わせ→個別コレクション(メモリ少)
- 4列以上・構造化アクセス重視→RECORD型
- テーブル全列+将来の列追加に追従→%ROWTYPE
BULK COLLECT LIMIT:メモリ制御と分割処理
-- ❌ 危険:1億件を全件ロード SELECT id BULK COLLECT INTO v_ids FROM huge_table; -- PGAメモリ数GB消費 → ORA-04030(out of process memory) -- Process Crashでセッション切断
DECLARE
TYPE t_ids IS TABLE OF huge_table.id%TYPE;
v_ids t_ids;
CURSOR cur IS SELECT id FROM huge_table;
BEGIN
OPEN cur;
LOOP
FETCH cur BULK COLLECT INTO v_ids LIMIT 5000;
EXIT WHEN v_ids.COUNT = 0;
-- 1バッチ分の処理
FORALL i IN 1..v_ids.COUNT
UPDATE huge_table SET processed = 1 WHERE id = v_ids(i);
COMMIT; -- バッチごとにCOMMIT
END LOOP;
CLOSE cur;
END;
LIMIT適正値の決め方
迷ったらLIMIT 1000から始める。ほとんどのユースケースでバランスが良く、パフォーマンス/メモリの両方で安全。遅い場合は5000、大きなレコードなら500に調整。PGA実測はSELECT value FROM v$mystat WHERE statistic# = (SELECT statistic# FROM v$statname WHERE name = 'session pga memory')。
FORALL:DMLを一括実行
基本構文
-- INSERT一括
DECLARE
TYPE t_emails IS TABLE OF VARCHAR2(200);
v_emails t_emails := t_emails('a@x.com', 'b@x.com', 'c@x.com');
BEGIN
FORALL i IN 1..v_emails.COUNT
INSERT INTO users(email, created_at)
VALUES (v_emails(i), SYSTIMESTAMP);
DBMS_OUTPUT.PUT_LINE(SQL%ROWCOUNT || '件INSERT');
END;
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE;
v_ids t_ids := t_ids(1, 2, 3, 4, 5);
BEGIN
FORALL i IN 1..v_ids.COUNT
UPDATE users SET status = 'active' WHERE id = v_ids(i);
END;
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE;
v_ids t_ids := t_ids(99, 100, 101);
BEGIN
FORALL i IN 1..v_ids.COUNT
DELETE FROM users WHERE id = v_ids(i);
END;
FORALLの制約:①直後の文はDML(INSERT/UPDATE/DELETE/MERGE)1つだけ、②複数DMLを書きたいなら複数のFORALLブロックに分ける、③ループインデックス(i)はDML内の参照専用(代入・計算NG)、④空コレクションに対するFORALLは何もしない(10gR2以降は例外も出ない)。
FORALLの3構文:1..N/INDICES OF/VALUES OF
①1..N:密配列(最も一般的)
FORALL i IN 1..v_ids.COUNT UPDATE users SET status = ... WHERE id = v_ids(i); -- 連続したインデックス(1, 2, 3, ...)を処理 -- 歯抜けがあるとエラー
②INDICES OF:疎配列(歯抜け対応)
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE INDEX BY PLS_INTEGER;
v_ids t_ids;
BEGIN
v_ids(10) := 100;
v_ids(50) := 200;
v_ids(300) := 300;
-- 10, 50, 300 のインデックスだけ処理
FORALL i IN INDICES OF v_ids
UPDATE users SET status = 'processed' WHERE id = v_ids(i);
-- 範囲指定も可能
FORALL i IN INDICES OF v_ids BETWEEN 10 AND 100
UPDATE users SET status = 'batch1' WHERE id = v_ids(i);
END;
③VALUES OF:別コレクションのインデックスを参照
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE INDEX BY PLS_INTEGER;
TYPE t_idxs IS TABLE OF PLS_INTEGER;
v_ids t_ids;
v_idxs t_idxs := t_idxs(3, 7, 11, 20); -- 処理したいインデックス番号
BEGIN
v_ids(3) := 300;
v_ids(7) := 700;
v_ids(11) := 1100;
v_ids(20) := 2000;
v_ids(99) := 9900; -- この行は処理されない
-- v_idxs の値(3, 7, 11, 20)をインデックスとして v_ids を参照
FORALL i IN VALUES OF v_idxs
UPDATE users SET status = 'filtered' WHERE id = v_ids(i);
END;
使い分け
1..N:BULK COLLECT直後の典型ケース(最頻出)INDICES OF:DELETE後に歯抜けがある連想配列VALUES OF:条件フィルタした行だけを処理(別コレクションにインデックスを格納)
FORALL SAVE EXCEPTIONS:失敗行だけスキップ
DECLARE
TYPE t_emails IS TABLE OF VARCHAR2(200);
v_emails t_emails := t_emails('a@x.com', 'b@x.com', NULL, 'd@x.com');
-- ↑NULL → NOT NULL制約違反
e_bulk_error EXCEPTION;
PRAGMA EXCEPTION_INIT(e_bulk_error, -24381);
BEGIN
FORALL i IN 1..v_emails.COUNT SAVE EXCEPTIONS
INSERT INTO users(email) VALUES (v_emails(i));
COMMIT;
EXCEPTION
WHEN e_bulk_error THEN
DBMS_OUTPUT.PUT_LINE('エラー件数: ' || SQL%BULK_EXCEPTIONS.COUNT);
FOR j IN 1..SQL%BULK_EXCEPTIONS.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(
'iteration=' || SQL%BULK_EXCEPTIONS(j).ERROR_INDEX ||
', ORA-' || LPAD(SQL%BULK_EXCEPTIONS(j).ERROR_CODE, 5, '0') ||
': ' || SQLERRM(-SQL%BULK_EXCEPTIONS(j).ERROR_CODE)
);
END LOOP;
COMMIT; -- 成功分は保存
END;
詳細は別記事で
FORALL SAVE EXCEPTIONSの詳細(SQL%BULK_EXCEPTIONS構造、エラー入力データの突合、LIMITと組み合わせたバッチ設計等)は【PL/SQL】FORALLとSAVE EXCEPTIONSでバルクDMLのエラーを個別処理する方法で詳解。
RETURNING BULK COLLECTで処理結果を取得
DECLARE
TYPE t_ids IS TABLE OF orders.id%TYPE;
v_target_ids t_ids;
v_returned_ids t_ids;
BEGIN
-- DELETE対象を事前取得
SELECT id BULK COLLECT INTO v_target_ids
FROM orders WHERE status = 'expired' AND ROWNUM <= 1000;
-- DELETE+削除されたIDをRETURNING
FORALL i IN 1..v_target_ids.COUNT
DELETE FROM orders WHERE id = v_target_ids(i)
RETURNING id BULK COLLECT INTO v_returned_ids;
DBMS_OUTPUT.PUT_LINE('削除完了: ' || v_returned_ids.COUNT || '件');
COMMIT;
END;
DECLARE
TYPE t_ids IS TABLE OF users.id%TYPE;
TYPE t_status IS TABLE OF users.status%TYPE;
v_ids t_ids := t_ids(1, 2, 3, 4);
v_old_statuses t_status;
BEGIN
FORALL i IN 1..v_ids.COUNT
UPDATE users SET status = 'active' WHERE id = v_ids(i)
RETURNING status BULK COLLECT INTO v_old_statuses; -- 変更前の値
-- 注:RETURNINGはUPDATE後の値(ORA-06550に注意、後述)
END;
RETURNINGはUPDATE後の値を返すのがOracleの仕様。変更前の値を保存したい場合は、UPDATE直前にSELECT ... FOR UPDATEで別コレクションに保存するか、トリガー内で:OLDを使う必要があります。
SQL%BULK_ROWCOUNT:各イテレーションの影響行数
DECLARE
TYPE t_depts IS TABLE OF NUMBER;
v_depts t_depts := t_depts(10, 20, 30);
BEGIN
FORALL i IN 1..v_depts.COUNT
UPDATE employees SET salary = salary * 1.1
WHERE department_id = v_depts(i);
-- 通常のSQL%ROWCOUNTは「全FORALLの合計行数」
DBMS_OUTPUT.PUT_LINE('合計更新行数: ' || SQL%ROWCOUNT);
-- SQL%BULK_ROWCOUNT(i)で各iの影響行数
FOR i IN 1..v_depts.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(
'dept=' || v_depts(i) ||
' → 更新行数=' || SQL%BULK_ROWCOUNT(i)
);
END LOOP;
END;
活用シーン
SQL%BULK_ROWCOUNTは「どのコレクション要素でどれだけ更新されたか」を追跡できるため、集計レポート・イテレーション単位の成否判定に有用。SQL%BULK_ROWCOUNT(i) = 0なら「その行は該当なし」を検出できます。
Cursor FOR Loopの暗黙BULK COLLECT(Oracle 10g+)
Oracle 10g Release 1以降、Cursor FOR Loopは内部で自動的にBULK COLLECT 100件するように最適化されました。明示BULK COLLECTを書かなくてもループ内でのSELECTは高速化されています。
-- 10g以降、このシンプルなループは内部でBULK COLLECT 100件で動く FOR rec IN (SELECT id, email FROM users WHERE status = 'active') LOOP -- rec.id, rec.email で参照可能 DBMS_OUTPUT.PUT_LINE(rec.id || ': ' || rec.email); END LOOP; -- 100件ずつ暗黙的にFETCH BULK COLLECT LIMIT 100されている
それでも明示BULK COLLECTが必要なケース:①BULK COLLECT LIMITをカスタマイズしたい(100以外)、②FORALL と組み合わせたい(Cursor FOR Loop内のDMLは暗黙BULK対象外)、③RETURNING BULK COLLECT で結果取得、④コレクションを他のプロシージャに渡したい。単純な「1行ずつ読んで何かする」ならCursor FOR Loopで十分です。
MERGE文 vs FORALL:使い分け
MERGE INTO users u USING ( SELECT 1 AS id, 'a@x.com' AS email FROM dual UNION ALL SELECT 2, 'b@x.com' FROM dual ) src ON (u.id = src.id) WHEN MATCHED THEN UPDATE SET u.email = src.email WHEN NOT MATCHED THEN INSERT (id, email) VALUES (src.id, src.email);
選び方:①テーブル→テーブルのUPSERTはMERGE文一択、②外部から受け取った配列データはBULK COLLECT+FORALL、③複雑な加工+DMLはBULK COLLECT+FORALL+MERGE組み合わせ。MERGE詳細は【PL/SQL】MERGE文の活用法参照。
実務10パターン
①大量UPDATE(LIMIT分割)
DECLARE
TYPE t_ids IS TABLE OF orders.id%TYPE;
v_ids t_ids;
CURSOR cur IS SELECT id FROM orders WHERE status = 'pending';
BEGIN
OPEN cur;
LOOP
FETCH cur BULK COLLECT INTO v_ids LIMIT 5000;
EXIT WHEN v_ids.COUNT = 0;
FORALL i IN 1..v_ids.COUNT
UPDATE orders SET status = 'processing', updated_at = SYSTIMESTAMP
WHERE id = v_ids(i);
COMMIT;
END LOOP;
CLOSE cur;
END;
②大量INSERT(CSV取り込み風)
DECLARE
TYPE t_emails IS TABLE OF VARCHAR2(200);
v_emails t_emails;
BEGIN
-- 外部から取得したデータを配列に
v_emails := get_emails_from_external();
FORALL i IN 1..v_emails.COUNT SAVE EXCEPTIONS
INSERT INTO users(email) VALUES (v_emails(i));
EXCEPTION
WHEN OTHERS THEN
FOR j IN 1..SQL%BULK_EXCEPTIONS.COUNT LOOP
log_error(SQL%BULK_EXCEPTIONS(j).ERROR_INDEX,
SQL%BULK_EXCEPTIONS(j).ERROR_CODE);
END LOOP;
END;
③大量DELETE(アーカイブ)
DECLARE
TYPE t_ids IS TABLE OF logs.id%TYPE;
v_ids t_ids;
CURSOR cur IS SELECT id FROM logs WHERE created_at < SYSDATE - 365;
BEGIN
OPEN cur;
LOOP
FETCH cur BULK COLLECT INTO v_ids LIMIT 10000;
EXIT WHEN v_ids.COUNT = 0;
FORALL i IN 1..v_ids.COUNT
DELETE FROM logs WHERE id = v_ids(i);
COMMIT;
END LOOP;
CLOSE cur;
END;
④集計結果の取り込み
DECLARE
TYPE t_summary IS RECORD (
user_id NUMBER,
order_count NUMBER,
total_amt NUMBER
);
TYPE t_summaries IS TABLE OF t_summary;
v_summaries t_summaries;
BEGIN
SELECT user_id, COUNT(*), SUM(amount)
BULK COLLECT INTO v_summaries
FROM orders
WHERE order_date >= TRUNC(SYSDATE) - 30
GROUP BY user_id;
FORALL i IN 1..v_summaries.COUNT
UPDATE users
SET order_count_30d = v_summaries(i).order_count,
total_amt_30d = v_summaries(i).total_amt
WHERE id = v_summaries(i).user_id;
COMMIT;
END;
⑤INSERT+RETURNING(ID採番取得)
DECLARE
TYPE t_names IS TABLE OF VARCHAR2(100);
TYPE t_ids IS TABLE OF NUMBER;
v_names t_names := t_names('Alice', 'Bob', 'Charlie');
v_ids t_ids;
BEGIN
FORALL i IN 1..v_names.COUNT
INSERT INTO users(id, name)
VALUES (user_seq.NEXTVAL, v_names(i))
RETURNING id BULK COLLECT INTO v_ids;
-- v_ids に新規採番されたIDが入っている
FOR i IN 1..v_ids.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(v_names(i) || ' → id=' || v_ids(i));
END LOOP;
END;
⑥INDICES OFで歯抜け配列
DECLARE
TYPE t_ids IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
v_ids t_ids;
BEGIN
-- 歯抜けで格納
v_ids(10) := 100; v_ids(20) := 200; v_ids(50) := 500;
FORALL i IN INDICES OF v_ids
UPDATE orders SET priority = i WHERE id = v_ids(i);
END;
⑦VALUES OFで条件フィルタ
DECLARE
TYPE t_ids IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
TYPE t_idxs IS TABLE OF PLS_INTEGER;
v_all t_ids;
v_even t_idxs := t_idxs();
BEGIN
FOR i IN 1..100 LOOP
v_all(i) := i * 10;
END LOOP;
-- 偶数インデックスだけ抽出
FOR i IN 1..100 LOOP
IF MOD(i, 2) = 0 THEN
v_even.EXTEND;
v_even(v_even.COUNT) := i;
END IF;
END LOOP;
FORALL i IN VALUES OF v_even
UPDATE t SET col = v_all(i) WHERE id = i;
END;
⑧MERGE風UPSERT(BULK経由)
DECLARE
TYPE t_users IS TABLE OF users%ROWTYPE;
v_users t_users;
BEGIN
v_users := get_users_from_api();
-- 存在チェックなしで一括MERGE
FORALL i IN 1..v_users.COUNT
MERGE INTO users u
USING (SELECT v_users(i).id AS id, v_users(i).email AS email FROM dual) src
ON (u.id = src.id)
WHEN MATCHED THEN UPDATE SET u.email = src.email
WHEN NOT MATCHED THEN INSERT (id, email) VALUES (src.id, src.email);
END;
⑨パーティション単位の処理
DECLARE
TYPE t_ids IS TABLE OF NUMBER;
v_ids t_ids;
BEGIN
-- 特定パーティションのデータだけ取得
SELECT id BULK COLLECT INTO v_ids
FROM orders PARTITION (p2026_01)
WHERE status = 'pending';
FORALL i IN 1..v_ids.COUNT
UPDATE orders SET status = 'processed' WHERE id = v_ids(i);
END;
⑩データ移行(旧テーブル→新テーブル)
DECLARE
TYPE t_records IS TABLE OF old_users%ROWTYPE;
v_records t_records;
CURSOR cur IS SELECT * FROM old_users;
BEGIN
OPEN cur;
LOOP
FETCH cur BULK COLLECT INTO v_records LIMIT 10000;
EXIT WHEN v_records.COUNT = 0;
FORALL i IN 1..v_records.COUNT SAVE EXCEPTIONS
INSERT INTO new_users(id, email, name, created_at)
VALUES (v_records(i).id,
LOWER(v_records(i).email), -- 正規化
UPPER(v_records(i).name),
NVL(v_records(i).created_at, SYSTIMESTAMP));
COMMIT;
END LOOP;
CLOSE cur;
END;
アンチパターン7選
①LIMIT無しのBULK COLLECT。1億件をいきなりSELECT BULK COLLECT INTOで取るとPGA枯渇(ORA-04030)。LIMIT 1000〜10000で必ず分割。
②COMMIT無しのLIMIT分割。全バッチ完了後に一括COMMITするとundo領域が枯渇(ORA-01555 snapshot too old)。バッチごと(LIMIT単位)にCOMMIT。
③FORALL内で変数代入や関数呼び出し。FORALL i IN 1..Nの直後の文は1つのDMLのみ。加工が必要なら事前にコレクションに加工結果を詰めてからFORALL。
④1行SELECTループを残したまま。FOR rec IN (SELECT ...) LOOP INSERT ... END LOOP;は1件ずつDMLでコンテキストスイッチ多発。INSERT … SELECTかBULK COLLECT+FORALLに書き換え。
⑤コレクション操作の過剰なEXTEND。1件ずつv_coll.EXTEND+代入は遅い。BULK COLLECTで一括取得するか、PLS_INTEGERインデックスの連想配列(自動拡張)を使う。
⑥巨大RECORDの配列保持。100列×1万行のRECORDコレクションは数百MB〜GBのPGAを消費。必要な列だけのRECORD/個別コレクションに絞る。
⑦SAVE EXCEPTIONS無しでエラー全停止。FORALL単体だと1件エラーで以降の処理が停止(変更はロールバックされない)。エラー許容が必要な業務はSAVE EXCEPTIONS必須。詳細参照。
よくある質問
1..NとINDICES OFの違いは?1..Nは連続した密配列用(BULK COLLECT直後なら常にこれ)。INDICES OFは歯抜けのある連想配列用。v_coll.DELETE(5)で要素削除した後は1..Nが使えないのでINDICES OFに切り替えます。SQL%BULK_EXCEPTIONSとSQL%BULK_ROWCOUNTの違いは?SQL%BULK_EXCEPTIONS(i)はFORALL内でエラー発生したイテレーションのインデックスとORAコードを保持。SQL%BULK_ROWCOUNT(i)は各イテレーションで更新/削除した行数を保持。エラー収集=前者、行数集計=後者、と使い分けます。v_coll.COUNT = 0になるだけ。通常のSELECT INTOは0件でNO_DATA_FOUND例外ですが、BULK COLLECT は「0件のコレクションを返す」動作なのでループ前にIF v_coll.COUNT = 0 THEN RETURN; END IF;で空チェックを。UPDATE ... RETURNING old_col, new_colでOLD.列とNEW.列を別名で取得、またはトリガー内で:OLDを使います。ORA-06533例外が発生していましたが現在は問題なし。念のためIF v_coll.COUNT > 0 THEN FORALL ... END IF;でガードを入れると意図が明確になります。関連記事
- 【PL/SQL】FORALLとSAVE EXCEPTIONSでバルクDMLのエラーを個別処理する方法 — SAVE EXCEPTIONS詳解
- 【PL/SQL】カーソル完全ガイド — Cursor FOR Loopの暗黙BULK
- 【PL/SQL】ループ処理完全ガイド — ループ全般とバルクの位置付け
- 【PL/SQL】例外処理完全ガイド — バルクエラーハンドリング
- 【PL/SQL】変数・定数完全ガイド — コレクション型の宣言
- 【PL/SQL】基本構文完全ガイド — PL/SQLブロックの土台
- 【PL/SQL】プロシージャ・ファンクション完全ガイド — バルク処理をプロシージャに
- 【PL/SQL】MERGE文の活用法|UPSERT処理を高速かつ安全に実装する方法 — MERGE文の深掘り
- 【PL/SQL】コレクション(配列・ネスト表)の基本と活用例 — コレクション詳解
- 【PL/SQL】パイプライン関数の仕組みと活用術 — PIPELINED関数との組み合わせ
まとめ
- バルク処理の本質はSQL/PL/SQLエンジン間のコンテキストスイッチ削減
- 1行ずつDML vs バルクで10〜100倍の速度差
BULK COLLECT INTOでSELECT結果を一括取得(%ROWTYPE/RECORD/複数列コレクション対応)- 大量データは
BULK COLLECT ... LIMIT 1000〜10000で分割+COMMITも分割 FORALL3構文:1..N/INDICES OF/VALUES OFFORALL SAVE EXCEPTIONS+SQL%BULK_EXCEPTIONSで失敗行だけスキップRETURNING BULK COLLECTでDML結果(新IDや変更後値)を配列で取得SQL%BULK_ROWCOUNT(i)で各イテレーションの影響行数を取得- Cursor FOR Loopは10g+で暗黙BULK COLLECT 100件、単純ループなら十分
- テーブル→テーブルのUPSERTはMERGE文、配列入力ならBULK+FORALL、複雑加工はBULK+FORALL+MERGE
- アンチパターン:LIMIT無し/COMMIT無し/FORALL中の代入/1行SELECTループ残存/過剰EXTEND/巨大RECORD/SAVE EXCEPTIONS無し
PL/SQLの性能チューニングの要はバルク処理です。本記事の基本構文、LIMIT分割、3種のFORALL構文、RETURNING、SAVE EXCEPTIONS、MERGE文との使い分けを押さえれば、1行ずつ処理していた夜間バッチが秒〜分単位に短縮できます。エラー処理はSAVE EXCEPTIONS詳解、ループ全般はループ処理完全ガイド、カーソルはカーソル完全ガイド、MERGE文はMERGE活用法と組み合わせて活用してください。

