PL/SQL でループ内に INSERT や UPDATE を書くと、1行ごとに PL/SQL エンジンと SQL エンジンの間でコンテキストスイッチが発生します。大量のデータを処理する場合、このコンテキストスイッチが積み重なって深刻なパフォーマンス問題になります。
BULK COLLECT と FORALL を使うと、複数行のデータを一括で SQL エンジンに渡すことでコンテキストスイッチを大幅に減らせます。10万行の UPDATE を通常のループで処理していたものが FORALL を使うと10〜100倍速くなるケースもあります。
この記事でわかること
- コンテキストスイッチがパフォーマンスに与える影響
- BULK COLLECT INTO でデータをコレクションに一括取得する方法
- LIMIT 句でメモリを節約しながら大量データを処理する方法
- FORALL で大量 DML を一括実行する方法
- SAVE EXCEPTIONS で一部エラーがあっても処理を継続する方法
- SQL%BULK_ROWCOUNT と SQL%BULK_EXCEPTIONS で結果を確認する方法
コンテキストスイッチがパフォーマンスに与える影響
NG: ループ内の DML(コンテキストスイッチが多発する)
-- NG: 1行ごとに SQL エンジンと PL/SQL エンジンの間でコンテキストスイッチが発生する
-- 100,000行の場合、100,000回のコンテキストスイッチ → 深刻な性能問題
DECLARE
CURSOR c_orders IS
SELECT order_id, amount FROM orders WHERE status = 'PENDING';
BEGIN
FOR rec IN c_orders LOOP
UPDATE orders
SET status = 'PROCESSED', updated_at = SYSDATE
WHERE order_id = rec.order_id; -- 1行ごとに SQL エンジンに制御を渡す
END LOOP;
COMMIT;
END;
/
-- 問題点: 100,000行なら 100,000回の SQL 実行 = 100,000回のコンテキストスイッチ
BULK COLLECT INTO で一括取得する
BULK COLLECT の基本的な使い方
-- BULK COLLECT: SELECT 結果をコレクション(配列)に一括取得する
DECLARE
TYPE order_id_t IS TABLE OF orders.order_id%TYPE;
TYPE amount_t IS TABLE OF orders.amount%TYPE;
v_ids order_id_t;
v_amounts amount_t;
BEGIN
-- BULK COLLECT INTO: SELECT 全件を一括でコレクションに格納する
SELECT order_id, amount
BULK COLLECT INTO v_ids, v_amounts
FROM orders
WHERE status = 'PENDING';
-- v_ids.COUNT でコレクションの件数を確認できる
DBMS_OUTPUT.PUT_LINE('件数: ' || v_ids.COUNT);
-- コレクションを FORALL で一括 UPDATE する(次のセクション参照)
END;
/
-- %ROWTYPE を使ってレコード型のコレクションで一括取得する
DECLARE
TYPE order_rec_t IS TABLE OF orders%ROWTYPE;
v_orders order_rec_t;
BEGIN
SELECT * BULK COLLECT INTO v_orders
FROM orders WHERE status = 'PENDING';
FOR i IN 1 .. v_orders.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(v_orders(i).order_id || ': ' || v_orders(i).amount);
END LOOP;
END;
/
-- カーソルの FETCH ... BULK COLLECT INTO
DECLARE
CURSOR c IS SELECT order_id FROM orders WHERE status = 'PENDING';
TYPE id_t IS TABLE OF orders.order_id%TYPE;
v_ids id_t;
BEGIN
OPEN c;
FETCH c BULK COLLECT INTO v_ids; -- カーソルの全件を一括取得
CLOSE c;
DBMS_OUTPUT.PUT_LINE('取得件数: ' || v_ids.COUNT);
END;
/
LIMIT 句でメモリを節約しながら処理する
-- LIMIT 句: 一度に取得する行数を制限する(大量データでのメモリ節約)
-- BULK COLLECT INTO ... LIMIT N → N行ずつ取得してループ処理する
DECLARE
CURSOR c IS SELECT order_id, amount FROM orders WHERE status = 'PENDING';
TYPE id_t IS TABLE OF orders.order_id%TYPE;
TYPE amt_t IS TABLE OF orders.amount%TYPE;
v_ids id_t;
v_amts amt_t;
c_limit CONSTANT PLS_INTEGER := 1000; -- 1回に処理する行数
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO v_ids, v_amts LIMIT c_limit;
EXIT WHEN v_ids.COUNT = 0;
-- FORALL で一括 UPDATE(後述)
FORALL i IN 1 .. v_ids.COUNT
UPDATE orders
SET status = 'PROCESSED', updated_at = SYSDATE
WHERE order_id = v_ids(i);
COMMIT; -- 1000件ごとにコミット(UNDO 肥大化の防止)
EXIT WHEN v_ids.COUNT < c_limit; -- 最後のバッチ(LIMIT 未満)で終了
END LOOP;
CLOSE c;
END;
/
-- LIMIT のサイズ目安: 100〜10,000。大きすぎるとメモリを消費、小さすぎると効果が減る
-- 一般的に 500〜2,000 がバランスが取れた値
FORALL で大量 DML を一括実行する
FORALL の基本的な使い方
-- FORALL: コレクションの各要素に対して DML を一括実行する
-- PL/SQL ループ → SQL エンジン への移送を1回にまとめる
DECLARE
TYPE id_t IS TABLE OF orders.order_id%TYPE;
TYPE status_t IS TABLE OF orders.status%TYPE;
v_ids id_t;
v_status status_t;
BEGIN
-- 対象データを BULK COLLECT で取得
SELECT order_id, 'PROCESSED'
BULK COLLECT INTO v_ids, v_status
FROM orders
WHERE status = 'PENDING' AND order_date < SYSDATE - 7;
-- FORALL で一括 UPDATE(1回の SQL 実行で全件処理)
FORALL i IN 1 .. v_ids.COUNT
UPDATE orders
SET status = v_status(i), updated_at = SYSDATE
WHERE order_id = v_ids(i);
-- 影響行数の確認: SQL%ROWCOUNT は FORALL 全体の合計影響行数
DBMS_OUTPUT.PUT_LINE('更新件数(合計): ' || SQL%ROWCOUNT);
COMMIT;
END;
/
-- FORALL で INSERT する
DECLARE
TYPE id_t IS TABLE OF orders.order_id%TYPE;
v_ids id_t := id_t(1001, 1002, 1003); -- 直接初期化する
BEGIN
FORALL i IN 1 .. v_ids.COUNT
INSERT INTO order_archive (order_id, archived_at)
VALUES (v_ids(i), SYSDATE);
COMMIT;
END;
/
-- FORALL で DELETE する
DECLARE
TYPE id_t IS TABLE OF orders.order_id%TYPE;
v_ids id_t;
BEGIN
SELECT order_id BULK COLLECT INTO v_ids
FROM orders WHERE status = 'CANCELLED' AND order_date < SYSDATE - 365;
FORALL i IN 1 .. v_ids.COUNT
DELETE FROM orders WHERE order_id = v_ids(i);
DBMS_OUTPUT.PUT_LINE('削除件数: ' || SQL%ROWCOUNT);
COMMIT;
END;
/
SQL%BULK_ROWCOUNT で各行の影響行数を確認する
-- SQL%BULK_ROWCOUNT(i): FORALL の i 番目のバインド値が影響した行数
DECLARE
TYPE id_t IS TABLE OF orders.order_id%TYPE;
v_ids id_t;
v_updated NUMBER := 0;
BEGIN
SELECT order_id BULK COLLECT INTO v_ids
FROM orders WHERE status = 'PENDING';
FORALL i IN 1 .. v_ids.COUNT
UPDATE orders SET status = 'PROCESSED' WHERE order_id = v_ids(i);
-- 各バインド値の影響行数を確認する
FOR i IN 1 .. v_ids.COUNT LOOP
v_updated := v_updated + SQL%BULK_ROWCOUNT(i);
IF SQL%BULK_ROWCOUNT(i) = 0 THEN
DBMS_OUTPUT.PUT_LINE('order_id=' || v_ids(i) || ' は更新されなかった(存在しない?)');
END IF;
END LOOP;
DBMS_OUTPUT.PUT_LINE('合計更新件数: ' || v_updated);
COMMIT;
END;
/
SAVE EXCEPTIONS で一部エラーがあっても処理を継続する
通常の FORALL は1件でもエラーが発生すると処理全体が停止します。SAVE EXCEPTIONS を使うと、エラーがあった行をスキップして残りの行の処理を続けられます。エラー情報は SQL%BULK_EXCEPTIONS で確認できます。
SAVE EXCEPTIONS でエラーをスキップして処理を継続する
DECLARE
TYPE id_t IS TABLE OF orders.order_id%TYPE;
v_ids id_t;
-- SAVE EXCEPTIONS 用の例外宣言
bulk_errors EXCEPTION;
PRAGMA EXCEPTION_INIT(bulk_errors, -24381); -- ORA-24381: error(s) in array DML
BEGIN
SELECT order_id BULK COLLECT INTO v_ids
FROM orders WHERE status = 'PENDING';
-- SAVE EXCEPTIONS: エラーが発生してもスキップして続行する
FORALL i IN 1 .. v_ids.COUNT SAVE EXCEPTIONS
UPDATE orders
SET status = 'PROCESSED'
WHERE order_id = v_ids(i);
COMMIT;
EXCEPTION
WHEN bulk_errors THEN
-- SQL%BULK_EXCEPTIONS.COUNT: エラーが発生した件数
DBMS_OUTPUT.PUT_LINE('エラー件数: ' || SQL%BULK_EXCEPTIONS.COUNT);
FOR i IN 1 .. SQL%BULK_EXCEPTIONS.COUNT LOOP
-- SQL%BULK_EXCEPTIONS(i).ERROR_INDEX: 何番目のバインド値でエラーが発生したか
-- SQL%BULK_EXCEPTIONS(i).ERROR_CODE: Oracle のエラーコード(ORA-番号から 20000 引いた値)
DBMS_OUTPUT.PUT_LINE(
'エラー位置: ' || SQL%BULK_EXCEPTIONS(i).ERROR_INDEX ||
', order_id: ' || v_ids(SQL%BULK_EXCEPTIONS(i).ERROR_INDEX) ||
', エラーコード: ' || SQL%BULK_EXCEPTIONS(i).ERROR_CODE
);
END LOOP;
-- エラーがあっても正常に処理できた行はコミットする
COMMIT;
END;
/
BULK COLLECT + FORALL のパフォーマンス効果
| 処理方法 | コンテキストスイッチ(100,000行) | 目安の処理時間(参考) |
|---|---|---|
| FOR ループ内で1行ずつ UPDATE | 100,000回 | 遅い(数十秒〜数分) |
| BULK COLLECT + FORALL(全件一括) | 1〜数回 | 速い(秒単位) |
| BULK COLLECT + FORALL(LIMIT 1000) | 100回程度 | 速い(秒単位)かつメモリ節約 |
| 直接 UPDATE(PL/SQL を使わない) | 0回(最速) | 最速 |
最速は直接 SQL UPDATE
PL/SQL を使わずに直接 UPDATE orders SET status='PROCESSED' WHERE ... を実行する方が最速です。BULK COLLECT / FORALL が威力を発揮するのは、SQL では表現できない複雑な条件分岐や計算を含む大量 DML 処理のときです。単純な UPDATE は PL/SQL を使わず SQL で直接実行することを検討してください。
まとめ
- コンテキストスイッチ:PL/SQL ループ内の DML は1行ごとにコンテキストスイッチが発生する。BULK COLLECT + FORALL でまとめて処理することで大幅に削減できる
- BULK COLLECT:SELECT 結果をコレクションに一括取得する。LIMIT 句で処理件数を制限してメモリ消費を抑える。LIMIT の目安は 500〜2,000
- FORALL:コレクションのデータを一括で DML 実行する。INSERT / UPDATE / DELETE すべてに使える。SQL%BULK_ROWCOUNT(i) で各行の影響行数を確認できる
- SAVE EXCEPTIONS:FORALL 中のエラーをスキップして処理を継続する。SQL%BULK_EXCEPTIONS で何番目の行でどのエラーが発生したかを確認できる。PRAGMA EXCEPTION_INIT(err, -24381) の宣言が必要
- 単純な UPDATE は SQL で直接実行する方が速い:複雑な計算・分岐を伴う大量処理のときだけ BULK COLLECT / FORALL を使う
PL/SQL コレクション(TABLE・VARRAY・Associative Array)の詳細は Oracle PL/SQL コレクション完全ガイドを参照してください。PL/SQL のパフォーマンスプロファイリングには Oracle PL/SQL プロファイラー完全ガイドも活用してください。