【PL/SQL】トランザクション分離レベル別の一貫性テストと検証パターン

【PL/SQL】トランザクション分離レベル別の一貫性テストと検証パターン PL/SQL

Oracleのトランザクション分離は、単に READ COMMITTEDSERIALIZABLEREAD ONLY の名前を覚えるだけでは足りません。実務では「同じSELECTを二回実行したら結果が変わるのか」「長時間レポートで集計値は揺れないのか」「競合時に待つのか、例外で落ちるのか」を、二つのセッションで再現できることが重要です。

この記事では、Oracleの一貫性読取を前提に、分離レベル別の挙動を検証する手順をまとめます。ORA-08177ORA-01555FOR UPDATE、UNDO前提、ジョブによる並行テストまで扱いますが、コード例は検証用です。業務処理の部品にそのまま COMMITROLLBACK を埋め込むのではなく、トランザクション境界をどこで持つかを先に決めてください。

先に結論

  • READ COMMITTED は文単位の一貫性です。同一トランザクション内でも、次のSELECTでは新しいコミット済みデータを読み得ます。
  • SERIALIZABLE はトランザクション単位のスナップショットです。競合時は ORA-08177 を前提に限定リトライを設計します。
  • READ ONLY は参照系の固定点を作る用途に向いています。ただし古い版を読むため、UNDO保持と ORA-01555 を同時に考えます。
  • SET TRANSACTION はトランザクションの先頭で実行します。すでにDMLやSELECTを実行した後に切り替える設計は避けます。

ORA-08177 の詳しい原因は ORA-08177の原因と解決方法、UNDOとスナップショットの関係は ORA-01555: スナップショットが古すぎます完全ガイド、リトライ設計は 例外設計と再試行パターン も参考になります。

スポンサーリンク

検証用スキーマ初期化と前提

まず、二つのセッションA/Bで同じテーブルを操作できる検証データを作ります。以降のコードはSQL*Plus、SQLcl、SQL Developer、または同等のクライアントで、別接続を二つ開いて実行する前提です。

setup-isolation-demo.sql
DROP TABLE iso_log PURGE;
DROP TABLE iso_demo PURGE;

CREATE TABLE iso_demo(
  id      NUMBER PRIMARY KEY,
  cat     VARCHAR2(20) NOT NULL,
  amount  NUMBER(12,2) NOT NULL,
  updated_at TIMESTAMP DEFAULT SYSTIMESTAMP
);

CREATE TABLE iso_log(
  id         NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  test_name  VARCHAR2(80),
  message    VARCHAR2(4000),
  logged_at  TIMESTAMP DEFAULT SYSTIMESTAMP
);

INSERT INTO iso_demo(id, cat, amount) VALUES(1, 'A', 100);
INSERT INTO iso_demo(id, cat, amount) VALUES(2, 'A', 200);
INSERT INTO iso_demo(id, cat, amount) VALUES(3, 'B', 300);
COMMIT;

-- セッションA/Bそれぞれで設定すると、V$SESSIONやログで追跡しやすい
BEGIN
  DBMS_APPLICATION_INFO.SET_MODULE('ISO_TEST', 'SESSION_A');
END;
/

検証前に必ず初期化を戻します。特に SERIALIZABLEREAD ONLY のテストは、前のトランザクションが残っていると結果がぶれます。各手順の前に ROLLBACK または COMMIT で区切ってください。

READ COMMITTEDでの読取の揺れを確認する

READ COMMITTED はOracleの既定です。各SQL文の開始時点で一貫性読取を行うため、単一SELECTの途中で値が混在することはありません。一方、同じトランザクション内で同じSELECTをもう一度実行すると、その時点までに他セッションがコミットした値を読むことがあります。

read-committed-two-sessions.sql
-- セッションA
ROLLBACK;
SELECT SUM(amount) AS sum_a FROM iso_demo WHERE cat = 'A';
-- 期待例: 300

-- セッションB
UPDATE iso_demo
SET amount = amount + 100,
    updated_at = SYSTIMESTAMP
WHERE id = 1;
COMMIT;

-- セッションA
SELECT SUM(amount) AS sum_a FROM iso_demo WHERE cat = 'A';
-- 期待例: 400。文単位で新しいコミット済みデータを読む

これは「読み途中で壊れた値を読む」という意味ではありません。Oracleの一貫性読取により、単一SQL文の中では文開始時点の整合した版を読みます。揺れるのは、同一トランザクション内で複数文を実行したときの観測点です。

SERIALIZABLEでORA-08177を再現する

SERIALIZABLE はトランザクション単位でスナップショットを固定します。そのため、古いスナップショットを前提に更新しようとして、他セッションのコミット済み更新と矛盾すると ORA-08177 が発生します。

重要

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE はトランザクションの最初に実行します。すでにSELECTやDMLを実行した後で分離レベルを変える前提のコードにしないでください。

serializable-ora-08177.sql
-- セッションA
ROLLBACK;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(amount) AS sum_a FROM iso_demo WHERE cat = 'A';

-- セッションB
UPDATE iso_demo
SET amount = amount + 100,
    updated_at = SYSTIMESTAMP
WHERE id = 1;
COMMIT;

-- セッションA
UPDATE iso_demo
SET amount = amount + 50,
    updated_at = SYSTIMESTAMP
WHERE id = 1;
-- ORA-08177: can't serialize access for this transaction

ROLLBACK;

ORA-08177 は「Oracleが壊れた」のではなく、直列化できない競合を安全に検知した結果です。再試行する場合は、同じ処理を繰り返しても状態が二重更新されない、つまり冪等性を担保してから行います。

READ ONLYでレポート固定点を取る

長時間の帳票、ダッシュボード、ETLの参照側では、複数のSELECTが同じ時点を見ていることが重要です。SET TRANSACTION READ ONLY を使うと、トランザクション中の参照が同じスナップショットに固定されます。

read-only-report-snapshot.sql
-- セッションA
ROLLBACK;
SET TRANSACTION READ ONLY;

SELECT COUNT(*) AS cnt_before FROM iso_demo;
SELECT SUM(amount) AS sum_before FROM iso_demo;

-- セッションB
UPDATE iso_demo SET amount = amount + 100 WHERE id IN (1, 2);
COMMIT;

-- セッションA
SELECT COUNT(*) AS cnt_after FROM iso_demo;
SELECT SUM(amount) AS sum_after FROM iso_demo;
-- READ ONLY中は、セッションAから見る結果は固定点のまま

COMMIT;

READ ONLY は参照の整合性を強くできますが、古い版を読むためにUNDOへ依存します。長時間処理や大量更新と重なる場合は、UNDO保持時間、UNDO表領域、処理時間を測っておく必要があります。UNDO容量不足は ORA-30036の原因と解決方法 も参考になります。

検証用リトライは業務ロジックから分離する

競合時のリトライ例を書くときに注意したいのは、業務プロシージャの中に安易に COMMITROLLBACK を入れないことです。下記は検証用の呼び出し側スクリプトです。更新処理本体は1回分だけ行い、トランザクション境界とリトライは外側で管理します。

retry-serializable-test-harness.sql
CREATE OR REPLACE PROCEDURE update_iso_amount_once(
  p_id    IN NUMBER,
  p_delta IN NUMBER
) IS
BEGIN
  UPDATE iso_demo
  SET amount = amount + p_delta,
      updated_at = SYSTIMESTAMP
  WHERE id = p_id;
END;
/

-- 検証用ハーネス。業務部品ではなく、呼び出し側でトランザクションを管理する例。
DECLARE
  e_serialize EXCEPTION;
  PRAGMA EXCEPTION_INIT(e_serialize, -8177);
  v_try PLS_INTEGER := 0;
BEGIN
  LOOP
    v_try := v_try + 1;
    BEGIN
      SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
      update_iso_amount_once(p_id => 1, p_delta => 10);
      COMMIT;
      EXIT;
    EXCEPTION
      WHEN e_serialize THEN
        ROLLBACK;
        INSERT INTO iso_log(test_name, message)
        VALUES('SERIALIZABLE_RETRY', 'retry=' || v_try || ', ORA-08177');
        COMMIT;

        IF v_try >= 3 THEN
          RAISE;
        END IF;

        -- DBMS_LOCK.SLEEPは権限が必要な環境がある。
        -- 権限がない場合は外部テストランナー側で待機させる。
        BEGIN
          DBMS_LOCK.SLEEP(POWER(2, v_try) * 0.1);
        EXCEPTION
          WHEN OTHERS THEN NULL;
        END;
    END;
  END LOOP;
END;
/

この例ではリトライ上限を3回に限定しています。無限リトライにすると、根本原因がロック設計やホット行集中にある場合でも処理が滞留します。実務では、リトライ回数、待機時間、失敗ログ、呼び出し元へのエラー返却をセットで設計します。

FOR UPDATEで意図的に直列化する

READ COMMITTED でも、対象行を FOR UPDATE でロックすれば、更新対象を意図的に直列化できます。キュー処理やワーカープールでは、NOWAITWAIT nSKIP LOCKED の違いを検証しておくと設計しやすくなります。

for-update-lock-patterns.sql
-- セッションA: ロックを取得して保持
ROLLBACK;
SELECT id, amount
FROM iso_demo
WHERE id = 1
FOR UPDATE;

-- セッションB: 即時失敗を確認
SELECT id, amount
FROM iso_demo
WHERE id = 1
FOR UPDATE NOWAIT;
-- ORA-00054

-- セッションB: ロック行を避けて処理候補を取る
SELECT id, amount
FROM iso_demo
WHERE id IN (1, 2, 3)
FOR UPDATE SKIP LOCKED;

SKIP LOCKED は便利ですが、ロックされた行を飛ばすため、処理順序の保証が必要な業務には向かないことがあります。キュー型処理では、ステータス管理テーブル、取得順、再実行条件を合わせて設計します。

ハッシュ検証で結果の揺れを検知する

結果集合が揺れたかを自動テストで確認するには、ハッシュや集計シグネチャを使うと便利です。ただし、LISTAGG で全行を連結するとデータ量によって ORA-01489 になり得ます。大量データでは、複数の集計値を組み合わせた軽量シグネチャから始めるほうが安全です。

result-signature-check.sql
SELECT
  COUNT(*) AS row_count,
  SUM(amount) AS sum_amount,
  SUM(ORA_HASH(id || ':' || cat || ':' || TO_CHAR(amount))) AS hash_sum,
  MIN(updated_at) AS min_updated_at,
  MAX(updated_at) AS max_updated_at
FROM iso_demo;

-- 小規模データで順序込みの厳密な比較をしたい場合だけLISTAGGを使う
SELECT STANDARD_HASH(
         LISTAGG(id || ':' || cat || ':' || amount, '|')
           WITHIN GROUP (ORDER BY id)
       ) AS strict_signature
FROM iso_demo;

軽量シグネチャは衝突の可能性をゼロにはできませんが、CIで「明らかな揺れ」を捕まえる目的には使いやすいです。厳密性が必要な場合は、テスト対象の行数を限定して順序込みの比較を行います。

UNDOと一貫性を定量確認する

一貫性読取はUNDOに依存します。長時間の READ ONLY、大きな結合、バックグラウンドの大量更新が重なると、古い版が必要になる時間が長くなります。検証では、処理時間、更新量、UNDO使用量を一緒に記録します。

check-undo-during-test.sql
SELECT
  begin_time,
  end_time,
  undoblks,
  txncount,
  maxquerylen,
  ssolderrcnt
FROM v$undostat
ORDER BY begin_time DESC
FETCH FIRST 12 ROWS ONLY;

ssolderrcnt が増える場合は、ORA-01555 が発生している、または発生しやすい状態です。単にSQLを書き換えるだけでなく、コミット頻度、UNDO_RETENTION、UNDO表領域、長時間カーソルの扱いを見直します。大量処理のコミット設計は 大量データ処理のコミット頻度とUNDO最適化 でも整理しています。

DBMS_SCHEDULERで並行テストする場合の注意

二セッションを手で操作する代わりに、DBMS_SCHEDULER でジョブを二つ起動する方法もあります。ただし、固定のジョブ名を使うと再実行時に名前重複で失敗しやすいため、事前削除または一意なジョブ名を使います。

scheduler-concurrency-test.sql
DECLARE
  v_job1 VARCHAR2(128) := 'ISO_T1_' || TO_CHAR(SYSTIMESTAMP, 'YYYYMMDDHH24MISSFF3');
  v_job2 VARCHAR2(128) := 'ISO_T2_' || TO_CHAR(SYSTIMESTAMP, 'YYYYMMDDHH24MISSFF3');
BEGIN
  DBMS_SCHEDULER.CREATE_JOB(
    job_name   => v_job1,
    job_type   => 'PLSQL_BLOCK',
    job_action => q'[
      DECLARE
        e_serialize EXCEPTION;
        PRAGMA EXCEPTION_INIT(e_serialize, -8177);
      BEGIN
        DBMS_APPLICATION_INFO.SET_MODULE('ISO_TEST','JOB_1');
        SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
        UPDATE iso_demo SET amount = amount + 10 WHERE id = 1;
        DBMS_LOCK.SLEEP(2);
        COMMIT;
      EXCEPTION
        WHEN e_serialize THEN
          ROLLBACK;
          INSERT INTO iso_log(test_name, message)
          VALUES('SCHEDULER_TEST', 'JOB_1 ORA-08177');
          COMMIT;
      END;]',
    enabled => TRUE,
    auto_drop => TRUE
  );

  DBMS_SCHEDULER.CREATE_JOB(
    job_name   => v_job2,
    job_type   => 'PLSQL_BLOCK',
    job_action => q'[
      DECLARE
        e_serialize EXCEPTION;
        PRAGMA EXCEPTION_INIT(e_serialize, -8177);
      BEGIN
        DBMS_APPLICATION_INFO.SET_MODULE('ISO_TEST','JOB_2');
        SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
        UPDATE iso_demo SET amount = amount + 5 WHERE id = 1;
        COMMIT;
      EXCEPTION
        WHEN e_serialize THEN
          ROLLBACK;
          INSERT INTO iso_log(test_name, message)
          VALUES('SCHEDULER_TEST', 'JOB_2 ORA-08177');
          COMMIT;
      END;]',
    enabled => TRUE,
    auto_drop => TRUE
  );
END;
/

SELECT test_name, message, logged_at
FROM iso_log
ORDER BY logged_at DESC;

このテストはタイミング依存です。毎回必ず同じジョブが ORA-08177 になるとは限りません。確実に競合を作りたい場合は、片方でロック保持や待機を入れ、ログに相関IDを残します。ジョブ管理の基本は DBMS_SCHEDULER完全ガイド、実行履歴の集中管理は ジョブ実行履歴の集中管理と異常検知 も参考になります。

実務で確認するチェックリスト

  • SET TRANSACTION をトランザクション先頭で実行しているか
  • 検証用コードと業務用プロシージャのトランザクション境界を分けているか
  • ORA-08177 を失敗ではなく、限定リトライ対象として扱っているか
  • リトライ対象処理に冪等性があるか
  • READ ONLY の長時間処理でUNDO保持を確認しているか
  • DBMS_LOCKDBMS_SCHEDULER の権限不足を想定しているか
  • ハッシュ検証で LISTAGG のサイズ上限に依存しすぎていないか
  • 並行テストのログにセッション識別子や相関IDを残しているか

まとめ

Oracleの分離レベル検証では、READ COMMITTED は文単位、SERIALIZABLEREAD ONLY はトランザクション単位のスナップショットとして整理すると理解しやすくなります。READ COMMITTEDでは繰り返し読取が揺れ得ますが、単一SELECT内の一貫性は保たれます。SERIALIZABLEでは競合を ORA-08177 として受け止め、READ ONLYでは固定点とUNDO保持をセットで検証します。

重要なのは、分離レベルを選ぶこと自体ではなく、二セッションで再現し、ログとシグネチャで検証し、失敗時のリトライやUNDO前提まで設計に落とし込むことです。検証用コードと業務コードの境界を分けておけば、本番での予測可能性を高められます。