PostgreSQL

PostgreSQL のアドバイザリロックでバッチの二重起動を防ぐ

cron → bash → java(jar)で動かすバッチの二重起動を防ぐために、PostgreSQL のアドバイザリロックを使うときのメモです。使い方は簡単ですが、「ロックは DB との接続(セッション)が持っている」ことを分かっていないと、思わぬところでロックが外れたり残ったりします。

アドバイザリロックとは

アプリが自分の都合で使える、意味を持たないロックです。テーブルや行には関係しません。

  • 名前はない。数値のキーで指定する(bigint を1つ、または int を2つ)。番号はバッチごとに自分で決めて管理する。
  • 置き場所は、サーバーの共有メモリにあるロックテーブル。テーブルロックや行ロックと同じ仕組み(ロックマネージャー)で管理される。
  • ディスクにも WAL にも書かれない。サーバーを再起動すると消える。レプリカにも複製されない。
  • 持ち主は、ロックを取ったセッション(その接続を受け持つバックエンドプロセス)。

番号の名前空間はデータベース単位です。スキーマやユーザーでは分かれません。

範囲番号は共通か
スキーマが違う共通
ユーザー(ロール)が違う共通
データベースが違う別々
サーバー(インスタンス)が違う別々

二重起動防止に使うとよい点

  • 異常終了しても自動で外れる。java が落ちたり kill されたりすると接続が切れ、ロックも消える。ロックファイルのように、古いファイルが残って次が動かない、という事故がない。
  • 複数のサーバーをまたいで効く。バッチを動かすサーバーが2台あっても、同じ DB を見ていれば排他できる(flock は1台の中だけ)。
  • テーブルが要らない。管理用のテーブルも、後片付けの処理も要らない。
  • 待たずに結果がわかる。pg_try_advisory_lock() は、取れなければすぐ false を返す。
  • SQL で状態を確認できる。pg_locks を見れば、誰がどの番号を持っているかがわかる。

jar での使い方

処理の頭で接続を取ってロックを取り、最後に接続を閉じればロックも外れます。

jar 起動
 └ 接続を取得
    └ pg_try_advisory_lock(...) → true
       └ 本来の処理
          └ unlock して接続を close()   ← ここでロックが外れる
jar 終了

close() まで行かずに終わっても、ロックは外れます。

終わり方ロック
正常に終わって close()すぐ外れる
例外で終わる、System.exit()JVM が終わるときに OS がソケットを閉じるので外れる
kill、kill -9同じく OS がソケットを閉じるので外れる
サーバーの電源断、LAN ケーブルが抜けるDB 側が切断に気づくまで残る(keepalive の設定しだいで数分~数時間)

Java の例です。ロック用の接続はプールを使わずに直接取り、処理が終わるまで持ち続けます。本来の処理は別の接続(プール)を使って構いません。

// ロックの番号表(区分, ジョブID)
public enum BatchLock {
    DAILY_SALES   (100, 1),
    MONTHLY_CLOSE (100, 2),
    CUSTOMER_SYNC (100, 3);

    final int category, id;
    BatchLock(int category, int id) { this.category = category; this.id = id; }
}
BatchLock key = BatchLock.DAILY_SALES;

// ロック専用の接続(プールを使わない)
try (Connection lockConn = DriverManager.getConnection(url, user, pass)) {
    boolean got;
    try (PreparedStatement ps = lockConn.prepareStatement("SELECT pg_try_advisory_lock(?, ?)")) {
        ps.setInt(1, key.category);
        ps.setInt(2, key.id);
        try (ResultSet rs = ps.executeQuery()) { rs.next(); got = rs.getBoolean(1); }
    }
    if (!got) {
        log.info("すでに実行中のためスキップ");
        System.exit(3);      // 例: スキップ用の終了コード
    }
    try {
        runBatch();          // 本来の処理
    } finally {
        try (PreparedStatement ps = lockConn.prepareStatement("SELECT pg_advisory_unlock(?, ?)")) {
            ps.setInt(1, key.category);
            ps.setInt(2, key.id);
            ps.execute();
        }
    }
}   // 接続を閉じればロックも外れる

close() だけでもロックは外れますが、finally で unlock も書いておくと、後でプールを使う作りに変えてもロックが残らず、意図もコードから読み取れます。「実行中だった」ときの終了コード(0 で正常扱いにするか、専用のコードにするか)とログも、決めておくと運用で迷いません。

注意点

ロックは「接続」に結びついている
  • コネクションプール(HikariCP など): プールの接続は close() してもプールに戻るだけで、セッションは終わらない。unlock し忘れると、ロックがプールの中の接続に残り続ける。
  • PgBouncer の transaction / statement モード: 文やトランザクションごとに裏の接続が入れ替わるので、セッション単位のロックは正しく動かない。直接つなぐか session モードにする。
  • DISCARD ALL: セッションの状態をリセットする命令で、アドバイザリロックも外れる。プールや PgBouncer が接続を返すときに自動で実行することがある。
「つながっているつもり」でロックが外れている

本当にセッションが生きていれば、自分で unlock しない限りロックは外れません。他のセッションからは外せませんし、ROLLBACK でも外れません。
問題になるのは、Java からはつながっているように見えるのに、DB 側のセッションがもう終わっているケースです。JDBC の Connection は、次に SQL を投げるまで切断に気づきません。

  • 管理者が pg_terminate_backend() で切った
  • idle_session_timeout などのタイムアウトで切られた
  • ファイアウォールやロードバランサーが、使われていない接続を切った
  • DB の再起動やフェイルオーバー

この間に次の cron が起動すると、ロックが取れてしまい二重に動きます。処理が長いときは、TCP keepalive(JDBC の tcpKeepAlive=true、サーバー側の tcp_keepalives_*)を設定しておき、処理の途中(ループの区切りなど)で、ロック用の接続を使ってまだロックを持っているか確かめると安心です。

-- 自分のセッションが (100, 1) を持っているか
SELECT count(*) > 0
FROM pg_locks
WHERE locktype = 'advisory'
  AND pid = pg_backend_pid()
  AND classid = 100 AND objid = 1;

セッションが切れていれば SQL 自体が例外になり、ロックが外れていれば false が返ります。どちらでも処理を中止するようにしておきます。

逆に、外れないこともある
  • java が固まった(ハングした)まま接続が生きていると、ロックは持たれたまま。二重起動防止としては正しい動きだが、ハングの検知は別に用意する(実行時間の監視など)。
  • クライアントのサーバーが電源断などで消えると、DB 側は TCP がタイムアウトするまで接続が残っていると思い、しばらくロックが残る。サーバー側の keepalive の設定で短くできる。
セッション単位とトランザクション単位
  • pg_try_advisory_xact_lock()(トランザクション単位)は COMMIT や ROLLBACK で外れる。バッチ全体を1つの長いトランザクションにする必要があり、VACUUM の妨げにもなるので、バッチには普通セッション単位の pg_try_advisory_lock() を使う。
  • セッション単位のロックは、ROLLBACK しても外れない。
  • 同じセッションで2回取ると2回分数えられ、2回 unlock しないと外れない(pg_locks には1行しか出ないので気づきにくい)。
権限で守る仕組みがない

その DB に接続できるユーザーなら、誰でも同じ番号のロックを取れます(関数は初期状態で PUBLIC が実行できる)。うっかり取られると、バッチが「実行中」と判断してスキップし続けます。気になるなら実行権限を絞れます。関数は引数の型ごとに別物なので、使うものすべてに設定が必要です。

REVOKE EXECUTE ON FUNCTION pg_try_advisory_lock(int, int) FROM PUBLIC;
GRANT  EXECUTE ON FUNCTION pg_try_advisory_lock(int, int) TO batch_user;

社内のバッチ用途なら、そこまでせずに番号の管理だけで運用することが多いです。

必ずプライマリにつなぐ

スタンバイでもアドバイザリロックは取れますが、プライマリのロックとは別物なので排他になりません。JDBC なら targetServerType=primary を指定しておきます。本番と検証で接続先の DB が違う、といった場合も、同じ DB につながないとロックは効きません。

番号の決め方(bigint と int 2つ)

名前がないので、バッチごとに自分で好きな番号を割り当てて、その一覧を管理する運用になります。ロックとしての動きは、bigint でも int 2つでも同じです。

int を2つ (int4, int4)bigint を1つ
向いている場面「区分 + 番号」で人が管理する元から1つの大きな数値がある
例(100, 1) = システム100のジョブ1注文 ID など bigint の主キー
pg_locks での見え方classid と objid にそのまま出る上位と下位の32ビットに分かれて出る
  • バッチの二重起動防止なら int 2つ がおすすめ。1つ目の数を「システムの区分」にしておくと、同じ DB を使う別システムと番号がぶつからない。
  • 一覧は1か所(設計書や Java の enum)にまとめ、一度決めた番号は変えず、使い回さない。
  • 同時に動いてほしくない複数のバッチに、わざと同じ番号を付けることもできる。
  • 2つの方式の番号同士はぶつからない。pg_try_advisory_lock(100) と pg_try_advisory_lock(0, 100) は別のロック(pg_locks の objsubid が bigint は 1、int 2つは 2)。ただし混ぜると分かりにくいので、どちらかに決める。

どうしても名前で扱いたいときは、文字列をハッシュで数値に変える方法もあります。

-- PostgreSQL 11 以降(bigint を返す)。古い版は hashtext('...')(int4 を返す)
SELECT pg_try_advisory_lock(hashtextextended('daily_sales_batch', 0));

ただし、別の名前が同じ数値になる可能性がゼロではないこと、ハッシュ関数の結果がバージョンをまたいで変わらないという約束がないこと、pg_locks を見てもどのバッチか分からないことから、番号表で管理するほうが確実です。

psql で試す

psql を開いている間がそのまま1つのセッションなので、取ったロックは psql を終了するまで残ります。端末を2つ開いて試します。

端末A: ロックを取る
SELECT pg_try_advisory_lock(100, 1);
 pg_try_advisory_lock
----------------------
 t
端末B: 確認する
SELECT classid, objid, objsubid, pid, granted
FROM pg_locks
WHERE locktype = 'advisory';

 classid | objid | objsubid |  pid  | granted
---------+-------+----------+-------+---------
     100 |     1 |        2 | 12345 | t

-- B からも取ろうとすると f になる
SELECT pg_try_advisory_lock(100, 1);
 f
端末A: 解放する
SELECT pg_advisory_unlock(100, 1);
 t

-- 全部まとめて外すなら
SELECT pg_advisory_unlock_all();

端末Bでもう一度 pg_locks を見ると、行が消えています。持っていないロックを unlock すると f が返り、WARNING: you don't own a lock of type ExclusiveLock が出ます。\q で psql を終了しても、ロックはすべて外れます。

誰がどの番号を持っているか(別セッションから)
SELECT l.classid  AS key1,
       l.objid    AS key2,
       l.granted,              -- true: 取得済み / false: 取得待ち
       l.pid,
       a.usename,
       a.client_addr,
       a.application_name,
       a.backend_start
FROM pg_locks l
LEFT JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'advisory'
  AND l.database = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY key1, key2;

-- bigint で取ったロックの番号に戻すとき
-- (classid::bigint << 32) | objid::bigint
  • pg_locks(番号と pid)は、どのユーザーからでも見える。
  • pg_stat_activity の詳しい内容(実行中の SQL、接続元など)は、同じユーザーのセッションか、pg_read_all_stats などの権限を持つユーザーにしか見えない。監視用のユーザーには GRANT pg_monitor TO monitor_user; を付けておくと便利。
  • 「pg_try_advisory_lock で取れるか試して、すぐ unlock」という確認はやめておく。試した一瞬に起動したバッチが「実行中」と判断してスキップしてしまう。確認は pg_locks を見る。
バッチのテストに使う
  1. psql で SELECT pg_try_advisory_lock(100, 1); を実行して、ロックを持ったままにする
  2. その状態で jar を起動し、「実行中のためスキップ」になることを確認する
  3. psql で unlock するか \q で終了してから jar を起動し、今度は正常に動くことを確認する

本番でバッチを一時的に止めておきたいときも、psql で先にロックを取っておけば、その間 cron から起動されても処理は動きません(使うなら運用手順として決めておく)。

flock との比べ方

  • バッチが1台のサーバーでしか動かないなら、bash 側の flock だけで済むこともある。プロセスが終わるとカーネルが自動で外すので、こちらも古いロックが残らない。
  • 複数台で動く、または今後そうなるかもしれないなら、アドバイザリロックが向いている。
  • 両方を組み合わせる(flock でサーバー内、アドバイザリロックでサーバー間)のもあり。

まとめると、ロック専用の接続を1本、処理が終わるまで持ち続けることと、その接続が途中で切れないようにする(切れたら気づける)ことが要点です。

スポンサーリンク
タイトルとURLをコピーしました