【Oracle】BULK COLLECT / FORALL 完全ガイド|コンテキストスイッチ削減・大量データ高速処理・SAVE EXCEPTIONS まで解説

PL/SQL でループ内に INSERT や UPDATE を書くと、1行ごとに PL/SQL エンジンと SQL エンジンの間でコンテキストスイッチが発生します。大量のデータを処理する場合、このコンテキストスイッチが積み重なって深刻なパフォーマンス問題になります。

BULK COLLECTFORALL を使うと、複数行のデータを一括で 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 プロファイラー完全ガイドも活用してください。