【JSPウェブアプリ】トランザクション管理の実践

2025-08-05

はじめに

トランザクション管理は、データベースを扱うアプリケーション開発において不可欠な概念です。特に金融システムや在庫管理システムなど、データの整合性が重要なアプリケーションでは、適切なトランザクション管理が必須となります。本記事では、JSP/Servlet環境でのトランザクション管理について、MySQLをメインに詳しく解説します。

トランザクションの基本概念

トランザクションとは

トランザクションとは、複数のデータベース操作を1つの論理的な作業単位としてまとめたものです。ACID特性(後述)を保証することで、データの整合性を維持します。

ACID特性

特性説明
Atomicity(原子性)トランザクション内の操作はすべて実行されるか、または全く実行されないかのどちらか
Consistency(一貫性)トランザクションはデータベースを一貫した状態から別の一貫した状態へ移行させる
Isolation(分離性)並行実行されるトランザクションは互いに干渉しない
Durability(永続性)一度コミットされたトランザクションの結果は永続的に保存される

トランザクションの状態遷移図

[開始]
  ↓
[アクティブ] ←→ [部分コミット]
  ↓              ↓
[失敗]        [コミット]
  ↓
[アボート]

JSP/Servletでのトランザクション管理基本

基本的なトランザクションの流れ

  1. 自動コミットを無効化 (setAutoCommit(false))
  2. 複数のSQL文を実行
  3. すべて成功したらコミット (commit())
  4. 失敗したらロールバック (rollback())

基本的な実装例

Connection conn = null;
try {
    conn = dataSource.getConnection();
    conn.setAutoCommit(false); // 自動コミットを無効化

    // トランザクション内の処理1
    try (PreparedStatement stmt1 = conn.prepareStatement("UPDATE accounts SET balance = balance - ? WHERE id = ?")) {
        stmt1.setBigDecimal(1, transferAmount);
        stmt1.setInt(2, fromAccountId);
        stmt1.executeUpdate();
    }

    // トランザクション内の処理2
    try (PreparedStatement stmt2 = conn.prepareStatement("UPDATE accounts SET balance = balance + ? WHERE id = ?")) {
        stmt2.setBigDecimal(1, transferAmount);
        stmt2.setInt(2, toAccountId);
        stmt2.executeUpdate();
    }

    conn.commit(); // 両方の更新が成功したらコミット
} catch (SQLException e) {
    if (conn != null) {
        try {
            conn.rollback(); // エラーが発生したらロールバック
        } catch (SQLException ex) {
            ex.printStackTrace();
        }
    }
    throw new RuntimeException("Transfer failed", e);
} finally {
    if (conn != null) {
        try {
            conn.setAutoCommit(true); // 自動コミットを元に戻す
            conn.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

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

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

分離レベルダーティリード非再現読み取りファントムリード
READ UNCOMMITTED発生する発生する発生する
READ COMMITTED発生しない発生する発生する
REPEATABLE READ (MySQLデフォルト)発生しない発生しない発生する
SERIALIZABLE発生しない発生しない発生しない

分離レベルの設定方法

// 接続ごとに設定
conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);

// データソースレベルで設定(接続プール)
dataSource.setDefaultTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);

実践的なトランザクション管理パターン

サービス層でのトランザクション管理

DAO層ではなく、サービス層でトランザクションを管理するのがベストプラクティスです。

public class BankService {
    private AccountDAO accountDao;

    public BankService(AccountDAO accountDao) {
        this.accountDao = accountDao;
    }

    public void transferFunds(int fromAccountId, int toAccountId, BigDecimal amount) 
            throws InsufficientFundsException, SQLException {

        Connection conn = null;
        try {
            conn = DataSourceManager.getConnection();
            conn.setAutoCommit(false);

            // DAOに接続を渡す
            accountDao.setConnection(conn);

            // 残高チェック
            BigDecimal fromBalance = accountDao.getBalance(fromAccountId);
            if (fromBalance.compareTo(amount) < 0) {
                throw new InsufficientFundsException("Insufficient funds in account: " + fromAccountId);
            }

            // 出金
            accountDao.withdraw(fromAccountId, amount);

            // 入金
            accountDao.deposit(toAccountId, amount);

            conn.commit();
        } catch (SQLException e) {
            if (conn != null) {
                conn.rollback();
            }
            throw e;
        } finally {
            if (conn != null) {
                try {
                    conn.setAutoCommit(true);
                    conn.close();
                } catch (SQLException e) {
                    // ログに記録
                }
            }
            // DAOから接続をクリア
            accountDao.clearConnection();
        }
    }
}

トランザクション境界をServlet Filterで管理

複数のサービスメソッドを1つのトランザクションでまとめたい場合に有効です。

@WebFilter("/*")
public class TransactionFilter implements Filter {
    private DataSource dataSource;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        try {
            Context ctx = new InitialContext();
            dataSource = (DataSource) ctx.lookup("java:comp/env/jdbc/mydb");
        } catch (NamingException e) {
            throw new ServletException("Failed to lookup DataSource", e);
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        Connection conn = null;
        try {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);

            // すべてのDAOがこの接続を使用するように設定
            ConnectionHolder.setConnection(conn);

            chain.doFilter(request, response);

            conn.commit();
        } catch (SQLException | RuntimeException e) {
            if (conn != null) {
                try {
                    conn.rollback();
                } catch (SQLException ex) {
                    // ログに記録
                }
            }
            throw new ServletException("Transaction failed", e);
        } finally {
            ConnectionHolder.clear();
            if (conn != null) {
                try {
                    conn.setAutoCommit(true);
                    conn.close();
                } catch (SQLException e) {
                    // ログに記録
                }
            }
        }
    }

    @Override
    public void destroy() {}
}

// スレッドローカルで接続を保持するヘルパークラス
public class ConnectionHolder {
    private static final ThreadLocal holder = new ThreadLocal<>();

    public static void setConnection(Connection conn) {
        holder.set(conn);
    }

    public static Connection getConnection() {
        return holder.get();
    }

    public static void clear() {
        holder.remove();
    }
}

高度なトランザクション管理

宣言的トランザクション管理

プログラム的にではなく、アノテーションや設定ファイルでトランザクションを管理する方法です。Spring Frameworkなどでサポートされていますが、ここでは独自実装の例を示します。

カスタムアノテーションの定義

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Transactional {
    int isolation() default Connection.TRANSACTION_READ_COMMITTED;
    boolean readOnly() default false;
}

アノテーションを処理するインターセプター

public class TransactionInterceptor {
    private DataSource dataSource;

    public TransactionInterceptor(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Object proceedWithTransaction(Method method, Object target, Object[] args) throws Exception {
        Transactional transactional = method.getAnnotation(Transactional.class);
        if (transactional == null) {
            return method.invoke(target, args);
        }

        Connection conn = null;
        Object result = null;
        try {
            conn = dataSource.getConnection();
            int originalIsolation = conn.getTransactionIsolation();
            boolean originalAutoCommit = conn.getAutoCommit();

            // トランザクション設定を適用
            conn.setTransactionIsolation(transactional.isolation());
            conn.setAutoCommit(false);

            // DAOに接続を設定
            if (target instanceof ConnectionAware) {
                ((ConnectionAware) target).setConnection(conn);
            }

            // メソッド実行
            result = method.invoke(target, args);

            // 読み取り専用でなければコミット
            if (!transactional.readOnly()) {
                conn.commit();
            } else {
                conn.rollback(); // 読み取り専用は変更を破棄
            }

            return result;
        } catch (Exception e) {
            if (conn != null) {
                conn.rollback();
            }
            throw e;
        } finally {
            if (conn != null) {
                try {
                    // 元の設定に戻す
                    conn.setAutoCommit(originalAutoCommit);
                    conn.setTransactionIsolation(originalIsolation);
                    conn.close();
                } catch (SQLException e) {
                    // ログに記録
                }
            }
            // DAOから接続をクリア
            if (target instanceof ConnectionAware) {
                ((ConnectionAware) target).clearConnection();
            }
        }
    }
}

使用例

public class AccountService {
    private AccountDAO accountDao;

    public AccountService(AccountDAO accountDao) {
        this.accountDao = accountDao;
    }

    @Transactional
    public void transfer(int fromId, int toId, BigDecimal amount) throws InsufficientFundsException {
        BigDecimal balance = accountDao.getBalance(fromId);
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFundsException();
        }
        accountDao.withdraw(fromId, amount);
        accountDao.deposit(toId, amount);
    }

    @Transactional(isolation = Connection.TRANSACTION_REPEATABLE_READ, readOnly = true)
    public BigDecimal getBalance(int accountId) {
        return accountDao.getBalance(accountId);
    }
}

トランザクションのネストと伝播

トランザクション伝播動作の種類

伝播動作説明
REQUIRED現在のトランザクションがあれば参加、なければ新規作成
REQUIRES_NEW常に新規トランザクションを作成
NESTED現在のトランザクション内にネストされたトランザクションを作成
SUPPORTS現在のトランザクションがあれば参加、なければトランザクションなし
NOT_SUPPORTEDトランザクション外で実行、既存のトランザクションは一時停止
NEVERトランザクション外で実行、既存のトランザクションがあれば例外
MANDATORY既存のトランザクションに参加、なければ例外

ネストされたトランザクションの実装例

MySQLのSAVEPOINTを使用して実装します。

public class NestedTransactionExample {
    public void outerOperation() throws SQLException {
        Connection conn = null;
        Savepoint savepoint = null;
        try {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);

            // 外部トランザクションの処理
            updateTable1(conn);

            try {
                savepoint = conn.setSavepoint("INNER_TRANSACTION");

                // 内部トランザクションの処理
                updateTable2(conn);
                updateTable3(conn);

            } catch (SQLException innerEx) {
                if (savepoint != null) {
                    conn.rollback(savepoint); // 内部トランザクションのみロールバック
                }
                // 外部トランザクションは継続
                // 必要に応じてエラー処理
            }

            // 外部トランザクションの続き
            updateTable4(conn);

            conn.commit();
        } catch (SQLException e) {
            if (conn != null) {
                conn.rollback();
            }
            throw e;
        } finally {
            if (conn != null) {
                try {
                    conn.setAutoCommit(true);
                    conn.close();
                } catch (SQLException e) {
                    // ログに記録
                }
            }
        }
    }

    private void updateTable1(Connection conn) throws SQLException {
        // 更新処理
    }

    // 他のメソッドも同様...
}

トランザクションタイムアウト

トランザクションタイムアウトの設定

public class TransactionTimeoutExample {
    public void executeWithTimeout() throws SQLException {
        Connection conn = null;
        try {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);

            // タイムアウトを5秒に設定
            try (Statement stmt = conn.createStatement()) {
                stmt.setQueryTimeout(5); // 秒単位

                // 時間がかかるクエリ
                stmt.executeUpdate("UPDATE large_table SET column = value WHERE complex_condition");
            }

            conn.commit();
        } catch (SQLTimeoutException e) {
            if (conn != null) {
                conn.rollback();
            }
            throw new RuntimeException("Operation timed out", e);
        } catch (SQLException e) {
            if (conn != null) {
                conn.rollback();
            }
            throw e;
        } finally {
            if (conn != null) {
                try {
                    conn.setAutoCommit(true);
                    conn.close();
                } catch (SQLException e) {
                    // ログに記録
                }
            }
        }
    }
}

分散トランザクション (XAトランザクション)

複数のデータベースやリソースにまたがるトランザクションを管理する場合、XAトランザクションを使用します。

MySQL XAトランザクションの例

public class XATransactionExample {
    public void performXATransaction() throws Exception {
        // XAデータソースの設定
        MysqlXADataSource xaDataSource1 = new MysqlXADataSource();
        xaDataSource1.setUrl("jdbc:mysql://db1:3306/mydb");
        xaDataSource1.setUser("user");
        xaDataSource1.setPassword("password");

        MysqlXADataSource xaDataSource2 = new MysqlXADataSource();
        xaDataSource2.setUrl("jdbc:mysql://db2:3306/mydb");
        xaDataSource2.setUser("user");
        xaDataSource2.setPassword("password");

        // XAリソースを取得
        XAConnection xaConn1 = xaDataSource1.getXAConnection();
        XAResource xaRes1 = xaConn1.getXAResource();
        Connection conn1 = xaConn1.getConnection();

        XAConnection xaConn2 = xaDataSource2.getXAConnection();
        XAResource xaRes2 = xaConn2.getXAResource();
        Connection conn2 = xaConn2.getConnection();

        // グローバルトランザクションIDを生成
        byte[] gtrid = "global_transaction_id".getBytes();
        int formatId = 1;

        try {
            // トランザクション開始
            Xid xid1 = new MyXid(gtrid, "branch1".getBytes(), formatId);
            Xid xid2 = new MyXid(gtrid, "branch2".getBytes(), formatId);

            xaRes1.start(xid1, XAResource.TMNOFLAGS);
            xaRes2.start(xid2, XAResource.TMNOFLAGS);

            // 各データベースで操作実行
            try (PreparedStatement stmt1 = conn1.prepareStatement("UPDATE accounts SET balance = balance - ? WHERE id = ?")) {
                stmt1.setBigDecimal(1, new BigDecimal("100.00"));
                stmt1.setInt(2, 1);
                stmt1.executeUpdate();
            }

            try (PreparedStatement stmt2 = conn2.prepareStatement("UPDATE accounts SET balance = balance + ? WHERE id = ?")) {
                stmt2.setBigDecimal(1, new BigDecimal("100.00"));
                stmt2.setInt(2, 2);
                stmt2.executeUpdate();
            }

            // フェーズ1: 準備
            int prepare1 = xaRes1.end(xid1, XAResource.TMSUCCESS);
            int prepare2 = xaRes2.end(xid2, XAResource.TMSUCCESS);

            int vote = XAResource.XA_OK;
            if (prepare1 == XAResource.XA_RDONLY || prepare2 == XAResource.XA_RDONLY) {
                vote = XAResource.XA_RDONLY;
            }

            // フェーズ2: コミットまたはロールバック
            if (vote == XAResource.XA_OK) {
                xaRes1.commit(xid1, false);
                xaRes2.commit(xid2, false);
            } else {
                xaRes1.rollback(xid1);
                xaRes2.rollback(xid2);
            }
        } catch (Exception e) {
            // エラーが発生したらロールバック
            try {
                xaRes1.rollback(xid1);
                xaRes2.rollback(xid2);
            } catch (XAException ex) {
                // ログに記録
            }
            throw e;
        } finally {
            conn1.close();
            conn2.close();
            xaConn1.close();
            xaConn2.close();
        }
    }
}

// Xidの実装
class MyXid implements Xid {
    protected byte[] gtrid;
    protected byte[] bqual;
    protected int formatId;

    public MyXid(byte[] gtrid, byte[] bqual, int formatId) {
        this.gtrid = gtrid;
        this.bqual = bqual;
        this.formatId = formatId;
    }

    public byte[] getGlobalTransactionId() { return gtrid; }
    public byte[] getBranchQualifier() { return bqual; }
    public int getFormatId() { return formatId; }
}

よくある問題と解決策

トランザクション関連の一般的な問題

  1. デッドロック:
  • 症状: トランザクションが互いにロックを待ち続ける
  • 解決策:
    • ロック取得順序を統一する
    • トランザクションを短く保つ
    • 適切なタイムアウトを設定
  1. ロングトランザクション:
  • 症状: トランザクションが長時間開かれたままになる
  • 解決策:
    • トランザクションを小さな単位に分割
    • タイムアウトを設定
    • 読み取り専用操作はトランザクション外で実行
  1. 接続リーク:
  • 症状: トランザクションが完了しても接続が解放されない
  • 解決策:
    • try-with-resourcesを使用
    • 接続取得・解放をパターン化
    • 監視ツールでリークを検出

MySQL特有の注意点

  1. ストレージエンジンの選択:
  • InnoDB: トランザクションをサポート
  • MyISAM: トランザクションをサポートしない
  1. 自動コミットモード:
  • MySQLではデフォルトで自動コミットが有効
  • 明示的にsetAutoCommit(false)を呼び出す必要がある
  1. ロックの競合:
  • SELECT ... FOR UPDATEで行ロックを取得
  • 適切なインデックスがないとテーブルロックになる可能性

パフォーマンスチューニング

トランザクション性能向上のためのヒント

  1. トランザクションの短縮:
  • ビジネスロジックの前後でトランザクションを開始・終了
  • ユーザー入力待ちをトランザクション内で行わない
  1. 適切な分離レベルの選択:
  • 必要以上に高い分離レベルを使用しない
  • 読み取りが多い場合はREAD COMMITTEDを検討
  1. バッチ処理の活用:
  • 大量のデータ操作はバッチ処理で行う
  • addBatch()executeBatch()を使用
  1. インデックスの最適化:
  • トランザクションで使用するカラムに適切なインデックスを作成
  • ロック競合を減らす

テスト戦略

トランザクションのテスト方法

  1. 単体テスト:
  • 正常系と異常系の両方をテスト
  • モックを使用してDAO層をテスト
public class BankServiceTest {
    private BankService bankService;
    private AccountDAO mockAccountDao;

    @Before
    public void setUp() {
        mockAccountDao = Mockito.mock(AccountDAO.class);
        bankService = new BankService(mockAccountDao);
    }

    @Test
    public void testTransferSuccess() throws Exception {
        // モックの設定
        when(mockAccountDao.getBalance(1)).thenReturn(new BigDecimal("200.00"));

        // テスト実行
        bankService.transfer(1, 2, new BigDecimal("100.00"));

        // 検証
        verify(mockAccountDao).withdraw(1, new BigDecimal("100.00"));
        verify(mockAccountDao).deposit(2, new BigDecimal("100.00"));
    }

    @Test(expected = InsufficientFundsException.class)
    public void testTransferInsufficientFunds() throws Exception {
        when(mockAccountDao.getBalance(1)).thenReturn(new BigDecimal("50.00"));

        bankService.transfer(1, 2, new BigDecimal("100.00"));
    }
}
  1. 統合テスト:
  • 実際のデータベースを使用
  • トランザクションのロールバックを確認
public class BankServiceIntegrationTest {
    private DataSource dataSource;
    private BankService bankService;

    @Before
    public void setUp() throws SQLException {
        // テスト用データベースをセットアップ
        dataSource = createTestDataSource();
        initializeTestDatabase(dataSource);

        AccountDAO accountDao = new AccountDAOImpl(dataSource);
        bankService = new BankService(accountDao);
    }

    @Test
    public void testTransferWithRollback() throws Exception {
        // 初期残高: account1=200, account2=100
        BigDecimal initialBalance1 = getBalance(1);
        BigDecimal initialBalance2 = getBalance(2);

        try {
            // 意図的に失敗させる転送 (残高不足)
            bankService.transfer(2, 1, new BigDecimal("150.00"));
            fail("Expected InsufficientFundsException");
        } catch (InsufficientFundsException e) {
            // 期待通り
        }

        // 残高が変わっていないことを確認 (ロールバックされた)
        assertEquals(initialBalance1, getBalance(1));
        assertEquals(initialBalance2, getBalance(2));
    }

    private BigDecimal getBalance(int accountId) throws SQLException {
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement("SELECT balance FROM accounts WHERE id = ?")) {
            stmt.setInt(1, accountId);
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return rs.getBigDecimal("balance");
                }
                throw new IllegalArgumentException("Account not found: " + accountId);
            }
        }
    }
}

まとめ

JSP/Servletアプリケーションでのトランザクション管理について、以下のポイントを学びました:

  1. 基本概念: ACID特性とトランザクションのライフサイクル
  2. 実装方法: サービス層でのトランザクション管理、Filterを使った横断的管理
  3. 高度なトピック: 分離レベル、ネストされたトランザクション、分散トランザクション
  4. ベストプラクティス: トランザクションの短縮、適切な分離レベルの選択
  5. 問題解決: デッドロック、ロングトランザクションへの対処法

適切なトランザクション管理を実装することで、データの整合性を保ちながら、パフォーマンスの良いアプリケーションを構築できます。実際のプロジェクトでは、これらの基本を理解した上で、Spring Frameworkなどの高度なトランザクション管理機能を活用することが次のステップとなります。