【PL/SQL】バルク処理完全ガイド|BULK COLLECT+FORALLで10〜100倍高速化・LIMIT分割・3構文・RETURNING・実務10パターン

【PL/SQL】バルク処理で高速化!FORALLとBULK COLLECTの使い方 PL/SQL

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..NINDICES OFVALUES OF)、RETURNING BULK COLLECTSQL%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..NINDICES OFVALUES OF
  • FORALL 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+FORALLの最小テンプレ
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;
/
大量データ向け:LIMIT分割版
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点を押さえれば実務のバルク処理で事故りません。

なぜバルク処理は速いのか:コンテキストスイッチの本質

1行ずつDMLのコスト
-- ❌ 遅い: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という本質的な違いがあります。同様にSELECTBULK COLLECT INTO1回のSQL呼び出しで全行取得します。

実測ベンチマーク(参考値)

件数 1行ずつFOR Loop BULK COLLECT+FORALL 速度差
1,000行 約 1〜2秒 約 0.05秒 20〜40倍
10,000行 約 10〜20秒 約 0.3秒 30〜70倍
100,000行 約 2〜5分 約 3〜5秒 40〜100倍
1,000,000行 約 30分〜 約 30〜60秒 30〜60倍

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:メモリ制御と分割処理

LIMIT無しの罠
-- ❌ 危険:1億件を全件ロード
SELECT id BULK COLLECT INTO v_ids FROM huge_table;
-- PGAメモリ数GB消費 → ORA-04030(out of process memory)
-- Process Crashでセッション切断
LIMIT付きカーソルFETCH
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適正値の決め方

1行のサイズ 推奨LIMIT 1バッチのメモリ
100B(ID+ステータス程度) 10,000 約 1MB
1KB(通常のRECORD) 5,000 約 5MB
10KB(CLOB含む) 500 約 5MB
100KB+(大きなLOB) 100 約 10MB

迷ったら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;
UPDATE一括
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;
DELETE一括
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で処理結果を取得

DML結果を配列で受け取る
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;
UPDATE+RETURNING
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は高速化されています。

暗黙BULK COLLECTの動作
-- 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文 BULK COLLECT+FORALL
データソース テーブル/SELECT コレクション(PL/SQL変数)
UPSERT(存在時UPDATE/無い時INSERT) ◎ 標準サポート △ 個別DMLを複数書く必要
PL/SQLロジック加工 △ CASE式等で限定的 ◎ 自由(IF/ループ/関数)
性能(テーブル→テーブル) ◎ 最速 △ コレクション経由で遅い
MERGE文の例(UPSERT)
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 … SELECTBULK 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必須。詳細参照。

よくある質問

Qバルク処理で具体的に何倍速くなる?
A一般的に10〜100倍。1件あたりのDMLが軽い(主キー指定のUPDATE等)ほど倍率が大きく、重い処理(複雑な更新ロジック含む)ほど倍率は小さくなります。1000行→20倍、10万行→50倍、100万行→60倍程度が実測の目安。詳細は本記事「実測ベンチマーク」セクション参照。
QLIMITの適正値は?
A1000〜10000が実用範囲。1行1KB想定なら5000、10KBなら500が目安。PGA使用量を見ながら調整する必要があります。迷ったら1000から始めるのが最も安全。
Q1..NINDICES OFの違いは?
A1..N連続した密配列用(BULK COLLECT直後なら常にこれ)。INDICES OF歯抜けのある連想配列用。v_coll.DELETE(5)で要素削除した後は1..Nが使えないのでINDICES OFに切り替えます。
QCursor FOR Loopを使うなら明示BULK COLLECTは不要?
A大半の場合は不要。10g以降のCursor FOR Loopは内部で暗黙BULK COLLECT 100件で動くため「SELECTしてループ」だけなら同等の性能。明示BULKが必要なのは①FORALL でDML、②LIMIT調整、③RETURNING BULK COLLECT、④他プロシージャに配列を渡す、のいずれか。
QSQL%BULK_EXCEPTIONSSQL%BULK_ROWCOUNTの違いは?
ASQL%BULK_EXCEPTIONS(i)FORALL内でエラー発生したイテレーションのインデックスとORAコードを保持。SQL%BULK_ROWCOUNT(i)各イテレーションで更新/削除した行数を保持。エラー収集=前者、行数集計=後者、と使い分けます。
QBULK COLLECT INTO は NO_DATA_FOUND を投げる?
A投げません。0件の場合はv_coll.COUNT = 0になるだけ。通常のSELECT INTOは0件でNO_DATA_FOUND例外ですが、BULK COLLECT は「0件のコレクションを返す」動作なのでループ前にIF v_coll.COUNT = 0 THEN RETURN; END IF;で空チェックを。
QRETURNINGは UPDATE/DELETE 前の値?後の値?
AUPDATE後の新しい値/DELETE時は削除された行の値を返します。UPDATEで変更前の値を取りたい場合は、UPDATE ... RETURNING old_col, new_colでOLD.列とNEW.列を別名で取得、またはトリガー内で:OLDを使います。
QFORALL中にCOMMITしてもいい?
AFORALL内のDML直後にCOMMITはNG(FORALLは1つのDMLに対する反復制御なので内部にCOMMITは書けない)。ただしLIMIT分割バッチ内でFORALL→COMMIT→次のFETCHというパターンは正しい設計(本記事「LIMIT付きカーソルFETCH」参照)。
QMERGE文とBULK COLLECT+FORALLどちらを使う?
Aテーブル→テーブルのUPSERTはMERGE一択(最速)。外部入力の配列データを使う場合はBULK COLLECT+FORALL加工ロジックが複雑ならPL/SQL+FORALLで柔軟に書く方が保守性も高い。MERGE詳細は【PL/SQL】MERGE文の活用法
QFORALLで空コレクションを渡したらどうなる?
A何も実行されず正常終了(10gR2以降)。10gR1以前ではORA-06533例外が発生していましたが現在は問題なし。念のためIF v_coll.COUNT > 0 THEN FORALL ... END IF;でガードを入れると意図が明確になります。

関連記事

まとめ

  • バルク処理の本質はSQL/PL/SQLエンジン間のコンテキストスイッチ削減
  • 1行ずつDML vs バルクで10〜100倍の速度差
  • BULK COLLECT INTOでSELECT結果を一括取得(%ROWTYPE/RECORD/複数列コレクション対応)
  • 大量データはBULK COLLECT ... LIMIT 1000〜10000で分割+COMMITも分割
  • FORALL 3構文:1..NINDICES OFVALUES OF
  • FORALL SAVE EXCEPTIONSSQL%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活用法と組み合わせて活用してください。