【PL/SQL】トランザクション設計パターン|COMMIT境界・ロック・再処理まで

【PL/SQL】データ整合性を保証するトランザクション設計パターン PL/SQL

PL/SQLのトランザクション設計で大事なのは、COMMITROLLBACK を呼ぶ場所だけではありません。どこからどこまでを1つの業務単位にするか、どの順番でロックするか、途中失敗をどう扱うか、再実行しても二重処理にならないかまで含めて設計する必要があります。

単純な登録処理なら1回の COMMIT で十分です。しかし、受注、在庫引当、請求、外部連携、バッチ投入、監査ログのように複数の更新が絡むと、設計を間違えた瞬間に不整合、デッドロック、ロック待ち、再処理不能な中途半端データが発生します。基本仕様は Oracleのトランザクション完全ガイド、PL/SQLでの境界設計は COMMITとROLLBACKの正しい使い方 もあわせて確認してください。

この記事で整理すること

  • COMMIT境界を業務単位で決める考え方
  • 制約・一意キー・外部キーを中心に整合性を守る設計
  • SELECT FOR UPDATENOWAITSKIP LOCKED の使い分け
  • SAVEPOINTDBMS_ERRLOGFORALL SAVE EXCEPTIONS による部分失敗の扱い
  • 監査ログ、アウトボックス、冪等キー、再処理設計
  • デッドロック、ロック待ち、ORA-01555を避ける実務チェック
スポンサーリンク

トランザクション設計の結論

PL/SQLでは、原則として呼び出し元が業務トランザクションの境界を決める設計にします。下位の共通プロシージャが勝手に COMMIT すると、呼び出し元は一連の業務処理をまとめて取り消せなくなります。そのため、登録・更新・検証の部品はCOMMITしない、画面処理やジョブの最上位だけがCOMMITする、という規約を先に決めます。

画面登録1リクエストを1トランザクションにし、入力チェック、親子登録、履歴登録をまとめて確定します。途中で失敗したら全体を戻します。
バッチ全件一括で確定するのか、業務キー単位で確定するのかを先に決めます。件数だけでCOMMIT間隔を決めると再処理が難しくなります。
外部連携DB更新と外部API呼び出しは同じトランザクションにできません。アウトボックスやステータス管理で再送可能にします。
監査ログ本体処理の成否とログの成否を分ける場合だけ、自律トランザクションを検討します。通常ログに乱用すると不整合の原因になります。

基本形:最上位だけでCOMMITする

最も安全な形は、業務処理の入口で例外をまとめて受け、成功時だけ COMMIT、失敗時は ROLLBACK する構成です。下位プロシージャはDMLだけを実行し、確定・取消は行いません。

業務処理の入口で確定する例

transaction-boundary.sql
CREATE OR REPLACE PROCEDURE register_order(
  p_order_id IN NUMBER
) AS
BEGIN
  validate_order(p_order_id);
  insert_order_header(p_order_id);
  insert_order_detail(p_order_id);
  reserve_stock(p_order_id);
  write_order_history(p_order_id);

  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    RAISE;
END;
/

この形なら、在庫だけ更新されて受注が作られない、履歴だけ残って本体がない、といった中途半端な状態を避けられます。一方で、処理時間が長い場合はロック保持時間も長くなるため、どこまでを同じ業務単位にするかは慎重に決めます。

共通部品ではCOMMITしない

共通部品の中で COMMIT してしまうと、呼び出し元は全体をロールバックできません。これはPL/SQLで最もよくあるトランザクション設計ミスです。

避けたい共通部品の例

bad-common-procedure.sql
CREATE OR REPLACE PROCEDURE update_customer_rank(
  p_customer_id IN NUMBER
) AS
BEGIN
  UPDATE customers
     SET rank_code = 'A'
   WHERE customer_id = p_customer_id;

  -- NG: 共通部品が勝手に確定している
  COMMIT;
END;
/

共通部品は、成功・失敗を呼び出し元へ返すだけにします。どうしても単体実行用の確定処理が必要なら、業務用プロシージャと単体実行用ラッパーを分けます。

確定するラッパーを分ける例

commit-wrapper.sql
CREATE OR REPLACE PROCEDURE update_customer_rank_core(
  p_customer_id IN NUMBER
) AS
BEGIN
  UPDATE customers
     SET rank_code = 'A'
   WHERE customer_id = p_customer_id;
END;
/

CREATE OR REPLACE PROCEDURE update_customer_rank_job(
  p_customer_id IN NUMBER
) AS
BEGIN
  update_customer_rank_core(p_customer_id);
  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    RAISE;
END;
/

制約ファーストで整合性を守る

PL/SQLだけで整合性を守ろうとすると、別セッションからの同時更新に弱くなります。一意性、参照整合性、必須項目、状態遷移の一部は、可能な限りテーブル制約で守ります。PL/SQLは制約違反を捕捉して、業務メッセージやログに変換する役割に寄せます。

constraint-first.sql
ALTER TABLE orders ADD CONSTRAINT uk_orders_request
  UNIQUE (request_id);

ALTER TABLE order_details ADD CONSTRAINT fk_order_details_header
  FOREIGN KEY (order_id) REFERENCES orders(order_id);

ALTER TABLE orders ADD CONSTRAINT ck_orders_status
  CHECK (status IN ('NEW', 'RESERVED', 'CANCELED', 'SHIPPED'));

再実行される可能性がある処理では、リクエストIDや外部連携IDなどの冪等キーを一意制約にします。これにより、同じ依頼が2回届いても二重登録ではなく、既存処理として扱えます。

設計判断早見リスト

トランザクション設計では、機能ごとに使う技術を選びます。以下は実務で迷いやすい場面の判断軸です。

全部成功か全部失敗にしたいCOMMIT は最上位に置きます。途中で部分確定せず、失敗時は全体を ROLLBACK します。
1件だけ失敗しても他は続けたいSAVEPOINTDBMS_ERRLOGFORALL SAVE EXCEPTIONS を検討します。件単位の再処理キーも残します。
同じ行を同時更新させたくないSELECT FOR UPDATE を使います。待たせるのか、即エラーにするのか、スキップするのかを業務要件で選びます。
ジョブを二重起動させたくない管理テーブルの状態更新、または DBMS_LOCK による排他制御を検討します。
外部送信をDB更新と連動したいDB内に送信待ちキューを作るアウトボックスパターンにし、COMMIT後に別処理が送信します。
処理結果ログだけは残したい監査ログ用途に限って AUTONOMOUS_TRANSACTION を使います。本体データ更新には使いません。

SELECT FOR UPDATEで更新対象を固定する

在庫引当や残高更新のように、現在値を読んでから更新する処理では、対象行を先にロックします。読み取り後に別セッションが更新してしまうと、古い値を前提にした処理になるためです。

読み取り直後に行ロックする例

select-for-update.sql
DECLARE
  v_stock_qty stock_items.stock_qty%TYPE;
BEGIN
  SELECT stock_qty
    INTO v_stock_qty
    FROM stock_items
   WHERE item_id = :item_id
   FOR UPDATE;

  IF v_stock_qty < :order_qty THEN
    RAISE_APPLICATION_ERROR(-20001, '在庫不足です');
  END IF;

  UPDATE stock_items
     SET stock_qty = stock_qty - :order_qty
   WHERE item_id = :item_id;
END;

ロック待ちを避けたい画面処理では NOWAIT、キュー処理では SKIP LOCKED が候補になります。ロック競合のエラーとしては ORA-00054の原因と対処、セッションロックの調査は DBMS_LOCKで二重起動防止する方法 も参考になります。

NOWAITとSKIP LOCKEDの使い分け

lock-options.sql
-- 画面: 他ユーザーが編集中ならすぐ戻す
SELECT *
  FROM orders
 WHERE order_id = :order_id
 FOR UPDATE NOWAIT;

-- ジョブ: ロックされていない待ち行だけ処理する
SELECT request_id
  FROM send_queue
 WHERE status = 'WAITING'
 ORDER BY request_id
 FOR UPDATE SKIP LOCKED;

ロック順序を固定してデッドロックを防ぐ

複数テーブルや複数キーを更新する場合は、全処理で同じ順番にロックします。A処理は顧客を先にロックし、B処理は受注を先にロックする、という設計はデッドロックの典型です。

lock-order.sql
-- 全処理で「顧客 -> 受注 -> 明細」の順に固定する
SELECT *
  FROM customers
 WHERE customer_id = :customer_id
 FOR UPDATE;

SELECT *
  FROM orders
 WHERE order_id = :order_id
 FOR UPDATE;

SELECT *
  FROM order_details
 WHERE order_id = :order_id
 FOR UPDATE;

デッドロックはOracleが検出して片方を失敗させますが、アプリ側から見ると突然処理が落ちたように見えます。原因調査と予防策は ORA-00060デッドロックの完全ガイド にまとめています。

SAVEPOINTで部分的に戻す

1つの大きな業務トランザクションの中で、一部だけやり直したい場合は SAVEPOINT を使います。ただし、SAVEPOINTはCOMMITではありません。最後にROLLBACKすれば、SAVEPOINT以降だけでなく全体を戻すこともできます。

任意処理だけ取り消す例

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

  SAVEPOINT before_optional_coupon;

  BEGIN
    apply_coupon(:order_id, :coupon_code);
  EXCEPTION
    WHEN OTHERS THEN
      ROLLBACK TO before_optional_coupon;
      write_warning_log(:order_id, SQLERRM);
  END;

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

部分ロールバックの実装パターンは SAVEPOINTで部分ロールバックを実装する完全ガイド で詳しく扱っています。ネストブロックと組み合わせる場合は PL/SQLネストブロック完全ガイド も有効です。

大量DMLはDBMS_ERRLOGで失敗行を分離する

大量INSERTやUPDATEで、1件のエラーだけで全件を止めたくない場合は DBMS_ERRLOGLOG ERRORS を使います。これはSQL単位で失敗行をエラーテーブルへ退避し、成功行は処理できる仕組みです。

dbms-errlog-pattern.sql
BEGIN
  DBMS_ERRLOG.CREATE_ERROR_LOG(
    dml_table_name => 'ORDERS',
    err_log_table_name => 'ERR$_ORDERS'
  );
END;
/

INSERT INTO orders(order_id, customer_id, order_date, amount)
SELECT order_id, customer_id, order_date, amount
  FROM staging_orders
LOG ERRORS INTO err$_orders ('IMPORT_20260528')
REJECT LIMIT UNLIMITED;

エラー行を後から再処理する設計まで含めるなら、DBMS_ERRLOGとLOG ERRORSの使い方 を参照してください。

FORALL SAVE EXCEPTIONSで配列処理の失敗を拾う

PL/SQL配列を使って大量DMLを行う場合は、FORALL SAVE EXCEPTIONS で失敗要素を後から確認できます。SQL一発の投入ならDBMS_ERRLOG、PL/SQL側で配列ごとに制御したいならFORALLを使う、という切り分けです。

forall-save-exceptions.sql
DECLARE
  TYPE t_order_ids IS TABLE OF orders.order_id%TYPE;
  l_order_ids t_order_ids := t_order_ids(101, 102, 103);
BEGIN
  FORALL i IN 1 .. l_order_ids.COUNT SAVE EXCEPTIONS
    UPDATE orders
       SET status = 'CANCELED'
     WHERE order_id = l_order_ids(i);

  COMMIT;
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;
    ROLLBACK;
    RAISE;
END;

配列DMLの実装は Oracle BULK COLLECT / FORALL完全ガイド、PL/SQL側の例外収集は FORALL SAVE EXCEPTIONSで失敗行を個別ログ化する方法 が関連します。

AUTONOMOUS_TRANSACTIONは監査ログに限定する

AUTONOMOUS_TRANSACTION は、呼び出し元とは独立してCOMMITできる強力な機能です。便利ですが、本体更新に使うと「本体はROLLBACKされたのに関連データだけ残る」という不整合を作りやすくなります。基本的には監査ログ、処理ログ、障害調査ログのような用途に限定します。

autonomous-log.sql
CREATE OR REPLACE PROCEDURE write_audit_log(
  p_message IN VARCHAR2
) AS
  PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
  INSERT INTO audit_logs(logged_at, message)
  VALUES (SYSTIMESTAMP, p_message);

  COMMIT;
END;
/

自律トランザクションの使いどころと危険な使い方は AUTONOMOUS_TRANSACTIONで独立処理を実装する完全ガイド で整理しています。

外部連携はアウトボックスで分離する

DB更新と外部API送信は、同じトランザクションとして完全には扱えません。DBをCOMMITした直後にAPI送信で失敗する、API送信後にDB更新がROLLBACKされる、といったズレが発生します。この問題は、送信予定をDBに記録してからCOMMITし、別ジョブが送信するアウトボックスパターンで扱います。

送信予定をDBに残してから確定する例

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

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

  COMMIT;
END;

送信ジョブは WAITING の行を FOR UPDATE SKIP LOCKED で取り、成功したら SENT、失敗したらリトライ回数を増やします。これにより、DB更新と外部送信を疎結合にしつつ、再送可能な状態を保てます。

バッチのCOMMIT間隔は件数だけで決めない

バッチ処理では「1000件ごとにCOMMIT」のような実装をよく見ます。件数単位のCOMMITは悪くありませんが、業務単位をまたいで区切ると再処理が難しくなります。たとえば同じ顧客の複数明細が別トランザクションに分かれると、途中失敗時に整合性確認が複雑になります。

batch-commit-unit.sql
DECLARE
  v_current_customer_id NUMBER := NULL;
BEGIN
  FOR r IN (
    SELECT customer_id, order_id
      FROM staging_orders
     ORDER BY customer_id, order_id
  ) LOOP
    IF v_current_customer_id IS NOT NULL
       AND v_current_customer_id <> r.customer_id THEN
      COMMIT;
    END IF;

    process_customer_order(r.customer_id, r.order_id);
    v_current_customer_id := r.customer_id;
  END LOOP;

  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    RAISE;
END;

長時間カーソルを開いたまま細かくCOMMITすると、読み取り一貫性やUNDOの問題が出ることがあります。特に fetch across commit に絡む問題は ORA-01555の原因と対処 を確認してください。

冪等性を持たせて再実行できるようにする

トランザクション設計は、失敗しないことだけを目標にすると弱くなります。実務ではネットワーク断、タイムアウト、ロック競合、ジョブ停止が起きます。大事なのは、失敗後に同じ処理をもう一度流しても壊れない設計です。

リクエストIDで二重処理を防ぐ例

idempotent-request.sql
MERGE INTO payment_requests 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, created_at)
  VALUES (s.request_id, s.order_id, s.amount, 'WAITING', SYSTIMESTAMP)
WHEN MATCHED THEN
  UPDATE SET last_seen_at = SYSTIMESTAMP;

冪等キーを持たない処理は、再実行時に二重請求、二重送信、二重在庫引当を起こします。外部から受け取ったリクエストID、業務キー、ファイル名と行番号など、再実行時にも同じ値になるキーを残します。

分離レベルは必要な場面だけ変更する

Oracleの通常の読み取り一貫性は多くの業務で十分です。安易に分離レベルを上げると、競合やリトライ設計が難しくなります。レポートや締め処理で時点を固定したい場合は、要件に応じて SET TRANSACTION READ ONLY などを検討します。

read-only-transaction.sql
SET TRANSACTION READ ONLY;

SELECT SUM(amount)
  FROM orders
 WHERE order_date >= DATE '2026-05-01'
   AND order_date <  DATE '2026-06-01';

COMMIT;

更新処理で分離レベルを変える場合は、アプリ側のリトライ、エラー時の利用者メッセージ、ロック待ち時間までセットで決めます。

例外処理ではROLLBACKしてRAISEする

例外を握りつぶすと、呼び出し元は成功したのか失敗したのか判断できません。トランザクション境界を持つ最上位では、ログを残し、ROLLBACKし、必要なら業務例外に変換して再送出します。

exception-rollback.sql
CREATE OR REPLACE PROCEDURE close_month(
  p_target_month IN DATE
) AS
BEGIN
  validate_month(p_target_month);
  create_billing(p_target_month);
  update_closing_status(p_target_month);

  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    write_audit_log(
      'close_month failed: ' || SQLERRM
    );
    RAISE;
END;
/

ここで注意したいのは、write_audit_log が自律トランザクションでなければ、直前のROLLBACKに巻き込まれる点です。ログだけ残したい場合は、ログ専用の自律トランザクションを検討します。

使ってよい設計・避けたい設計

使ってよい業務単位でCOMMIT境界を決める。制約で整合性を守る。ロック順序を固定する。再処理キーを残す。外部送信をアウトボックスに分離する。
避けたい共通部品が勝手にCOMMITする。件数だけでCOMMITする。例外を握りつぶす。ロック順序が処理ごとに違う。自律トランザクションで本体データを更新する。
レビューで見るROLLBACK漏れ、COMMIT位置、SAVEPOINTの範囲、再実行時の二重処理、エラー行の再処理方法、ロック待ち時の利用者体験を確認します。

本番前チェックリスト

  • COMMITするプロシージャとCOMMITしないプロシージャが分かれている
  • 業務キー、冪等キー、一意制約が設計されている
  • 親子登録や在庫引当が1つの業務単位として扱われている
  • ロックするテーブルと順序が処理間で統一されている
  • ロック待ち時に待つ、即エラー、スキップのどれにするか決まっている
  • 大量DMLの失敗行を後から特定できる
  • バッチの再実行範囲が業務キーで判断できる
  • 監査ログと本体更新のトランザクション分離が意図通りになっている
  • 外部連携はCOMMIT後に再送可能な状態で処理される
  • デッドロック、ORA-00054、ORA-01555の調査観点が運用手順にある

まとめ

PL/SQLのトランザクション設計は、単に COMMITROLLBACK を書く作業ではありません。業務単位、制約、ロック、部分失敗、監査ログ、外部連携、再処理をまとめて設計してはじめて、障害時にも復旧しやすい処理になります。

基本は、共通部品ではCOMMITせず、最上位で確定することです。そのうえで、部分失敗にはSAVEPOINTやDBMS_ERRLOG、配列DMLにはFORALL SAVE EXCEPTIONS、外部連携にはアウトボックス、監査ログには必要最小限のAUTONOMOUS_TRANSACTIONを使います。失敗しない処理ではなく、失敗しても再実行できる処理を目指すのが、実務で強いトランザクション設計です。