【PL/SQL】DBMS_LOCKで二重起動防止する方法|排他制御・release_on_commit・実務テンプレート

【PL/SQL】DBMS_LOCKを使った排他制御の実装方法 PL/SQL

DBMS_LOCK は、Oracle Database内でユーザー定義ロックを扱うためのPL/SQL標準パッケージです。テーブル行を直接ロックするのではなく、任意の名前に対して排他ロックや共有ロックを取得できます。そのため、PL/SQLバッチの二重起動防止、ジョブ単位の排他、外部連携処理の同時実行抑止に向いています。

ただし、release_on_commit の選び方を間違えると、途中 COMMIT でロックが外れてしまい、守っているつもりの処理が並行実行される危険があります。この記事では、PL/SQL実装パターンに絞って、ALLOCATE_UNIQUEREQUESTRELEASE、戻り値判定、ログ設計、DBMS_SCHEDULER連携まで実務向けに整理します。DBMS_LOCK全体の仕様は Oracle DBMS_LOCK完全ガイド も参考にしてください。

この記事で扱うこと

  • DBMS_LOCKで二重起動を防ぐ基本形
  • ALLOCATE_UNIQUE とロック名設計
  • REQUEST / RELEASE の戻り値
  • release_on_commit と途中COMMITの注意点
  • 本番向けの明示RELEASEテンプレート
  • 実行ログ・スキップ理由・処理時間の記録
  • 行ロック、DBMS_SCHEDULER、DBMS_ALERTとの使い分け
スポンサーリンク

DBMS_LOCKで守る対象

DBMS_LOCKで守るべき対象は、テーブルの1行そのものではなく、論理的な資源です。たとえば「月次請求バッチ」「顧客ID単位の外部API連携」「ファイル出力処理」「キャッシュ更新処理」のように、業務上同時に動かしたくない処理を名前で表します。

向いている用途ジョブ二重起動防止、機能単位の排他、外部リソース利用の直列化。
向かない用途行単位の整合性、重複登録防止、参照中データの更新抑止。
行ロック向き特定行を更新する前提の排他は SELECT FOR UPDATE や一意制約で守ります。
ジョブ管理向き定期実行や履歴管理は DBMS_SCHEDULER と組み合わせます。

行ロックやトランザクション全体の考え方は テーブル・行ロック完全ガイド、OracleのCOMMIT/ROLLBACKや SELECT FOR UPDATEOracleトランザクション完全ガイド も参考になります。

必要な権限

DBMS_LOCK を使うには、実行ユーザーにパッケージ実行権限が必要です。本番ではアプリケーション実行スキーマへ直接広く権限を配るのではなく、ロック用パッケージを作って権限境界を絞る設計も検討します。

grant-dbms-lock.sql
-- DBAまたは権限を持つユーザーで実行する例
GRANT EXECUTE ON DBMS_LOCK TO app_owner;

-- 実務では app_lock パッケージだけを公開し、
-- DBMS_LOCK の直接利用箇所を集約する設計も有効

ALLOCATE_UNIQUEでロック名からハンドルを取得する

DBMS_LOCK.ALLOCATE_UNIQUE は、文字列のロック名からロックハンドルを取得します。同じロック名を使えば、別セッションでも同じ論理ロックを参照できます。Oracle公式ドキュメントでは、新しいロック名が初めて割り当てられると DBMS_LOCK_ALLOCATED に情報が格納されると説明されています。そのため、毎回ランダムなロック名を作るのではなく、安定した名前を設計します。

allocate-unique-lock.sql
DECLARE
  v_handle VARCHAR2(128);
BEGIN
  DBMS_LOCK.ALLOCATE_UNIQUE(
    lockname        => 'APP:BATCH:MONTHLY_BILLING',
    lockhandle      => v_handle,
    expiration_secs => 86400
  );

  DBMS_OUTPUT.PUT_LINE('lock handle=' || v_handle);
END;
/
名前設計の注意: ロック名はアプリケーション内の契約です。短すぎる名前や曖昧な名前にすると、別処理と衝突して不要な待ちが発生します。APP名:資源種別:キー のように規則を決めておくと運用しやすくなります。

REQUESTで排他ロックを取得する

ロックを取得するには DBMS_LOCK.REQUEST を使います。二重起動防止では、X_MODE の排他ロックを、timeout => 0 で即時取得します。取得できなければ他セッションが実行中と判断し、処理をスキップします。

request-x-mode.sql
DECLARE
  v_handle VARCHAR2(128);
  v_result PLS_INTEGER;
BEGIN
  DBMS_LOCK.ALLOCATE_UNIQUE('APP:BATCH:DAILY_EXPORT', v_handle);

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

  IF v_result = 0 THEN
    DBMS_OUTPUT.PUT_LINE('lock acquired');
    -- ここに短い処理を書く
    COMMIT; -- release_on_commit => TRUE なのでここで解放
  ELSIF v_result = 1 THEN
    DBMS_OUTPUT.PUT_LINE('already running');
  ELSE
    DBMS_OUTPUT.PUT_LINE('lock request failed: ' || v_result);
  END IF;
END;
/

この例は短い処理向けです。途中で COMMIT しない処理であれば扱いやすいですが、バッチ中に複数回COMMITする処理では危険です。次の章がこの記事で一番大事です。

途中COMMITがあるならrelease_on_commitをFALSEにする

release_on_commit => TRUE は、COMMIT または ROLLBACK でロックを解放します。つまり、処理の途中でコミットするバッチでは、最初のコミット時点でロックが外れます。その後に別セッションが同じジョブを開始できてしまうため、二重起動防止としては不十分になります。

danger-release-on-commit.sql
-- 危険な例: 途中COMMITでロックが外れる
v_result := DBMS_LOCK.REQUEST(
  lockhandle        => v_handle,
  lockmode          => DBMS_LOCK.X_MODE,
  timeout           => 0,
  release_on_commit => TRUE
);

-- ここでロック取得成功
UPDATE work_table SET status = 'PROCESSING';
COMMIT; -- この時点でロックが解放される

-- この後の長い処理中に、別セッションが同じロックを取得できる可能性がある

途中COMMITがある長時間バッチでは、release_on_commit => FALSE にして、最後に必ず RELEASE します。例外時にも解放を試みるよう、テンプレート化しておくのが安全です。

本番向けテンプレート: 明示RELEASE方式

次は、途中COMMITがあるバッチでも二重起動を防ぎやすいテンプレートです。ロック取得成功時だけ本処理へ進み、正常時・異常時のどちらでも RELEASE を呼びます。

dbms-lock-explicit-release-template.sql
DECLARE
  v_handle VARCHAR2(128);
  v_result PLS_INTEGER;
  v_released PLS_INTEGER;
BEGIN
  DBMS_LOCK.ALLOCATE_UNIQUE('APP:BATCH:MONTHLY_BILLING', v_handle);

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

  IF v_result = 0 THEN
    BEGIN
      -- 途中COMMITがあってもロックは保持される
      UPDATE billing_work
      SET status = 'PROCESSING'
      WHERE status = 'READY';
      COMMIT;

      -- 本処理
      NULL;

      v_released := DBMS_LOCK.RELEASE(v_handle);
      COMMIT;
    EXCEPTION
      WHEN OTHERS THEN
        ROLLBACK;
        v_released := DBMS_LOCK.RELEASE(v_handle);
        RAISE;
    END;
  ELSIF v_result = 1 THEN
    DBMS_OUTPUT.PUT_LINE('他セッションが実行中のためスキップ');
  ELSE
    RAISE_APPLICATION_ERROR(-20001, 'DBMS_LOCK.REQUEST failed: ' || v_result);
  END IF;
END;
/
注意: release_on_commit => FALSE は明示解放が必要です。セッションが切れれば解放されますが、それを前提にした設計は避けます。正常系・例外系の両方で RELEASE を呼び、戻り値もログに残しましょう。

REQUESTとRELEASEの戻り値を判定する

REQUEST の戻り値を 0 だけ見て終わらせると、タイムアウト、デッドロック、パラメータ不正の区別ができません。運用ログでは、少なくとも成功・タイムアウト・デッドロック・その他を分けます。

0成功。ロックを取得できました。
1タイムアウト。指定秒数内に取得できませんでした。
2デッドロック検出。取得順序や同時実行設計を見直します。
3パラメータエラー。モード、タイムアウト、ハンドル指定を確認します。
4/5既に保持している、またはハンドル不正など。ログに残して調査します。
request-return-handler.sql
CASE v_result
  WHEN 0 THEN
    DBMS_OUTPUT.PUT_LINE('LOCK_ACQUIRED');
  WHEN 1 THEN
    DBMS_OUTPUT.PUT_LINE('LOCK_TIMEOUT');
  WHEN 2 THEN
    DBMS_OUTPUT.PUT_LINE('LOCK_DEADLOCK');
  WHEN 3 THEN
    DBMS_OUTPUT.PUT_LINE('LOCK_PARAMETER_ERROR');
  ELSE
    DBMS_OUTPUT.PUT_LINE('LOCK_ERROR=' || v_result);
END CASE;

デッドロックの調査や予防は ORA-00060の原因と解決方法、ロック待ちやNOWAIT系のエラーは ORA-00054完全ガイド も参考になります。

共通パッケージに集約する

DBMS_LOCKを各バッチに直接書くと、タイムアウトや解放漏れの扱いがばらつきます。実務では、ロック取得・解放・戻り値判定を共通パッケージに寄せると安全です。

app-lock-package.sql
CREATE OR REPLACE PACKAGE app_lock AS
  FUNCTION acquire(
    p_lock_name VARCHAR2,
    p_timeout   NUMBER DEFAULT 0
  ) RETURN VARCHAR2;

  PROCEDURE release(
    p_lock_handle VARCHAR2
  );
END app_lock;
/

CREATE OR REPLACE PACKAGE BODY app_lock AS
  FUNCTION acquire(
    p_lock_name VARCHAR2,
    p_timeout   NUMBER DEFAULT 0
  ) RETURN VARCHAR2 IS
    v_handle VARCHAR2(128);
    v_result PLS_INTEGER;
  BEGIN
    DBMS_LOCK.ALLOCATE_UNIQUE(p_lock_name, v_handle);

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

    IF v_result = 0 THEN
      RETURN v_handle;
    ELSIF v_result = 1 THEN
      RETURN NULL;
    ELSE
      RAISE_APPLICATION_ERROR(-20001, 'lock request failed: ' || v_result);
    END IF;
  END;

  PROCEDURE release(p_lock_handle VARCHAR2) IS
    v_result PLS_INTEGER;
  BEGIN
    IF p_lock_handle IS NOT NULL THEN
      v_result := DBMS_LOCK.RELEASE(p_lock_handle);
      IF v_result <> 0 THEN
        RAISE_APPLICATION_ERROR(-20002, 'lock release failed: ' || v_result);
      END IF;
    END IF;
  END;
END app_lock;
/

この形なら、呼び出し側はロックハンドルを保持し、最後に明示解放するだけで済みます。途中COMMITを含むバッチでもロック寿命を読みやすく保てます。

呼び出し側の実装例

共通パッケージを使う側では、取得できなかった場合にスキップし、取得できた場合だけ本処理を実行します。例外時も finally 相当の位置で解放処理を呼びます。

use-app-lock-package.sql
DECLARE
  v_handle VARCHAR2(128);
BEGIN
  v_handle := app_lock.acquire('APP:BATCH:DAILY_EXPORT', 0);

  IF v_handle IS NULL THEN
    DBMS_OUTPUT.PUT_LINE('already running. skip.');
    RETURN;
  END IF;

  BEGIN
    -- 本処理。途中COMMITしてもロックは保持される
    NULL;
    COMMIT;

    app_lock.release(v_handle);
  EXCEPTION
    WHEN OTHERS THEN
      ROLLBACK;
      app_lock.release(v_handle);
      RAISE;
  END;
END;
/

実行ログを残す

二重起動防止は、取得できたかどうかだけでなく、なぜスキップしたのか、何秒処理したのかを追えるようにしておくと運用が楽になります。DBMS_SCHEDULERと組み合わせる場合も、DB標準の実行履歴とは別に業務ログを残すと調査しやすくなります。

app-lock-log-table.sql
CREATE TABLE app_lock_run_log (
  log_id       NUMBER GENERATED BY DEFAULT AS IDENTITY,
  lock_name    VARCHAR2(200) NOT NULL,
  run_status   VARCHAR2(30)  NOT NULL,
  result_code  NUMBER,
  started_at   TIMESTAMP DEFAULT SYSTIMESTAMP,
  finished_at  TIMESTAMP,
  message      VARCHAR2(1000),
  CONSTRAINT pk_app_lock_run_log PRIMARY KEY (log_id)
);

-- 例: ロック取得できずスキップした記録
INSERT INTO app_lock_run_log(lock_name, run_status, result_code, message)
VALUES ('APP:BATCH:DAILY_EXPORT', 'SKIPPED', 1, 'already running');

ジョブ履歴や異常検知まで管理する場合は DBMS_SCHEDULER完全ガイド、PL/SQL側のジョブ履歴管理は ジョブ実行履歴の集中管理と異常検知 が近いテーマです。

DBMS_SCHEDULERと組み合わせる

DBMS_SCHEDULER にもジョブの多重実行を制御する考え方はありますが、複数ジョブが同じ外部リソースを使う、手動実行と定期実行がぶつかる、別名ジョブ同士を同じグループで排他したい、という場合はDBMS_LOCKが便利です。

scheduler-job-with-dbms-lock.sql
CREATE OR REPLACE PROCEDURE run_daily_export IS
  v_handle VARCHAR2(128);
BEGIN
  v_handle := app_lock.acquire('APP:RESOURCE:EXPORT_FILE', 0);

  IF v_handle IS NULL THEN
    -- 既に別ジョブが同じファイル出力を実行中
    RETURN;
  END IF;

  BEGIN
    -- ファイル出力や外部連携
    NULL;
    COMMIT;
    app_lock.release(v_handle);
  EXCEPTION
    WHEN OTHERS THEN
      ROLLBACK;
      app_lock.release(v_handle);
      RAISE;
  END;
END;
/

DBMS_SCHEDULERの高度な運用やCHAIN連携は DBMS_SCHEDULERでジョブ管理を極める も参考になります。

DBMS_ALERTやDBMS_PIPEとの違い

DBMS_LOCK は排他制御のための仕組みであり、通知やメッセージ配送の仕組みではありません。「処理してよいか」を守るならDBMS_LOCK、「処理対象ができたことを知らせる」ならDBMS_ALERTやDBMS_PIPEが近いです。通知と排他を組み合わせる場合は、イベントテーブルに処理対象を保存し、ワーカー起動はALERT、二重処理防止はDBMS_LOCK、という分担にします。

通知系の実装は DBMS_ALERTとDBMS_PIPEの使い方 にまとめています。

共有ロックとCONVERTの使いどころ

読み取り系を複数並行させ、更新系だけ排他にしたい場合は S_MODEX_MODE を使い分けます。既に取得したロックのモードを変えるには CONVERT を使います。ただし、使い方を誤ると待ちやデッドロックを増やすため、まずは単純なX_MODE排他から始めるのが無難です。

shared-and-exclusive-lock.sql
-- 読み取り側
v_result := DBMS_LOCK.REQUEST(
  lockhandle        => v_handle,
  lockmode          => DBMS_LOCK.S_MODE,
  timeout           => 10,
  release_on_commit => FALSE
);

-- 更新前に排他へ変換する例
v_result := DBMS_LOCK.CONVERT(
  lockhandle => v_handle,
  lockmode   => DBMS_LOCK.X_MODE,
  timeout    => 10
);

デッドロックを避ける設計

複数のロックを取得する場合は、全処理で取得順序を統一します。たとえば顧客単位ロックと月次処理ロックを両方取るなら、必ず月次処理ロックを先に取る、という規則にします。規則がないと、セッションAは顧客ロックを持って月次ロック待ち、セッションBは月次ロックを持って顧客ロック待ち、という循環待ちが起きます。

取得順序を固定複数資源を扱う処理では、全プログラムでロック取得順を統一します。
保持時間を短くロック取得後に外部API待ちや長時間I/Oを抱えないようにします。
タイムアウトを短めに待ち続けず、スキップ・リトライ・キュー投入に切り替えます。
ログを残すタイムアウトとデッドロックを分けて記録し、取得順序の見直しに使います。

DBMS_LOCKで守らないほうがよいもの

DBMS_LOCKは便利ですが、データ整合性の最後の砦にするべきではありません。重複登録を防ぐなら一意制約、行更新の衝突を防ぐなら SELECT FOR UPDATE、楽観ロックなら更新日時やバージョン列を使います。DBMS_LOCKは、それらでは表現しづらい「処理単位」「資源単位」の排他に使うときに力を発揮します。

use-row-lock-for-row-consistency.sql
-- 行そのものを更新するなら SELECT FOR UPDATE が自然
SELECT status
INTO v_status
FROM orders
WHERE order_id = p_order_id
FOR UPDATE;

UPDATE orders
SET status = 'PROCESSING'
WHERE order_id = p_order_id;

よくある質問

Q. release_on_commitはTRUEとFALSEのどちらがよいですか?
A. 途中COMMITしない短い処理ならTRUEでも扱えます。途中COMMITがあるバッチや長時間処理ではFALSEにして明示RELEASEするほうが安全です。
Q. DBMS_LOCKで行ロックの代わりになりますか?
A. 代わりにはしないほうがよいです。行の整合性はSELECT FOR UPDATEや制約で守り、処理単位の排他にDBMS_LOCKを使います。
Q. ロック取得に失敗したらリトライすべきですか?
A. 業務要件次第です。二重起動防止ならスキップ、重要処理なら短いリトライ、必ず処理するならキューやジョブ管理へ回します。
Q. セッションが異常終了したらロックは残りますか?
A. セッション終了で保持ロックは解放されます。ただし、それを解放手段として期待せず、正常系・例外系でRELEASEする実装にします。
Q. DBMS_LOCKはRACでも使えますか?
A. Oracleのロック管理を使うため、同じデータベース内のセッション間で論理ロックとして使えます。ただし、名前設計と待ち時間の監視はより重要になります。

まとめ

DBMS_LOCK は、PL/SQLバッチやジョブの二重起動防止に使いやすいユーザー定義ロックです。ALLOCATE_UNIQUE で安定したロック名からハンドルを取得し、REQUEST で排他ロックを取り、処理後に RELEASE で解放します。

実務で特に重要なのは、途中COMMITがある処理では release_on_commit => TRUE を安易に使わないことです。長時間バッチや複数COMMITを含む処理では、release_on_commit => FALSE と明示RELEASEを基本にし、戻り値、処理時間、スキップ理由をログに残しましょう。行ロックや制約、DBMS_SCHEDULER、DBMS_ALERTと役割分担すれば、PL/SQLだけでもかなり堅牢な同時実行制御を組めます。

参考: Oracle Database PL/SQL Packages and Types Reference – DBMS_LOCK