0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostgreSQLのAdvisory Lockでバッチ二重起動を防ぐ:SQLとJava実装

0
Posted at

個人開発しているもの

日々の開発で得た知識を発信しながら、「あったら便利」「作ってみたい」と思ったアイデアを形にしています。

これまでに、グループ旅行アプリ Trip Supporter、スコア管理サービス PointPia、リアルタイム共有サービス JustNow などを開発しました。

さらに、刺繍やオリジナルキャラクターを中心としたアパレルブランド MAD TOMMY WITH... も運営しています。

開発したサービスや技術記事は chicchi.work で紹介しています。

定期バッチを複数のアプリケーションインスタンスで動かしていると、「同じ日の集計が2台で同時に始まる」という問題が起きます。アプリ側のフラグだけでは、各インスタンスが別々のメモリを持つため防ぎきれません。

PostgreSQLを共通で利用しているなら、**Advisory Lock(アドバイザリロック)**という「アプリが意味を決める排他ロック」が選択肢になります。この記事では、SQLで競合を再現し、Java/Springから安全に扱う方法と、使ってはいけないケースを整理します。

対象はPostgreSQLの一般的なAdvisory Lock機能です。2026年11月7日にPostgreSQL公式ドキュメントのロック仕様と関数を確認しています。以下のコードは説明用であり、本番DBでの負荷試験を実施した結果ではありません。

1. 普通の行ロックと何が違う?

通常の SELECT ... FOR UPDATE は、テーブル内の実在する行をロックします。一方、Advisory Lockは「日次集計バッチ=7001」のように任意の整数キーに意味を与えてロックできます。管理用テーブルにロック行を事前作成する必要はありません。

PostgreSQLは、Advisory Lockをセッション単位とトランザクション単位の2種類で提供しています。

種類 代表関数 解除タイミング 向く用途
トランザクション単位 pg_try_advisory_xact_lock COMMIT / ROLLBACK DB更新を含む短い処理
セッション単位 pg_try_advisory_lock 明示的なunlock / 接続終了 接続全体で排他する必要がある処理

try 付き関数は待機せず、取得できたら true、他の接続が同じキーを保持していて取得できなければ false を返します。取得に失敗したバッチを「今回はスキップ」と扱いたい場合に便利です。

注意:このロックはPostgreSQLが勝手に業務処理を止めてくれる仕組みではありません。競合するすべての実行経路が同じデータベースの同じキーを取得する約束を守る必要があります。

2. 2つのSQLセッションで動作を確認

psql などで同じPostgreSQLデータベースに接続する端末A・Bを用意します。キー 7001 は例示用です。実環境ではジョブ別に衝突しないキー管理を設計します。

端末A:先にロックを取得し、トランザクションを開いたままにする

BEGIN;
SELECT pg_try_advisory_xact_lock(7001);
-- true: このトランザクションがロックを取得

端末B:別接続で同じキーを試す

BEGIN;
SELECT pg_try_advisory_xact_lock(7001);
-- false: 端末Aが保持中。ここでは処理をスキップ

端末Aで終了

COMMIT;
-- ロックは自動的に解除される

端末Bで再度試す

SELECT pg_try_advisory_xact_lock(7001);
-- true: 空いたロックを取得できる
ROLLBACK;
-- 端末Bのロックも解除

BEGIN はトランザクション開始、COMMIT は確定、ROLLBACK は取り消しです。今回の実験ではトランザクションが開いている間だけ排他されます。BEGIN を省いて自動コミットで1文だけ実行すると、ロックはその文のトランザクション終了時に解放され、後続バッチを保護できません。

3. Java/Springでは同じトランザクションで仕事をする

以下はSpringの JdbcTemplate と TransactionTemplate を使う例です。JdbcTemplate はSQL実行を簡潔にする部品、TransactionTemplate は処理の範囲をトランザクションで囲む部品です。

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

@Service
public class DailySummaryJob {
    private static final long LOCK_KEY = 7001L;

    private final JdbcTemplate jdbc;
    private final TransactionTemplate tx;

    public DailySummaryJob(
            JdbcTemplate jdbc,
            PlatformTransactionManager transactionManager) {
        this.jdbc = jdbc;
        this.tx = new TransactionTemplate(transactionManager);
    }

    // true: 実行した / false: 他の実行が保持中だった
    public boolean executeOnceAtATime() {
        Boolean executed = tx.execute(status -> {
            Boolean acquired = jdbc.queryForObject(
                    "SELECT pg_try_advisory_xact_lock(?)",
                    Boolean.class,
                    LOCK_KEY);

            if (!Boolean.TRUE.equals(acquired)) {
                return false;
            }

            // 同じトランザクション内でDB更新する
            jdbc.update(
                    "INSERT INTO batch_execution_log " +
                    "(job_name, executed_at) VALUES (?, CURRENT_TIMESTAMP)",
                    "daily-summary");
            return true;
        });

        return Boolean.TRUE.equals(executed);
    }
}

実験用テーブルは次のように準備できます。

CREATE TABLE batch_execution_log (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    job_name text NOT NULL,
    executed_at timestamptz NOT NULL
);

ロック取得と INSERT は同じトランザクション・同じDB接続を使う必要があります。標準的なJDBCトランザクション設定で、JdbcTemplate とトランザクションマネージャーが同じ DataSource を参照していることを確かめてください。

TransactionTemplate.execute(...) の終了時にコミットまたはロールバックされ、取得できたトランザクション単位のロックは解放されます。バッチの実行開始が競合したとき、後から来た側は待たずに false で終了できます。

4. ここがハマりどころ

ロック取得後すぐCOMMITすると意味がない

BEGIN;
SELECT pg_try_advisory_xact_lock(7001);
COMMIT;
-- ここから集計を始めても、すでにロックはない

集計をロック下で守りたいなら、集計の処理が終わるまでトランザクションを維持しなければなりません。一方、外部API通信や数分以上かかる作業を長いDBトランザクションで囲むのも危険です。DBトランザクションの長期化が許容できない場合は、リース期限を持つジョブ管理テーブルやキューによる所有権管理を検討します。

セッション単位のロックと接続プール

pg_try_advisory_lock(xact のない関数)は、COMMIT しても解除されません。アプリがコネクションプールへ接続を返してもセッションが生きていれば、ロックが残り得ます。使う場合は取得した接続で必ず対応するunlockを行うなど、接続寿命と例外経路を明確にしてください。トランザクション単位にできる短いDB処理なら xact 版の方が扱いやすいです。

「二重起動防止」と「1日1回」は別物

ロックは同時実行を防ぐだけです。1つ目の処理が終了した後、2つ目が再度起動すればロックは取得できます。「2026-11-07の日次集計は1回だけ」を保証したいなら、日付などにユニーク制約を付けた実行記録テーブルも組み合わせます。

また、ロックを取らない別の実行経路があれば、そちらは止められません。すべてのワーカーに共通のキー命名規則が必要です。

ロックの観察

SELECT pid, mode, granted
FROM pg_locks
WHERE locktype = 'advisory';

pg_locks はPostgreSQLの現在のロック状態を調べるシステムビューです。環境によって閲覧できる詳細は権限に左右されます。問題調査時には「誰が持っているか」「待機しているか」を追う手掛かりになります。

5. 使い分けの目安

  • 短いDB更新の排他:pg_try_advisory_xact_lock とトランザクションを組み合わせる。
  • 既存行の更新競合:SELECT ... FOR UPDATE や SKIP LOCKED を検討する。行をロックしたいならこちらが自然。
  • 長時間の外部処理:リース方式やジョブキューを検討し、DB接続を長時間占有しないようにする。
  • 再実行による重複そのものの禁止:ユニーク制約と冪等性(同じ要求を複数回処理しても結果を壊さない設計)を併用する。

Advisory Lockは小さなSQLで分散したアプリの同時実行だけを調整できます。便利ですが、トランザクション境界や接続プールを誤ると意図した排他になりません。先に2接続でロックの寿命を再現し、それからアプリへ組み込むと切り分けやすくなります。

参考資料(2026年11月7日確認)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?