概要

業務システムでは「2つ以上のテーブル更新を、すべて成功するかすべて取り消すかのどちらかにしたい」という要件が頻繁に現れます。たとえば送金処理では、送金元の残高を減らす UPDATE と送金先の残高を増やす UPDATE が片方だけ成功してはなりません。JDBC ではデフォルトで autoCommit が ON のため、各 SQL が即座に確定します。トランザクション制御を行うには autoCommit を OFF にしたうえで、全処理が成功したら commit、失敗したら rollback を呼ぶ必要があります。この記事では送金処理を題材に、autoCommit の切り替え、commit と rollback のタイミング、finally での autoCommit 復元、残高不足チェックといった実務で踏む落とし穴を整理します。

使いどころ

口座間の送金処理で、送金元の減額と送金先の加算を1トランザクションにまとめて整合性を保つ

受注登録で注文ヘッダと明細行を同時に INSERT し、途中失敗時は全行ロールバックする

在庫引当と出荷テーブルへの INSERT を1トランザクションで実行し、在庫不足時に全体を取り消す

コード例

送金処理でトランザクションの commit / rollback を実装する
import java.sql.*;
import java.util.ArrayList;
import java.util.List;

public class TransactionSample {

    record Account(int id, String owner, int balance) {}

    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 (var 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)");
        }
    }

    public static void transfer(Connection conn, int fromId, int toId,
            int amount) throws SQLException {
        conn.setAutoCommit(false);
        try {
            var withdraw = "UPDATE accounts SET balance = balance - ? WHERE id = ?";
            try (var pstmt = conn.prepareStatement(withdraw)) {
                pstmt.setInt(1, amount);
                pstmt.setInt(2, fromId);
                if (pstmt.executeUpdate() == 0) {
                    throw new SQLException(
                        "送金元アカウントが見つかりません: id=" + fromId);
                }
            }

            var checkSql = "SELECT balance FROM accounts WHERE id = ?";
            try (var pstmt = conn.prepareStatement(checkSql)) {
                pstmt.setInt(1, fromId);
                try (var rs = pstmt.executeQuery()) {
                    if (rs.next() && rs.getInt("balance") < 0) {
                        throw new SQLException("残高不足: id=" + fromId);
                    }
                }
            }

            var deposit = "UPDATE accounts SET balance = balance + ? WHERE id = ?";
            try (var pstmt = conn.prepareStatement(deposit)) {
                pstmt.setInt(1, amount);
                pstmt.setInt(2, toId);
                if (pstmt.executeUpdate() == 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 List<Account> getBalances(Connection conn)
            throws SQLException {
        var results = new ArrayList<Account>();
        try (var stmt = conn.createStatement();
             var rs = stmt.executeQuery(
                 "SELECT id, owner, balance FROM accounts ORDER BY id")) {
            while (rs.next()) {
                results.add(new Account(
                    rs.getInt("id"), rs.getString("owner"),
                    rs.getInt("balance")));
            }
        }
        return results;
    }

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

            System.out.println("=== 初期残高 ===");
            getBalances(conn).forEach(a ->
                System.out.printf("  id=%d %s: %,d 円%n",
                    a.id(), a.owner(), a.balance()));

            System.out.println("\n=== 正常送金(田中 → 鈴木 30,000円)===");
            try {
                transfer(conn, 1, 2, 30000);
            } catch (SQLException e) { /* 処理済み */ }
            getBalances(conn).forEach(a ->
                System.out.printf("  id=%d %s: %,d 円%n",
                    a.id(), a.owner(), a.balance()));

            System.out.println("\n=== 残高不足(田中 → 鈴木 200,000円)===");
            try {
                transfer(conn, 1, 2, 200000);
            } catch (SQLException e) { /* 処理済み */ }
            System.out.println("ロールバック後の残高:");
            getBalances(conn).forEach(a ->
                System.out.printf("  id=%d %s: %,d 円%n",
                    a.id(), a.owner(), a.balance()));
        }
    }
}

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

Version Coverage

var による型推論とテキストブロックで記述が簡潔になる。record でアカウント情報を保持すると可読性が上がる。

Java 17
// Java 17: var + record で簡潔に
record Account(int id, String owner, int balance) {}
var conn = getConnection();
conn.setAutoCommit(false);
try (var stmt = conn.createStatement()) {
    stmt.executeUpdate("UPDATE accounts SET balance = balance - 30000 WHERE id = 1");
    stmt.executeUpdate("UPDATE accounts SET balance = balance + 30000 WHERE id = 2");
    conn.commit();
} catch (SQLException e) {
    conn.rollback();
    throw e;
} finally {
    conn.setAutoCommit(true);
}

Library Comparison

Pure JDBC トランザクションフレームワークなしで明示的にトランザクション境界を制御したい場合。制御が明確だが、commit/rollback/autoCommit 復元の管理コードが必要。
Spring @TransactionalSpring Boot でアノテーションベースのトランザクション管理を行う場合。宣言的で簡潔だが、プロキシの仕組みを理解していないと意図しない挙動になる。
JTA(Java Transaction API)複数データソースにまたがる分散トランザクションが必要な場合。2相コミットに対応するが、構成が重く、導入コストが高い。

注意点

autoCommit を OFF にしたら、finally ブロックで必ず true に戻すこと。戻し忘れると同じ Connection を再利用した際に意図しないトランザクション状態になる

rollback() 自体が SQLException を投げる可能性がある。catch 内で rollback する場合は、rollback の失敗もハンドリングすること

Connection をコネクションプールから取得する場合、autoCommit の初期値はプールの設定に依存する。明示的に setAutoCommit(false) を呼ぶのが安全

トランザクションは短く保つ。長時間ロックを保持すると他のリクエストがブロックされ、デッドロックの原因にもなる

Savepoint を使うと部分的なロールバックが可能だが、対応していない DB ドライバもある。使用前にドライバの仕様を確認すること

FAQ

autoCommit を false にし忘れるとどうなりますか?

各 SQL が即座に確定するため、途中で失敗しても前の SQL はロールバックできません。トランザクション制御が無効になります。

コネクションプール使用時も autoCommit の管理は必要ですか?

はい。プールから取得した Connection の autoCommit 状態はプール設定に依存するため、明示的に設定するのが安全です。

Savepoint と通常の rollback の違いは何ですか?

rollback() はトランザクション全体を取り消しますが、rollback(savepoint) は指定地点まで部分的に戻せます。

関連書籍

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

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