java-recipes

ホーム 並行処理・メモリ › P-02

P-02: DB を使った ATOMIC 採番(採番テーブル / SELECT FOR UPDATE)

P-01 の AtomicLong は単一 JVM 内でのみ有効です。 複数サーバ・複数 JVM が共通の採番番号を使う場合は、DB のロック機構を活用した採番方式が必要です。

2 つの DB 採番方式

① 採番テーブル方式(UPDATE 方式)

UPDATE seq_table SET current_val = current_val + 1 WHERE seq_name = ?
UPDATE 文は対象行に自動的に行ロックを取得します。 UPDATE 後に SELECT で新しい値を取得するため、実装がシンプルです。最もよく使われる方式です。

② SELECT FOR UPDATE 方式

SELECT current_val FROM seq_table WHERE seq_name = ? FOR UPDATE
SELECT 時に行ロックを取得し、値を読み取ってから UPDATE します。 採番ロジックが複雑なケース(増分が可変など)に向いていますが、 デッドロックのリスクに注意が必要です。

採番テーブルのスキーマ例:
CREATE TABLE seq_table (seq_name VARCHAR(64) PRIMARY KEY, current_val BIGINT DEFAULT 0)

サンプルコード

動作確認に H2 インメモリ DB を使用しています。実務では MySQL / PostgreSQL 等に接続先を変更してください。

Sample.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;

/**
 * DB を使った ATOMIC 採番サンプル(Java 8+)。
 *
 * AtomicLong は単一 JVM 内でのみ有効。
 * 複数サーバ(マルチ JVM)が共通の採番を必要とする場合は DB を使った方式を選ぶ。
 *
 * 動作確認: H2 インメモリ DB を使用。
 * 実務では MySQL / PostgreSQL 等のドライバを CLASSPATH に追加してください。
 */
public class DbAtomicCounterSample {

    // ---- 採番テーブル方式 ----
    //
    // UPDATE 文で採番テーブルのカウンターを 1 増やし、新しい値を返す。
    // UPDATE は行ロックを取得するため、複数 JVM から同時実行しても安全。

    public static long nextVal(Connection conn, String seqName) throws SQLException {
        String updateSql =
            "UPDATE seq_table SET current_val = current_val + 1 WHERE seq_name = ?";
        String selectSql =
            "SELECT current_val FROM seq_table WHERE seq_name = ?";

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

            upd.setString(1, seqName);
            int updated = upd.executeUpdate();
            if (updated == 0) {
                throw new SQLException("採番行が見つかりません: " + seqName);
            }

            sel.setString(1, seqName);
            try (ResultSet rs = sel.executeQuery()) {
                if (rs.next()) {
                    return rs.getLong("current_val");
                }
                throw new SQLException("採番値の取得に失敗: " + seqName);
            }
        }
    }

    // ---- SELECT FOR UPDATE 方式 ----
    //
    // SELECT FOR UPDATE で行をロックしてから読み取り、インクリメントして更新。
    // UPDATE 方式より柔軟だが、デッドロックのリスクに注意。

    public static long nextValWithLock(Connection conn, String seqName)
            throws SQLException {
        String selectForUpdate =
            "SELECT current_val FROM seq_table WHERE seq_name = ? FOR UPDATE";
        String updateSql =
            "UPDATE seq_table SET current_val = ? WHERE seq_name = ?";

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

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

            long next = current + 1;
            upd.setLong(1, next);
            upd.setString(2, seqName);
            upd.executeUpdate();
            return next;
        }
    }

    // ---- テーブル初期化 ----

    public static void setup(Connection conn) throws SQLException {
        try (Statement 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"
                + ")"
            );
        }
        try (PreparedStatement 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();
    }

    public static void main(String[] args) throws Exception {
        // H2 インメモリ DB で動作確認
        // 実務では "jdbc:mysql://localhost:3306/mydb" 等に変更する
        String url = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1";

        try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
            conn.setAutoCommit(false);
            setup(conn);

            System.out.println("=== 採番テーブル方式 ===");
            for (int i = 0; i < 5; i++) {
                conn.setAutoCommit(false);
                try {
                    long val = nextVal(conn, "ORDER_SEQ");
                    System.out.println("注文番号: ORD-" + val);
                    conn.commit();
                } catch (SQLException e) {
                    conn.rollback();
                    throw e;
                }
            }
        }
    }
}

よくあるミス・注意点

⚠️ autoCommit=false を忘れると複数 JVM での安全性が失われる

setAutoCommit(false) にしないと、UPDATE とその後の SELECT の間にコミットが入り 別のスレッドに横取りされる可能性があります。 必ずトランザクション内で実行し、完了後に commit() を呼んでください。

⚠️ ロック待ちによるパフォーマンス低下

大量のリクエストが同時に採番しようとすると、行ロック待ちが発生してパフォーマンスが低下します。 高スループットが必要な場合は、採番をバッチ取得(一度に 100 件確保してメモリに保持)する方式も検討してください。

⚠️ SELECT FOR UPDATE のデッドロック

複数の採番行を同時にロックする処理が複数スレッドから逆順で実行されるとデッドロックが発生します。 採番行を触るトランザクションは短く保ち、他のテーブルのロックと組み合わせないようにしてください。

テストする観点

  • ✅ 単一スレッドで5回採番した結果が 10001〜10005 の連番になるか
  • ✅ 複数スレッドから同時採番した結果に重複がないか(H2 インメモリで並列テスト)
  • ✅ 存在しない seq_name で採番したとき適切な例外・エラー結果が返るか
  • rollback() 後に再採番すると番号が正しく継続されるか(ギャップの確認)
  • ✅ トランザクションが途中で失敗したとき番号が飛ぶことを確認する

GitHub でソースコードを見る →