はじめに
トランザクション管理は、データベースを扱うアプリケーション開発において不可欠な概念です。特に金融システムや在庫管理システムなど、データの整合性が重要なアプリケーションでは、適切なトランザクション管理が必須となります。本記事では、JSP/Servlet環境でのトランザクション管理について、MySQLをメインに詳しく解説します。
トランザクションの基本概念
トランザクションとは
トランザクションとは、複数のデータベース操作を1つの論理的な作業単位としてまとめたものです。ACID特性(後述)を保証することで、データの整合性を維持します。
ACID特性
| 特性 | 説明 |
|---|---|
| Atomicity(原子性) | トランザクション内の操作はすべて実行されるか、または全く実行されないかのどちらか |
| Consistency(一貫性) | トランザクションはデータベースを一貫した状態から別の一貫した状態へ移行させる |
| Isolation(分離性) | 並行実行されるトランザクションは互いに干渉しない |
| Durability(永続性) | 一度コミットされたトランザクションの結果は永続的に保存される |
トランザクションの状態遷移図
下の図は、トランザクションが開始されてから終了するまでの状態遷移を表しています。トランザクションは開始後に処理を実行するアクティブ状態となり、すべての処理が正常に完了するとコミットされて変更内容が確定します。
[開始]
↓
[アクティブ] ←→ [部分コミット]
↓ ↓
[失敗] [コミット]
↓
[アボート]
一方、処理中にエラーが発生した場合は失敗状態となり、最終的にアボート(ロールバック)によって変更内容が取り消されます。これにより、データの整合性を保ちながら安全に処理を実行できます。
基本的なトランザクションの流れ
基本的なトランザクションでは、まず自動コミットを無効にして複数のSQL文を1つの処理として実行します。
すべてのSQLが正常に完了した場合はコミット(commit())を実行して変更内容を確定し、途中でエラーが発生した場合はロールバック(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のトランザクション分離レベル
下記の表は、MySQLで利用できるトランザクションの分離レベルと、どのようなデータ不整合を防げるかを示しています。
分離レベルが高くなるほど、ダーティリードや非再現読み取り、ファントムリードといった問題を防ぐことができますが、その分データベースの処理負荷も高くなります。
| 分離レベル | ダーティリード | 非再現読み取り | ファントムリード |
|---|---|---|---|
| READ UNCOMMITTED | 発生する | 発生する | 発生する |
| READ COMMITTED | 発生しない | 発生する | 発生する |
| REPEATABLE READ (MySQLデフォルト) | 発生しない | 発生しない | 発生する |
| SERIALIZABLE | 発生しない | 発生しない | 発生しない |
MySQLでは、REPEATABLE READ がデフォルトの分離レベルとなっており、ダーティリードと非再現読み取りを防ぎながら、性能とのバランスを取っています。
次の内容はトランザクションの分離レベルは、データの整合性と処理性能のバランスを調整するための設定です。
// 接続ごとに設定
conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
// データソースレベルで設定(接続プール)
dataSource.setDefaultTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
アプリケーションの要件に応じて適切な分離レベルを選択することで、安全性とパフォーマンスを両立したデータベース処理を実現できます。
実践的なトランザクション管理パターン
サービス層でのトランザクション管理
DAO層ではなく、サービス層でトランザクションを管理するのがベストプラクティスです。次のコードは、銀行口座間の送金処理を1つのトランザクションとして実行する例です。
まず、自動コミットを無効にしてトランザクションを開始し、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();
}
}
}
すべての処理が正常に完了した場合はcommit()で変更内容を確定し、途中でエラーが発生した場合はrollback()を実行して、すべての変更を取り消します。
最後に接続を接続プールへ返却し、DAOからも接続情報を解除することで、リソースを適切に管理しながらデータの整合性を維持しています。
トランザクション境界をServlet Filterで管理
複数のサービスメソッドを1つのトランザクションでまとめたい場合に有効で、以下はFilterを利用してトランザクションを一元管理する実装例です。
リクエストを受け取ると、Filterがデータベース接続を取得して自動コミットを無効にし、その接続をConnectionHolderに保存します。
@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();
}
}
アプリケーション内のDAOはこの接続を利用して処理を実行し、すべての処理が正常に完了した場合はcommit()で変更内容を確定します。途中で例外が発生した場合はrollback()を実行して変更を取り消します。
処理終了後は接続を接続プールへ返却し、ConnectionHolderから接続情報を削除します。このようにFilterでトランザクションを管理することで、各ServiceやDAOにトランザクション処理を記述する必要がなくなり、共通処理として一元的に管理できるようになります。
高度なトランザクション管理
宣言的トランザクション管理
プログラム的にではなく、アノテーションや設定ファイルでトランザクションを管理する方法です。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を使用して実装します。外部トランザクションを開始した後、セーブポイントを作成して内部トランザクションを実行します。内部処理でエラーが発生した場合は、rollback(savepoint)によってセーブポイントまで処理を戻し、内部トランザクションのみを取り消すことができます。
一方、外部トランザクションはそのまま継続され、残りの処理を実行した後にcommit()で変更内容を確定します。これにより、一部の処理だけをロールバックしながら、トランザクション全体を柔軟に制御できます。
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 {
// 更新処理
}
// 他のメソッドも同様...
}
よくある問題と解決策
トランザクション関連の一般的な問題
デッドロック
複数のトランザクションがお互いのロック解除を待ち続ける状態です。ロック取得順序を統一し、トランザクションを短く保ち、適切なタイムアウトを設定することで防止できます。
ロングトランザクション
トランザクションが長時間実行されることで、ロック競合や性能低下を引き起こす状態です。処理を小さく分割し、不要な処理はトランザクション外で実行することが重要です。
接続リーク
使用後のデータベース接続が解放されず、接続プールが枯渇する問題です。try-with-resourcesを利用し、接続の取得・解放を適切に管理することで防止できます。
MySQL特有の注意点
ストレージエンジンの選択
MySQLでは、トランザクションを利用する場合はInnoDBを使用します。MyISAMはトランザクションに対応していません。
自動コミットモード
MySQLでは自動コミットが初期設定で有効です。トランザクションを利用する場合は、setAutoCommit(false)を設定する必要があります。
ロックの競合
SELECT … FOR UPDATEを利用すると行ロックを取得できます。適切なインデックスを設定することで、不要なロック競合を防ぐことができます。
パフォーマンスチューニング
トランザクションの短縮
トランザクションは必要な処理だけを含め、できるだけ短時間で完了させることで性能を向上できます。
適切な分離レベルの選択
必要以上に高い分離レベルは性能を低下させます。処理内容に応じて適切な分離レベルを選択することが重要です。
バッチ処理の活用
大量のデータを処理する場合は、addBatch()やexecuteBatch()を利用してまとめて実行すると効率が向上します。
インデックスの最適化
検索や更新に使用するカラムへ適切なインデックスを設定することで、検索速度が向上し、ロック競合も軽減できます。
まとめ
JSP/Servletアプリケーションにおけるトランザクション管理について学習しました。トランザクションの基本概念やACID特性を理解し、Service層やFilterを利用した管理方法を学びました。
また、分離レベルやネストされたトランザクションなどの応用知識に加え、適切なトランザクション設計やデッドロックへの対処方法についても学習し、安全で効率的なデータベース処理を実現する方法を身に付けました。
適切なトランザクション管理を実装することで、データの整合性を保ちながら、パフォーマンスの良いアプリケーションを構築できます。実際のプロジェクトでは、これらの基本を理解した上で、Spring Frameworkなどの高度なトランザクション管理機能を活用することが次のステップとなります。