java-recipes

ホーム データベース(JDBC) › DB-03

DB-03: トランザクション処理(commit / rollback)

トランザクションとは、複数のデータベース操作を「全て成功」か「全て失敗」のどちらかにまとめる仕組みです。 銀行の送金のように「送金元の引き落とし」と「送金先への入金」を必ずセットで完了させたい処理に不可欠です。

ACID 特性とトランザクション

トランザクションは ACID と呼ばれる4つの性質を保証します。 これにより、複数の処理が途中で失敗しても DB が矛盾した状態にならないことが保証されます。

ACID 特性

英語日本語意味
Atomicity原子性全て成功するか、全て取り消されるか、どちらかしかない
Consistency一貫性トランザクション前後でデータの整合性が保たれる
Isolation分離性複数のトランザクションが互いに影響を与えない
Durability永続性コミット後のデータはシステム障害が起きても失われない

トランザクション分離レベル

分離レベルを上げると整合性が高まりますが、同時実行性(スループット)が低下します。 多くの DB では READ_COMMITTED がデフォルトです。

分離レベルダーティリードファジーリードファントムリード
READ_UNCOMMITTED発生あり発生あり発生あり
READ_COMMITTED防止発生あり発生あり
REPEATABLE_READ防止防止発生あり
SERIALIZABLE防止防止防止

サンプルコード

TransactionSample.java
import java.sql.*;

public class TransactionSample {

    public static Connection getConnection() throws SQLException {
        return DriverManager.getConnection(
            "jdbc:h2:mem:txtest;DB_CLOSE_DELAY=-1", "sa", "");
    }

    public static void setup(Connection conn) throws SQLException {
        try (Statement stmt = conn.createStatement()) {
            stmt.execute("CREATE TABLE IF NOT EXISTS accounts (id INT PRIMARY KEY, owner VARCHAR(50), balance INT)");
            stmt.execute("DELETE FROM accounts");
            stmt.execute("INSERT INTO accounts VALUES (1, '田中太郎', 100000)");
            stmt.execute("INSERT INTO accounts VALUES (2, '鈴木花子', 50000)");
        }
    }

    // 送金処理: 2つの UPDATE を1トランザクションで実行
    // 途中で例外が発生すると両方ロールバックされる
    public static void transfer(Connection conn, int fromId, int toId, int amount) throws SQLException {
        // 自動コミットを OFF にする(デフォルトは ON)
        conn.setAutoCommit(false);
        try {
            try (Statement stmt = conn.createStatement()) {
                // 送金元の残高を減らす
                int rows1 = stmt.executeUpdate(
                    "UPDATE accounts SET balance = balance - " + amount + " WHERE id = " + fromId);
                if (rows1 == 0) {
                    throw new SQLException("送金元アカウントが見つかりません: id=" + fromId);
                }

                // 残高不足チェック
                try (ResultSet rs = stmt.executeQuery(
                        "SELECT balance FROM accounts WHERE id = " + fromId)) {
                    if (rs.next() && rs.getInt("balance") < 0) {
                        throw new SQLException("残高不足: id=" + fromId);
                    }
                }

                // 送金先の残高を増やす
                int rows2 = stmt.executeUpdate(
                    "UPDATE accounts SET balance = balance + " + amount + " WHERE id = " + toId);
                if (rows2 == 0) {
                    throw new SQLException("送金先アカウントが見つかりません: id=" + toId);
                }
            }

            conn.commit(); // 全て成功 → コミット
            System.out.println("送金完了: " + amount + " 円");

        } catch (SQLException e) {
            conn.rollback(); // 失敗 → ロールバック(両方の変更が取り消される)
            System.out.println("送金失敗(ロールバック): " + e.getMessage());
            throw e;
        } finally {
            conn.setAutoCommit(true); // 自動コミットを元に戻す
        }
    }

    // 残高照会
    public static void printBalances(Connection conn) throws SQLException {
        try (Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery("SELECT id, owner, balance FROM accounts ORDER BY id")) {
            while (rs.next()) {
                System.out.printf("  id=%d %s: %,d 円%n",
                    rs.getInt("id"), rs.getString("owner"), rs.getInt("balance"));
            }
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection conn = getConnection()) {
            setup(conn);

            System.out.println("=== 初期残高 ===");
            printBalances(conn);

            System.out.println("\n=== 正常送金(田中→鈴木 30,000円)===");
            try {
                transfer(conn, 1, 2, 30000);
            } catch (SQLException e) { /* 処理済み */ }
            printBalances(conn);

            System.out.println("\n=== 残高不足で失敗(田中→鈴木 200,000円)===");
            try {
                transfer(conn, 1, 2, 200000);
            } catch (SQLException e) { /* 処理済み */ }
            System.out.println("ロールバック後の残高:");
            printBalances(conn);
        }
    }
}

Java 8 では setAutoCommit(false) → 処理 → commit() または rollback() → finally で setAutoCommit(true) のパターンが基本です。rollback() も try-catch で囲んで例外を握りつぶさないよう注意してください。

よくあるミス・注意点

⚠️ setAutoCommit(false) したら必ず commit() か rollback() を呼ぶ

setAutoCommit(false) にしたままcommit()rollback() も呼ばずに接続を閉じると、 DB によってはトランザクションが宙ぶらりんになり、ロックが長時間保持されたままになります。 必ず finally ブロックで処理を完結させましょう。

⚠️ Connection を返す前に setAutoCommit(true) でリセットする

コネクションプールを使う環境では、一度使った Connection が他のリクエストで再利用されます。setAutoCommit(false) の状態が残ったまま返却されると、 次のリクエストが意図せずトランザクション中になります。finally で必ずsetAutoCommit(true) に戻してください。

⚠️ 分離レベルを上げるとデッドロックが発生しやすくなる

SERIALIZABLE など高い分離レベルでは複数のトランザクションが互いにロックを待ち合い、 デッドロック(永久に進まない状態)が発生しやすくなります。 実務では READ_COMMITTED を使い、 必要な箇所だけ SELECT FOR UPDATE などで明示的にロックするのが一般的です。

⚠️ 長いトランザクションは他の処理をブロックする

トランザクション中は関連する行やテーブルにロックがかかります。 処理が長くなるほど他のリクエストを待たせる時間が増えます。 トランザクションの範囲はできるだけ短くし、ループ内で大量に処理するときは一定件数ごとに commit() するバッチコミットを検討しましょう。

テストする観点

  • 正常送金後、送金元の残高が減り、送金先の残高が増えていること
  • 残高不足で失敗したとき、ロールバック後に両方の残高が変わっていないこと
  • コミット後に接続を閉じて再取得しても、データが永続化されていること
  • 存在しない送金元 ID を指定したとき、ロールバックが行われること(境界値)
  • 送金額が 0 のとき、残高が変わらずコミットされること(境界値)
  • rollback() が呼ばれた後、同じ接続で次の処理が正常に動作すること

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