【PL/SQL】例外設計と再試行パターン|一時障害・冪等性・SAVEPOINT・DBMS_SCHEDULER

【PL/SQL】大規模システムでの例外設計と再試行パターン PL/SQL

大規模なPL/SQLシステムでは、例外処理の品質がそのまま運用品質になります。WHEN OTHERS THEN NULL で握りつぶす、何でも再試行する、ログだけ残して正常終了する、といった設計は、障害の原因を隠し、二重更新や二重送信を引き起こします。

重要なのは、例外を再試行できる一時障害再試行しても直らない恒久障害に分けることです。そのうえで、トランザクション境界、冪等キー、部分ロールバック、外部API副作用、監査ログ、ジョブ再実行をセットで設計します。PL/SQL例外処理の基本は PL/SQL例外処理完全ガイド、トランザクション全体の設計は PL/SQLトランザクション設計パターン もあわせて確認してください。

この記事で扱うこと

  • 再試行してよい例外・してはいけない例外の分類
  • PRAGMA EXCEPTION_INIT によるORAコードの名前付け
  • 指数バックオフ、ジッター、最大試行回数
  • 冪等キー、外部API副作用、アウトボックス
  • SAVEPOINTDBMS_ERRLOGFORALL SAVE EXCEPTIONS の使い分け
  • DBMS_SCHEDULER によるジョブ再実行と運用ログ
スポンサーリンク

最初に結論:再試行は例外分類と冪等性が前提

再試行は便利ですが、失敗した処理をもう一度流すだけでは危険です。ロック競合や一時的なネットワーク断なら再試行で直る可能性がありますが、入力値不正、権限不足、制約違反、存在しないオブジェクトは再試行しても直りません。さらに、外部送信や請求処理のような副作用がある処理では、同じ処理を2回実行しても壊れない冪等性が必要です。

一時障害ORA-00054ORA-00060ORA-08177、一時的なHTTP失敗など、時間を置くと成功する可能性があるものです。
恒久障害入力不備、制約違反、権限不足、存在しない表、データ型不一致など、同じ入力では何度やっても失敗するものです。
再試行の前提最大回数、待機時間、再実行キー、ログ、ロールバック範囲を決めてから実装します。
再試行しない判断原因がデータ・定義・権限にある場合は、早く失敗させて人間が直せる情報を残します。

例外を名前付きで分類する

PL/SQLでは、代表的なOracleエラーを PRAGMA EXCEPTION_INIT で名前付き例外にできます。SQLCODEを直接比較するより、意図が読みやすくなります。

再試行候補の例外を定義する

retry-exceptions.sql
CREATE OR REPLACE PACKAGE retry_errors AS
  deadlock_ex      EXCEPTION;
  lock_nowait_ex   EXCEPTION;
  serialize_ex     EXCEPTION;
  snapshot_old_ex  EXCEPTION;

  PRAGMA EXCEPTION_INIT(deadlock_ex,     -60);    -- ORA-00060
  PRAGMA EXCEPTION_INIT(lock_nowait_ex,  -54);    -- ORA-00054
  PRAGMA EXCEPTION_INIT(serialize_ex,    -8177);  -- ORA-08177
  PRAGMA EXCEPTION_INIT(snapshot_old_ex, -1555);  -- ORA-01555
END;
/

PRAGMA EXCEPTION_INIT は、Oracleエラー番号に業務上意味のある名前を付けるために使います。何でも名前付きにするのではなく、ハンドリング方針が明確なエラーだけを定義すると読みやすくなります。

再試行判断フロー

例外が起きたら、すぐループで再実行するのではなく、次の順に判断します。

1. 例外の種類を見るロック競合、デッドロック、シリアライズ競合、一時的な外部API失敗かを確認します。
2. 副作用を確認するDB更新だけか、メール送信・API送信・ファイル出力のような外部副作用があるかを確認します。
3. 冪等キーがあるか見る同じリクエストIDで再実行しても二重登録・二重請求にならない設計か確認します。
4. ロールバック範囲を決める全体を戻すのか、SAVEPOINTまで戻すのか、失敗行だけ退避するのかを選びます。
5. 最大回数で打ち切る無限リトライは障害を隠します。最大回数を超えたら失敗としてログと通知を出します。

ロック競合の代表例は ORA-00054の原因と対処、デッドロックは ORA-00060デッドロック完全ガイド、シリアライズ競合は ORA-08177の原因と解決方法 が関連します。

指数バックオフ付き再試行テンプレート

再試行では、すぐに同じ処理を叩き続けるのではなく、待機時間を少しずつ増やします。複数セッションが同時に再試行すると再び衝突するため、ジッターを入れてタイミングをずらします。

DBMS_LOCK.SLEEPで待機する例

retry-with-backoff.sql
DECLARE
  c_max_attempts CONSTANT PLS_INTEGER := 5;
  v_attempt      PLS_INTEGER := 0;
  v_sleep_sec    NUMBER;
BEGIN
  LOOP
    v_attempt := v_attempt + 1;

    BEGIN
      process_payment(p_payment_id => :payment_id);
      COMMIT;
      EXIT;

    EXCEPTION
      WHEN retry_errors.lock_nowait_ex
        OR retry_errors.deadlock_ex
        OR retry_errors.serialize_ex THEN

        ROLLBACK;

        IF v_attempt >= c_max_attempts THEN
          logger_pkg.write_log(
            p_level       => 'ERROR',
            p_message     => 'retry limit exceeded',
            p_process_key => 'payment_id=' || :payment_id,
            p_sql_code    => SQLCODE,
            p_error_message => SQLERRM,
            p_backtrace   => DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
          );
          RAISE;
        END IF;

        v_sleep_sec := POWER(2, v_attempt - 1)
                     + DBMS_RANDOM.VALUE(0, 0.5);

        DBMS_LOCK.SLEEP(v_sleep_sec);
    END;
  END LOOP;
END;
/

DBMS_LOCK.SLEEPDBMS_RANDOM の利用権限がない環境もあります。その場合は、アプリケーション側やジョブ制御側で待機・再実行する設計に寄せます。排他制御そのものは DBMS_LOCKで二重起動防止する方法 も参考になります。

再試行してはいけない例外

恒久障害は、再試行しても成功しません。むしろログを増やし、キューを詰まらせ、原因調査を遅らせます。入力データや定義に問題がある場合は、即失敗させて再処理待ちに回します。

入力不備必須項目不足、コード値不正、桁あふれ、日付形式不正は同じ入力では成功しません。
制約違反一意制約、外部キー、CHECK制約は、業務データを修正しない限り直りません。
権限不足ORA-01031やORA-00942は、GRANTやスキーマ修飾を直す必要があります。
プログラム不具合NO_DATA_FOUNDを想定外で出している、VALUE_ERRORが出る、関数が値を返さないなどはコード修正対象です。

長時間処理で出やすい ORA-01555 は、単純リトライで隠すよりUNDO、COMMIT頻度、SQL設計を見直すべきことがあります。詳しくは ORA-01555完全ガイド大量データ処理のコミット頻度とUNDO最適化 を確認してください。

冪等キーで二重処理を防ぐ

再試行を安全にするには、同じ依頼を何度実行しても結果が壊れない冪等性が必要です。外部から受け取ったリクエストID、ファイル名と行番号、ジョブID、業務キーなどを一意制約にします。

リクエストIDで二重登録を防ぐ

idempotent-request.sql
CREATE TABLE payment_request (
  request_id  VARCHAR2(100) PRIMARY KEY,
  order_id    NUMBER NOT NULL,
  amount      NUMBER(12,2) NOT NULL,
  status      VARCHAR2(20) NOT NULL,
  created_at  TIMESTAMP DEFAULT SYSTIMESTAMP,
  updated_at  TIMESTAMP
);

MERGE INTO payment_request t
USING (
  SELECT :request_id AS request_id,
         :order_id AS order_id,
         :amount AS amount
    FROM dual
) s
ON (t.request_id = s.request_id)
WHEN NOT MATCHED THEN
  INSERT (request_id, order_id, amount, status)
  VALUES (s.request_id, s.order_id, s.amount, 'WAITING')
WHEN MATCHED THEN
  UPDATE SET updated_at = SYSTIMESTAMP;

再試行できる設計は、トランザクション境界とも密接に関係します。詳しくは PL/SQLトランザクション設計パターン を参照してください。

SAVEPOINTで任意処理だけ戻す

1つの業務トランザクションの中で、一部の任意処理だけ失敗を許容する場合は SAVEPOINT を使います。ただし、SAVEPOINTは部分COMMITではありません。最後に全体ROLLBACKすれば、すべて戻ります。

任意処理を部分ロールバックする例

savepoint-retry.sql
BEGIN
  update_order_header(:order_id);

  SAVEPOINT before_optional_coupon;

  BEGIN
    apply_coupon(:order_id, :coupon_code);
  EXCEPTION
    WHEN NO_DATA_FOUND THEN
      ROLLBACK TO before_optional_coupon;
      logger_pkg.write_log(
        p_level       => 'WARN',
        p_message     => 'coupon not applied',
        p_process_key => 'order_id=' || :order_id
      );
  END;

  update_order_total(:order_id);
  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    RAISE;
END;

SAVEPOINTの実務パターンは SAVEPOINTで部分ロールバックを実装する完全ガイド、ネストブロックとの組み合わせは PL/SQLネストブロック完全ガイド が関連します。

大量処理はDBMS_ERRLOGとFORALLで失敗行を分離する

大量データ処理で1件失敗しただけで全体を止めたくない場合は、例外を行単位に分離します。SQL一発のINSERT/MERGEなら DBMS_ERRLOG、PL/SQL配列処理なら FORALL SAVE EXCEPTIONS が候補です。

DBMS_ERRLOGで失敗行を退避する

errlog-retry.sql
INSERT INTO target_orders(order_id, customer_id, amount)
SELECT order_id, customer_id, amount
  FROM staging_orders
 WHERE load_id = :load_id
LOG ERRORS INTO err$_target_orders ('LOAD_20260528')
REJECT LIMIT UNLIMITED;

COMMIT;

FORALL SAVE EXCEPTIONSで個別失敗を拾う

forall-save-exceptions-retry.sql
BEGIN
  FORALL i IN 1 .. l_orders.COUNT SAVE EXCEPTIONS
    UPDATE orders
       SET status = 'CANCELED'
     WHERE order_id = l_orders(i).order_id;

EXCEPTION
  WHEN OTHERS THEN
    FOR i IN 1 .. SQL%BULK_EXCEPTIONS.COUNT LOOP
      INSERT INTO batch_error_log(
        error_index,
        error_code,
        error_message
      ) VALUES (
        SQL%BULK_EXCEPTIONS(i).ERROR_INDEX,
        SQL%BULK_EXCEPTIONS(i).ERROR_CODE,
        SQLERRM(-SQL%BULK_EXCEPTIONS(i).ERROR_CODE)
      );
    END LOOP;
    RAISE;
END;

詳しくは DBMS_ERRLOGとLOG ERRORSの使い方FORALL SAVE EXCEPTIONSで失敗行を個別ログ化する方法Oracle BULK COLLECT / FORALL完全ガイド を参照してください。

外部APIの再試行は副作用を分離する

外部API、メール送信、ファイル出力は、DBトランザクションのROLLBACKでは取り消せません。DB更新と外部送信を同じ処理の中で直接行うと、DBはROLLBACKされたのに外部送信だけ済んだ、またはその逆が起こります。この場合は、アウトボックスや送信キューを作り、DB内には送信予定をCOMMITしてから別処理が送信します。

アウトボックスへ送信予定を残す

outbox-retry.sql
BEGIN
  UPDATE orders
     SET status = 'CONFIRMED'
   WHERE order_id = :order_id;

  INSERT INTO event_outbox(
    event_id,
    event_type,
    aggregate_id,
    request_id,
    payload_json,
    status,
    retry_count,
    next_retry_at,
    created_at
  ) VALUES (
    event_seq.NEXTVAL,
    'ORDER_CONFIRMED',
    :order_id,
    :request_id,
    :payload_json,
    'WAITING',
    0,
    SYSTIMESTAMP,
    SYSTIMESTAMP
  );

  COMMIT;
END;

PL/SQLからREST APIを呼ぶ場合は UTL_HTTPでREST API連携する方法、API向けの例外レスポンス設計は 例外をJSON形式で返すREST対応API設計 が関連します。

DBMS_SCHEDULERで再実行を運用する

バッチや外部送信の再試行は、PL/SQL内のループだけで抱え込まず、DBMS_SCHEDULER やジョブ管理テーブルへ逃がす方が運用しやすいことがあります。即時リトライは短時間の揺らぎに、スケジューラ再実行は数分後・数十分後の再処理に向いています。

再送キューを処理する例

scheduler-retry-worker.sql
BEGIN
  FOR r IN (
    SELECT event_id
      FROM event_outbox
     WHERE status = 'WAITING'
       AND next_retry_at <= SYSTIMESTAMP
     ORDER BY event_id
     FOR UPDATE SKIP LOCKED
  ) LOOP
    BEGIN
      send_event(r.event_id);

      UPDATE event_outbox
         SET status = 'SENT',
             sent_at = SYSTIMESTAMP
       WHERE event_id = r.event_id;

    EXCEPTION
      WHEN OTHERS THEN
        UPDATE event_outbox
           SET retry_count = retry_count + 1,
               next_retry_at = SYSTIMESTAMP + NUMTODSINTERVAL(5, 'MINUTE'),
               last_error = SQLERRM
         WHERE event_id = r.event_id;
    END;
  END LOOP;

  COMMIT;
END;

ジョブ管理は DBMS_SCHEDULERでジョブ管理を極める、Oracle側の基本は DBMS_SCHEDULER完全ガイド、実行履歴の集中管理は ジョブ実行履歴の集中管理と異常検知 が関連します。

例外ログは本体処理と分けて残す

失敗時にROLLBACKすると、同じトランザクション内のログも消えます。障害調査ログだけは残したい場合、ログ専用の自律トランザクションを使います。ただし、本体データ更新を自律トランザクションへ逃がすのは不整合の原因です。

バックトレース付きログ

exception-log.sql
EXCEPTION
  WHEN OTHERS THEN
    logger_pkg.write_log(
      p_level         => 'ERROR',
      p_message       => 'order import failed',
      p_process_key   => 'load_id=' || :load_id,
      p_sql_code      => SQLCODE,
      p_error_message => SQLERRM,
      p_backtrace     => DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
    );
    ROLLBACK;
    RAISE;

ログ・トレースの設計は インストゥルメンテーション設計、自律トランザクションは AUTONOMOUS_TRANSACTIONで独立処理を実装する完全ガイド が関連します。エラー箇所の読み方は ORA-06512の原因と読み方 も参考になります。

WHEN OTHERSの正しい使い方

WHEN OTHERS は悪ではありません。問題は、握りつぶすことです。最上位ではログを残し、必要に応じてROLLBACKし、原則として RAISE で呼び出し元へ失敗を伝えます。

握りつぶさない例外処理

when-others-reraise.sql
BEGIN
  run_business_process(:request_id);
  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    logger_pkg.write_log(
      p_level         => 'ERROR',
      p_message       => 'business process failed',
      p_process_key   => 'request_id=' || :request_id,
      p_sql_code      => SQLCODE,
      p_error_message => SQLERRM,
      p_backtrace     => DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
    );
    RAISE;
END;

エラーを業務コードへ変換して返す場合も、内部ログには元の SQLCODE とバックトレースを残します。

避けたい設計

WHEN OTHERSで握りつぶす呼び出し元が失敗を検知できず、不整合や再処理漏れになります。
全例外をリトライする入力不備や権限不足まで再試行し、キューとログを無駄に増やします。
無限リトライする障害を隠し、セッションやジョブを詰まらせます。最大回数と打ち切り方針が必要です。
外部APIをDB更新と直結するROLLBACKできない副作用を作ります。アウトボックスや送信キューへ分離します。
ログだけ残して正常終了する運用監視が検知できません。失敗として返すか、再処理キューに明示的に移します。

本番前チェックリスト

  • 例外を一時障害と恒久障害に分類している
  • 再試行対象のORAコードが明確で、最大試行回数がある
  • 再試行前にROLLBACKまたはSAVEPOINT戻しを行っている
  • 冪等キーにより二重登録・二重送信を防いでいる
  • 外部APIやメール送信はアウトボックスやキューに分離している
  • 大量処理では失敗行をDBMS_ERRLOGやFORALL SAVE EXCEPTIONSで分離している
  • WHEN OTHERSでログ後にRAISEしている
  • SQLCODE、SQLERRM、FORMAT_ERROR_BACKTRACE、処理キーをログに残している
  • ジョブ再実行時に前回成功分を重複処理しない
  • 再試行不能になった処理を人間が再処理できる状態にしている

まとめ

PL/SQLの例外設計では、例外を捕まえることよりも、捕まえた後にどう扱うかが重要です。再試行できる一時障害、再試行すべきでない恒久障害、部分失敗として継続できる行単位エラー、外部副作用を伴う失敗を分けて設計します。

再試行は、冪等キー、ロールバック範囲、最大回数、待機時間、ログ、運用通知がそろって初めて安全になります。握りつぶさず、無限に回さず、失敗しても再実行できる状態を残すことが、大規模PL/SQLで強い例外設計です。