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

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

アプリケーションの同時実行制御をPL/SQLだけで実現したい場合、DBMS_LOCKは強力な選択肢になります。テーブル行ロックでは表現しづらい「任意の名前に対する排他」や「ジョブ名ごとの同時実行抑止」を、データ更新を伴わずに安全に行えます。ここではDBMS_LOCKの基本API、名前からロックハンドルを生成する方法、排他・共有の使い分け、タイムアウトやコミット動作の違い、実務テンプレートまでを解説します。

基本概念と主要API

DBMS_LOCKは「ハンドル」と呼ばれる識別子を介してアプリケーションロックを取得・解放します。まずDBMS_LOCK.ALLOCATE_UNIQUEで一意なロックハンドルを取得し、そのハンドルを使ってDBMS_LOCK.REQUESTでロックを取り、処理後にDBMS_LOCK.RELEASEで解放します。モードは排他(X)と共有(S)などがあり、タイムアウト秒数やコミット時の解放動作を指定できます。

名前からロックを作る:ALLOCATE_UNIQUE

アプリ名やジョブ名などの「文字列」から永続的に同じロックハンドルを得るのが定石です。こうしておくと、別セッションでも同じ名前で同じロック対象を参照できます。

DECLARE
  v_handle VARCHAR2(128);
BEGIN
  DBMS_LOCK.ALLOCATE_UNIQUE(
    lockname        => 'JOB:MONTHLY_BILLING',  -- 論理名
    lockhandle      => v_handle,
    expiration_secs => 86400                    -- 未使用ハンドルの保持期限(秒)
  );
  DBMS_OUTPUT.PUT_LINE('HANDLE='||v_handle);
END;
/

排他ロックを取得してクリティカルセクションを守る

同名ジョブの二重起動を防ぎたい、あるいは特定のメンテ作業を単独実行させたい、といった用途では排他(X_MODE)を要求します。

DECLARE
  v_handle  VARCHAR2(128);
  v_result  PLS_INTEGER;
BEGIN
  DBMS_LOCK.ALLOCATE_UNIQUE('JOB:MONTHLY_BILLING', v_handle);

  v_result := DBMS_LOCK.REQUEST(
    lockhandle        => v_handle,
    lockmode          => DBMS_LOCK.X_MODE,  -- 排他
    timeout           => 0,                 -- 0=即時復帰(待たない)
    release_on_commit => TRUE                -- COMMITで自動解放
  );

  IF v_result = 0 THEN
    -- ここがクリティカルセクション
    BEGIN
      -- 本処理
      NULL; -- 実処理に置換
      COMMIT;  -- TRUEのためここで解放される
    EXCEPTION
      WHEN OTHERS THEN
        ROLLBACK; -- 失敗時はロックも解放される(TRUEの場合)
        RAISE;
    END;
  ELSIF v_result = 1 THEN
    DBMS_OUTPUT.PUT_LINE('ほかのセッションが実行中のためスキップ(タイムアウト)');
  ELSIF v_result = 2 THEN
    DBMS_OUTPUT.PUT_LINE('デッドロック検出により取得できず');
  ELSE
    DBMS_OUTPUT.PUT_LINE('ロック取得エラー code='||v_result);
  END IF;
END;
/

REQUESTの戻り値は0で成功、1でタイムアウト、2でデッドロック、その他はパラメータ不正やハンドル無効などです。タイムアウトを0にすれば「二重起動なら即スキップ」、正の秒数を指定すれば「一定時間だけ待って諦める」という動作にできます。

コミット時の解放動作:release_on_commit

release_on_commitをTRUEにするとCOMMIT/ROLLBACK時にロックが自動解放されます。クリティカルセクション内でDMLを行い、トランザクション単位でロック寿命を揃えたい場合に便利です。FALSEを選ぶとセッションが続く限りロックが残るため、明示的にDBMS_LOCK.RELEASEで解放する必要があります。長時間にわたる非DML処理や、複数トランザクションをまたいで保護したい作業で有効ですが、解放漏れに注意してください。

-- 明示解放の例(release_on_commit => FALSE)
DECLARE
  v_handle VARCHAR2(128);
  v_res    PLS_INTEGER;
BEGIN
  DBMS_LOCK.ALLOCATE_UNIQUE('MAINT:REINDEX', v_handle);
  v_res := DBMS_LOCK.REQUEST(v_handle, DBMS_LOCK.X_MODE, 300, FALSE);
  IF v_res = 0 THEN
    BEGIN
      -- DMLを跨ぐ長時間処理
      NULL;
      COMMIT;  -- ここでは解放されない
      DBMS_LOCK.RELEASE(v_handle); -- 明示解放
    EXCEPTION
      WHEN OTHERS THEN
        DBMS_LOCK.RELEASE(v_handle); -- 例外時も解放を試みる
        RAISE;
    END;
  END IF;
END;
/

共有ロックで読み取りを並行させ、更新時だけ排他にする

読み取り系を複数並行して許し、更新系のときだけ排他にするなら、S_MODE(共有)とX_MODE(排他)の使い分けが有効です。共有ロック同士は共存し、排他ロックは単独になります。

-- 共有ロック(読み取りセクション)
v_res := DBMS_LOCK.REQUEST(v_handle, DBMS_LOCK.S_MODE, 10, TRUE);

-- 排他ロック(更新セクション)
v_res := DBMS_LOCK.REQUEST(v_handle, DBMS_LOCK.X_MODE, 10, TRUE);

既にS_MODEを保持している状態からX_MODEへ格上げしたい場合はDBMS_LOCK.CONVERTを使います。必要な間だけXへ上げ、終わったらSへ戻す設計にすると待ち時間を最小化できます。

名前設計と衝突回避のコツ

ロック名はシステム内で一意になるよう命名スキームを揃えます。例えば「サブシステム:資源種類:キー」のように接頭辞で意味を分け、レコード単位の保護ならプライマリキーを含めます。これにより不必要な広い粒度の競合を避け、並行性を高く保てます。

-- 例:顧客ID単位の排他
DBMS_LOCK.ALLOCATE_UNIQUE('CUSTOMER:UPDATE:'||p_customer_id, v_handle);

実務テンプレート:二重起動防止ラッパー

毎回正しく取得・解放・例外処理を書くのは面倒なので、共通パッケージにカプセル化しておくと安全です。

CREATE OR REPLACE PACKAGE app_lock AS
  FUNCTION try_run(p_name VARCHAR2, p_timeout NUMBER DEFAULT 0) RETURN BOOLEAN;
  PROCEDURE release(p_name VARCHAR2);
END;
/
CREATE OR REPLACE PACKAGE BODY app_lock AS
  FUNCTION handle_of(p_name VARCHAR2) RETURN VARCHAR2 IS
    v_handle VARCHAR2(128);
  BEGIN
    DBMS_LOCK.ALLOCATE_UNIQUE(p_name, v_handle);
    RETURN v_handle;
  END;
  FUNCTION try_run(p_name VARCHAR2, p_timeout NUMBER) RETURN BOOLEAN IS
    v_res    PLS_INTEGER;
    v_handle VARCHAR2(128);
  BEGIN
    v_handle := handle_of(p_name);
    v_res := DBMS_LOCK.REQUEST(v_handle, DBMS_LOCK.X_MODE, p_timeout, TRUE);
    RETURN v_res = 0;
  END;
  PROCEDURE release(p_name VARCHAR2) IS
    v_handle VARCHAR2(128);
  BEGIN
    v_handle := handle_of(p_name);
    DBMS_LOCK.RELEASE(v_handle);
  EXCEPTION
    WHEN OTHERS THEN NULL; -- 解放失敗は握りつぶし(ログ推奨)
  END;
END;
/
-- 使い方
DECLARE
  v_ok BOOLEAN;
BEGIN
  v_ok := app_lock.try_run('JOB:DAILY_EXPORT', 0);
  IF v_ok THEN
    BEGIN
      -- 本処理
      NULL;
      COMMIT; -- 自動解放
    EXCEPTION
      WHEN OTHERS THEN
        ROLLBACK;
        RAISE;
    END;
  ELSE
    DBMS_OUTPUT.PUT_LINE('他セッションが実行中のため終了');
  END IF;
END;
/

デッドロックとタイムアウトの実務対応

複数資源に対する連鎖取得では取得順序を全処理で統一し、循環待ちを避けます。REQUESTのタイムアウトは短め(数秒〜数十秒)にして、取得できない場合はリトライやキュー投入へ切り替える設計が安定します。戻り値は必ず判定し、タイムアウト(1)とその他のエラーを分けてログ化します。

表ロック・行ロックとの使い分け

行レベルの整合性を保証したい処理は従来どおりSELECT … FOR UPDATEや一意制約で守り、ジョブ単位や機能単位の広い粒度の「起動排他」はDBMS_LOCKに任せるのが合理的です。両者を混在させる場合は取得順で待ち合わせやすい側から先に取り、ロック保持時間を短く保つのがコツです。

まとめ

DBMS_LOCKはテーブルに手を触れずに「名前付きの資源」へ排他や共有のロックを与えられる仕組みです。ALLOCATE_UNIQUEで安定したハンドルを得て、REQUESTのモード・タイムアウト・release_on_commitで寿命を設計し、テンプレート化して二重起動防止やメンテ排他を安全に実装できます。行ロックと役割分担し、取得順序と解放の徹底、戻り値の適切な扱いを徹底すれば、PL/SQLだけで堅牢な同時実行制御を実現できます。