概要
業務システムでは「2つ以上のテーブル更新を、すべて成功するかすべて取り消すかのどちらかにしたい」という要件が頻繁に現れます。たとえば送金処理では、送金元の残高を減らす UPDATE と送金先の残高を増やす UPDATE が片方だけ成功してはなりません。JDBC ではデフォルトで autoCommit が ON のため、各 SQL が即座に確定します。トランザクション制御を行うには autoCommit を OFF にしたうえで、全処理が成功したら commit、失敗したら rollback を呼ぶ必要があります。この記事では送金処理を題材に、autoCommit の切り替え、commit と rollback のタイミング、finally での autoCommit 復元、残高不足チェックといった実務で踏む落とし穴を整理します。
使いどころ
口座間の送金処理で、送金元の減額と送金先の加算を1トランザクションにまとめて整合性を保つ
受注登録で注文ヘッダと明細行を同時に INSERT し、途中失敗時は全行ロールバックする
在庫引当と出荷テーブルへの INSERT を1トランザクションで実行し、在庫不足時に全体を取り消す
コード例
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()));
}
}
}Version Coverage
var による型推論とテキストブロックで記述が簡潔になる。record でアカウント情報を保持すると可読性が上がる。
// 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
注意点
autoCommit を OFF にしたら、finally ブロックで必ず true に戻すこと。戻し忘れると同じ Connection を再利用した際に意図しないトランザクション状態になる
rollback() 自体が SQLException を投げる可能性がある。catch 内で rollback する場合は、rollback の失敗もハンドリングすること
Connection をコネクションプールから取得する場合、autoCommit の初期値はプールの設定に依存する。明示的に setAutoCommit(false) を呼ぶのが安全
トランザクションは短く保つ。長時間ロックを保持すると他のリクエストがブロックされ、デッドロックの原因にもなる
Savepoint を使うと部分的なロールバックが可能だが、対応していない DB ドライバもある。使用前にドライバの仕様を確認すること
FAQ
各 SQL が即座に確定するため、途中で失敗しても前の SQL はロールバックできません。トランザクション制御が無効になります。
はい。プールから取得した Connection の autoCommit 状態はプール設定に依存するため、明示的に設定するのが安全です。
rollback() はトランザクション全体を取り消しますが、rollback(savepoint) は指定地点まで部分的に戻せます。