概要

AtomicLong は単一 JVM 内で完結する採番には最適ですが、複数サーバー(マルチ JVM)から同じ連番体系で採番する場合は使えません。このような場面では DB の採番テーブルに現在値を保持し、UPDATE 文の行ロックで排他制御を行う方式が広く採用されています。この記事では、UPDATE で直接インクリメントする方式と、SELECT FOR UPDATE で行をロックしてから更新する方式の2つを JDBC で実装します。それぞれのトランザクション制御の違い、デッドロックのリスク、コミットのタイミングといった現場で実際に判断が必要になるポイントを整理します。H2 インメモリ DB で動作確認できる完結したコードを示すため、手元ですぐに試せます。

使いどころ

複数の AP サーバーから共通の注文番号体系で採番し、番号の重複を DB の行ロックで防ぐ

バッチサーバーと Web サーバーが同じ請求番号を使う構成で、採番テーブルを共有して一意性を担保する

用途別(注文・請求・出荷)に採番行を分けて管理し、それぞれ独立した連番を払い出す

コード例

DbAtomicCounterDemo.java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class DbAtomicCounterDemo {

    /** UPDATE 方式: 行を直接インクリメントして新しい値を取得 */
    public static long nextVal(Connection conn, String seqName)
            throws SQLException {
        var updateSql = "UPDATE seq_table SET current_val = current_val + 1 "
                      + "WHERE seq_name = ?";
        var selectSql = "SELECT current_val FROM seq_table WHERE seq_name = ?";

        try (var upd = conn.prepareStatement(updateSql);
             var sel = conn.prepareStatement(selectSql)) {

            upd.setString(1, seqName);
            if (upd.executeUpdate() == 0) {
                throw new SQLException("採番行が見つかりません: " + seqName);
            }
            sel.setString(1, seqName);
            try (var rs = sel.executeQuery()) {
                if (rs.next()) {
                    return rs.getLong("current_val");
                }
                throw new SQLException("採番値の取得に失敗: " + seqName);
            }
        }
    }

    /** SELECT FOR UPDATE 方式: 行ロック後に読み取り・更新 */
    public static long nextValWithLock(Connection conn, String seqName)
            throws SQLException {
        var selectForUpdate = """
                SELECT current_val FROM seq_table
                WHERE seq_name = ? FOR UPDATE
                """;
        var updateSql = """
                UPDATE seq_table SET current_val = ?
                WHERE seq_name = ?
                """;

        try (var sel = conn.prepareStatement(selectForUpdate);
             var upd = conn.prepareStatement(updateSql)) {

            sel.setString(1, seqName);
            long current;
            try (var rs = sel.executeQuery()) {
                if (!rs.next()) {
                    throw new SQLException("採番行が見つかりません: " + seqName);
                }
                current = rs.getLong("current_val");
            }
            var next = current + 1;
            upd.setLong(1, next);
            upd.setString(2, seqName);
            upd.executeUpdate();
            return next;
        }
    }

    /** トランザクションヘルパー */
    @FunctionalInterface
    interface TxWork<T> {
        T execute(Connection conn) throws SQLException;
    }

    public static <T> T runInTransaction(Connection conn, TxWork<T> work)
            throws SQLException {
        conn.setAutoCommit(false);
        try {
            T result = work.execute(conn);
            conn.commit();
            return result;
        } catch (SQLException e) {
            conn.rollback();
            throw e;
        }
    }

    public static void main(String[] args) throws Exception {
        var url = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1";

        try (var conn = DriverManager.getConnection(url, "sa", "")) {
            // テーブル準備
            try (var st = conn.createStatement()) {
                st.execute("""
                    CREATE TABLE IF NOT EXISTS seq_table (
                      seq_name    VARCHAR(64) PRIMARY KEY,
                      current_val BIGINT NOT NULL DEFAULT 0
                    )
                    """);
            }
            conn.setAutoCommit(false);
            try (var ps = conn.prepareStatement(
                    "INSERT INTO seq_table (seq_name, current_val) VALUES (?, ?)")) {
                ps.setString(1, "ORDER_SEQ");
                ps.setLong(2, 10000);
                ps.executeUpdate();
            }
            conn.commit();

            // 採番テーブル方式で5回採番
            System.out.println("=== UPDATE 方式 ===");
            for (var i = 0; i < 5; i++) {
                var val = runInTransaction(conn,
                    c -> nextVal(c, "ORDER_SEQ"));
                System.out.println("注文番号: ORD-" + val);
            }
        }
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

var で変数宣言を簡潔にし、テキストブロック(""")で複数行 SQL を読みやすく書ける。トランザクションヘルパーを FunctionalInterface で汎用化できる。

Java 17
// Java 17: テキストブロックで SQL を読みやすく
var selectForUpdate = """
    SELECT current_val FROM seq_table
    WHERE seq_name = ? FOR UPDATE
    """;
try (var sel = conn.prepareStatement(selectForUpdate)) {
    sel.setString(1, seqName);
    try (var rs = sel.executeQuery()) {
        // ...
    }
}

Library Comparison

標準 API(JDBC 採番テーブル)DB 非依存で用途別の採番行を管理したいとき。JDBC だけで完結し、追加依存がない。行ロックの管理と commit タイミングを自前で制御する必要がある。高頻度採番ではボトルネックになりやすい。
DB ネイティブシーケンス(SEQUENCE / AUTO_INCREMENT)単一 DB 製品に依存してよい場合。シーケンスの管理を DB エンジンに任せられる。DB 製品間で構文が異なるため移植性が下がる。用途別の柔軟な管理には向かない場合がある。
MyBatis / JPAO/R マッパーを導入済みで、採番も含めて統一的に管理したいとき。採番だけのために導入するのは過剰。既存の JDBC コードベースに後付けすると混在が複雑になる。

注意点

autoCommit が true のままだと UPDATE 直後にロックが解放され、SELECT で取得する値が他トランザクションの値になる可能性がある。必ず autoCommit=false で使うこと

SELECT FOR UPDATE 方式は、同じテーブルの複数行を異なる順序でロックするとデッドロックのリスクがある。採番テーブルでは1行ずつ操作するのが原則

採番テーブルへのアクセスは短時間で commit して行ロックを開放すること。長いトランザクションに含めると後続のリクエストが待たされる

H2 のインメモリ DB はテスト用。本番では MySQL / PostgreSQL 等のドライバを CLASSPATH に追加し、接続文字列を変更する

DB シーケンス(PostgreSQL の SEQUENCE、MySQL の AUTO_INCREMENT)が使える場合は採番テーブルより簡潔。テーブル方式は DB 非依存や用途別管理が必要な場合に選ぶ

FAQ

UPDATE 方式と SELECT FOR UPDATE 方式はどちらを選ぶべきですか。

UPDATE 方式のほうが単純で安全です。SELECT FOR UPDATE は更新前の値を読み取る必要がある場合に使いますが、デッドロックのリスクが増えるため、採番だけなら UPDATE + SELECT の2文で済ませるのが無難です。

採番テーブルが高負荷でボトルネックになった場合はどうしますか。

一度に複数番号を取得するバッチ採番(例: 100番分をまとめて UPDATE)が有効です。アプリ側でプールしておき、使い切ったら次のバッチを取得する方式で DB アクセス頻度を減らせます。

JVM 再起動時に AtomicLong の初期値を DB から復元するにはどうすればよいですか。

起動時に採番テーブルの current_val を SELECT し、その値で AtomicLong を初期化します。DB 採番と JVM 内採番を併用する場合はバッチ取得方式にして、DB 側を正とするのが安全です。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます