【PL/SQL】マルチセッション環境での排他・同時実行制御の高度設計

【PL/SQL】マルチセッション環境での排他・同時実行制御の高度設計 PL/SQL

大規模なOracle/PLSQLシステムでは、複数セッションが同じデータ資源へ同時にアクセスします。場当たり的なロック取得や無制限リトライは、デッドロック、待機時間の増大、スループット低下を招きます。この記事では、行ロック、楽観ロック、SKIP LOCKEDDBMS_LOCK、再試行、監視SQLまでを、実務で使える形に整理します。

トランザクションの基本は Oracleトランザクション完全ガイド、行ロック全般は テーブル・行ロック完全ガイド も参考にしてください。

この記事で扱うこと

  • SELECT FOR UPDATE NOWAITSKIP LOCKED の使い分け
  • バージョン列による楽観ロック
  • 一意制約、制御テーブル、DBMS_LOCK によるキー単位の直列化
  • ORA-00054ORA-00060ORA-08177 の再試行設計
  • ロック待ちとブロッカーを調べる監視SQL
スポンサーリンク

最初に結論:ロック順序・占有時間・再試行対象を固定する

同時実行制御では、すべての競合を消すのではなく、競合しても壊れない形に設計します。ロック取得順序を固定し、占有区間を短くし、再試行する例外を限定し、失敗時に安全に戻せる単位で処理します。

順序を固定する複数行・複数表を更新する時は、同じキー順・同じテーブル順でロックします。
短く持つロックを取ってから外部APIや長時間処理を呼ばないようにします。
待つか逃がすか決めるNOWAIT、WAIT、SKIP LOCKED、DBMS_LOCK timeoutを用途で分けます。
再試行を限定するORA-00054、ORA-00060、ORA-08177など、一時競合だけを短くリトライします。

悲観制御:FOR UPDATEとNOWAIT/SKIP LOCKED

更新対象を先に占有したい場合は、SELECT ... FOR UPDATE を使います。待たずに失敗させたい場合は NOWAIT、複数ワーカーで未処理行を分散処理したい場合は SKIP LOCKED を使います。

select-for-update-nowait.sql
-- ロック中ならすぐORA-00054にする
SELECT id
FROM task_queue
WHERE id = :id
FOR UPDATE NOWAIT;

ORA-00054 は、別セッションが対象リソースを保持していてロックを取得できない時の代表例です。詳しくは ORA-00054完全ガイド を確認してください。

ワーカー処理:SKIP LOCKEDで1件ずつ安全に拾う

キュー表を複数ワーカーで処理する場合は、ロック中の行を飛ばして前進する設計が有効です。SQL一文で無理にUPDATEするより、カーソルで対象行をロックしてから WHERE CURRENT OF で状態を変える方が読みやすく、安全に説明できます。

worker-skip-locked.sql
DECLARE
  CURSOR c_task IS
    SELECT id
    FROM task_queue
    WHERE status = 'READY'
    ORDER BY priority DESC, id
    FOR UPDATE SKIP LOCKED;

  v_id task_queue.id%TYPE;
BEGIN
  OPEN c_task;
  FETCH c_task INTO v_id;

  IF c_task%FOUND THEN
    UPDATE task_queue
       SET status = 'RUNNING',
           picked_at = SYSTIMESTAMP
     WHERE CURRENT OF c_task;
  END IF;

  CLOSE c_task;
  COMMIT;
END;
/

SKIP LOCKED は取りこぼしではなく、後で再スキャンして拾う設計が前提です。処理中に落ちた行を戻すため、RUNNING のまま一定時間を超えた行を READY に戻す回収ジョブも用意します。カーソルと FOR UPDATE SKIP LOCKED の基本は PL/SQLカーソル完全ガイド も参考になります。

楽観制御:バージョン列でロストアップデートを防ぐ

競合が少ない画面更新では、最初からロックを取るより、更新時にバージョン列で衝突検知する方が向いています。期待バージョンと一致した時だけ更新し、更新できなければ利用者へ再読込を促します。

optimistic-lock-row-version.sql
ALTER TABLE customers ADD (row_ver NUMBER DEFAULT 0 NOT NULL);

UPDATE customers
   SET email = :new_email,
       row_ver = row_ver + 1
 WHERE id = :id
   AND row_ver = :expected_ver;

IF SQL%ROWCOUNT = 0 THEN
  RAISE_APPLICATION_ERROR(-20001, 'version conflict');
END IF;

キー単位の直列化:一意制約と制御テーブル

特定キーに対して同時に1処理だけ通したい場合は、一意制約をミューテックスとして使う方法があります。ロック用テーブルにキーをINSERTし、競合時は一意制約違反で失敗させます。ただし、処理失敗時に制御行が残らないよう、トランザクション境界と後片付けを設計します。

unique-key-lock-table.sql
CREATE TABLE key_locks (
  lock_key VARCHAR2(200) PRIMARY KEY
);

BEGIN
  INSERT INTO key_locks(lock_key) VALUES (:key);
EXCEPTION
  WHEN DUP_VAL_ON_INDEX THEN
    RAISE_APPLICATION_ERROR(-20010, 'key busy');
END;

-- クリティカルセクション

DELETE FROM key_locks WHERE lock_key = :key;
COMMIT;

Oracleには INSERT ... ON CONFLICT はありません。冪等化や重複吸収は、一意制約、MERGE、例外ハンドリングで設計します。一意制約違反の扱いは ORA-00001の原因と解決方法 と関連します。

DBMS_LOCK:ユーザー定義ロックで二重起動を防ぐ

ジョブやバッチ単位で明示的な排他が必要な場合は DBMS_LOCK が使えます。利用には権限付与が必要なことがあり、戻り値を見て成功、タイムアウト、デッドロック、パラメータエラーを分けます。

dbms-lock-request.sql
DECLARE
  v_handle VARCHAR2(128);
  v_result PLS_INTEGER;
BEGIN
  DBMS_LOCK.ALLOCATE_UNIQUE('JOB:DAILY_AGG', v_handle);

  v_result := DBMS_LOCK.REQUEST(
                lockhandle        => v_handle,
                lockmode          => DBMS_LOCK.X_MODE,
                timeout           => 0,
                release_on_commit => TRUE);

  CASE v_result
    WHEN 0 THEN
      NULL; -- success
    WHEN 1 THEN
      RAISE_APPLICATION_ERROR(-20000, 'lock timeout');
    WHEN 2 THEN
      RAISE_APPLICATION_ERROR(-20001, 'lock deadlock');
    WHEN 3 THEN
      RAISE_APPLICATION_ERROR(-20002, 'parameter error');
    WHEN 4 THEN
      RAISE_APPLICATION_ERROR(-20003, 'already own lock');
    ELSE
      RAISE_APPLICATION_ERROR(-20004, 'illegal lock handle');
  END CASE;

  -- クリティカルセクション
  COMMIT; -- release_on_commit=TRUEならここで解放
END;
/

DBMS_LOCK.REQUEST の戻り値や release_on_commit の設計は重要です。詳しくは DBMS_LOCK完全ガイド、二重起動防止の実装は DBMS_LOCKで二重起動防止する方法 を確認してください。

トランザクション粒度:チャンクごとに安全に確定する

大量更新では、ロックを長時間持ち続けないようチャンク単位で確定します。IDの値で MOD(id, 50000) を見ると、IDが連番でない場合に意図通りになりません。処理件数カウンタでコミット間隔を管理します。

chunk-commit-counter.sql
DECLARE
  v_count PLS_INTEGER := 0;
BEGIN
  FOR r IN (
    SELECT id
    FROM targets
    WHERE processed = 'N'
    ORDER BY id
  ) LOOP
    SAVEPOINT sp_row;
    BEGIN
      UPDATE targets
         SET processed = 'Y',
             processed_at = SYSTIMESTAMP
       WHERE id = r.id;

      v_count := v_count + 1;
    EXCEPTION
      WHEN OTHERS THEN
        ROLLBACK TO sp_row;
    END;

    IF v_count >= 5000 THEN
      COMMIT;
      v_count := 0;
    END IF;
  END LOOP;

  COMMIT;
END;
/

隔離レベル:READ COMMITTEDとSERIALIZABLE

更新系は通常の READ COMMITTED で短いロックにし、厳密な読み取り一貫性が必要な処理だけ SERIALIZABLEREAD ONLY を検討します。SERIALIZABLE で更新競合が起きると ORA-08177 が出るため、再試行設計が必要です。

serializable-read-only.sql
-- 読み取り専用の一貫したスナップショット
SET TRANSACTION READ ONLY;

SELECT ...
FROM report_source;

COMMIT;

ORA-08177 の詳細は ORA-08177の原因と解決方法 を確認してください。

外部副作用:アウトボックスでDB外処理を分離する

メール送信やHTTP通知などの外部副作用は、データベースの行ロックでは守れません。業務DMLと同じトランザクションでアウトボックス表にイベントを記録し、別ワーカーが SKIP LOCKED で取り出して配送します。外部側にも冪等キーを渡し、二重送信を吸収できるようにします。

outbox-skip-locked.sql
INSERT INTO outbox(event_key, event_type, payload, created_at)
VALUES (:event_key, 'ORDER_CREATED', :json_payload, SYSTIMESTAMP);

-- 配送ワーカー側
SELECT id
FROM outbox
WHERE sent_at IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED;

再試行戦略:対象例外を限定してバックオフする

再試行は、ロック競合、デッドロック、シリアライズ競合のような一時障害だけに限定します。未宣言の resource_busydeadlock_detected を使うとコンパイルできないため、PRAGMA EXCEPTION_INIT でORA番号に紐付けます。

retry-with-exception-init.sql
DECLARE
  resource_busy       EXCEPTION;
  deadlock_detected   EXCEPTION;
  cannot_serialize    EXCEPTION;

  PRAGMA EXCEPTION_INIT(resource_busy,     -54);   -- ORA-00054
  PRAGMA EXCEPTION_INIT(deadlock_detected, -60);   -- ORA-00060
  PRAGMA EXCEPTION_INIT(cannot_serialize,  -8177); -- ORA-08177

  v_try PLS_INTEGER := 0;
BEGIN
  LOOP
    v_try := v_try + 1;
    BEGIN
      -- 衝突しやすい更新
      UPDATE targets
         SET processed = 'Y'
       WHERE id = :id;

      COMMIT;
      EXIT;
    EXCEPTION
      WHEN resource_busy OR deadlock_detected OR cannot_serialize THEN
        ROLLBACK;
        IF v_try < 5 THEN
          DBMS_LOCK.SLEEP(POWER(2, v_try) * 0.2);
        ELSE
          RAISE;
        END IF;
    END;
  END LOOP;
END;
/

ORA-00060 は最後のSQLだけがロールバックされるため、アプリ側でトランザクション全体をどう扱うか決める必要があります。詳しくは ORA-00060の原因と解決方法 を確認してください。

ジョブの同時実行制御

DBMS_SCHEDULER の設定だけに頼らず、ジョブ開始時に DBMS_LOCK や一意制約で二重起動を防ぎます。日次や時間帯単位のジョブでは、処理済み範囲、再開点、失敗行を記録し、再実行しても同じ最終状態へ収束するようにします。

  • ジョブ単位の排他キーを決めた
  • 同時起動時は待つのか、即終了するのか決めた
  • 失敗時の再開点を記録している
  • 外部通知はアウトボックスで分離している
  • リトライ回数と上限時間を決めている

監視とトラブルシュート

障害発生後は、待機イベント、ロック待ちセッション、ブロッカーをすぐに確認できるようにします。運用では次のSQLを定番として手元に置いておくと初動が速くなります。

lock-wait-monitor.sql
-- Concurrency待機中のセッション
SELECT sid,
       serial#,
       username,
       event,
       seconds_in_wait,
       blocking_session
FROM v$session
WHERE state = 'WAITING'
  AND wait_class = 'Concurrency';

-- ロック待ちと保有者の対応
SELECT waiter.sid  AS waiter_sid,
       holder.sid  AS holder_sid,
       waiter.type,
       waiter.id1,
       waiter.id2
FROM v$lock waiter
JOIN v$lock holder
  ON waiter.type = holder.type
 AND waiter.id1  = holder.id1
 AND waiter.id2  = holder.id2
WHERE waiter.request > 0
  AND holder.block = 1;

待機が増える場合は、実行計画、インデックス、パーティション設計、ロック順序、長いトランザクションを合わせて確認します。

まとめ

PL/SQLの同時実行制御では、ロック順序を固定し、占有時間を短くし、待つ処理と逃がす処理を使い分けることが重要です。NOWAITSKIP LOCKED、楽観ロック、DBMS_LOCK、一意制約を、用途ごとに選びます。

再試行は ORA-00054ORA-00060ORA-08177 のような一時競合に限定し、PRAGMA EXCEPTION_INIT で明示的にハンドリングします。監視SQLと運用ルールまで含めて設計すると、マルチセッション環境でも詰まりにくいシステムにできます。