現場のコードレビューにおいて、最も頻繁に遭遇するアンチパターンの一つが@Transactionalアノテーションの無自覚な使用です。多くの開発者はデフォルト設定(REQUIRED)に依存しており、トランザクション境界がどこで開始され、どこで終了するかを正確に把握していないケースが散見されます。これは単なるバグだけでなく、データベースコネクションプールの枯渇や、デッドロックによるシステム停止を引き起こす主要因となります。本稿では、Spring Frameworkにおけるトランザクション伝播(Propagation)のメカニズムを、プロキシパターンと物理/論理トランザクションの観点から解析し、各設定値のトレードオフを明らかにします。
1. アーキテクチャと抽象化のコスト
Springのトランザクション管理は「魔法」ではありません。その本質はAOP(アスペクト指向プログラミング)によるプロキシパターンと、ThreadLocalを用いたコンテキスト管理にあります。@Transactionalが付与されたメソッドを呼び出す際、呼び出し元は実際のBeanではなく、Springが生成したプロキシオブジェクトを経由します。
Springにおける「論理トランザクション(Logical Transaction)」と「物理トランザクション(Physical Transaction)」の区別は重要です。複数のメソッドが同一のトランザクションに参加する場合、論理スコープは複数存在しますが、裏側でデータベースと接続している物理コネクションは1つです。
プロキシはTransactionInterceptorを通じてトランザクションの開始・コミット・ロールバックを制御します。ここで重要なのが「伝播(Propagation)」設定です。これは、既存の物理トランザクションが存在する場合に、新しい論理トランザクションスコープをどのように扱うかを定義するルールセットです。適切な設定を選択しない限り、部分的なロールバックの失敗や、意図しないデータの永続化が発生します。
2. 7つの伝播属性とそのトレードオフ
Springが提供する7つの伝播設定は、ユースケースに応じて厳密に使い分ける必要があります。ここでは、それぞれの挙動とエンジニアリング観点での注意点を解説します。
| Propagation Type | 既存Txがある場合 | 既存Txがない場合 | 主なリスク・備考 |
|---|---|---|---|
| REQUIRED | 参加する | 新規作成 | デフォルト。部分的なロールバックは不可(全体がロールバックされる)。 |
| REQUIRES_NEW | 中断(Suspend)し新規作成 | 新規作成 | DBコネクションを2本消費する。デッドロックのリスク増。 |
| SUPPORTS | 参加する | Txなしで実行 | 一貫性が保証されない可能性がある。参照系で使用。 |
| MANDATORY | 参加する | 例外発生 | 必ず既存コンテキスト内で実行させる制約。 |
| NOT_SUPPORTED | 中断しTxなしで実行 | Txなしで実行 | Txコンテキストオーバーヘッドの回避。 |
| NEVER | 例外発生 | Txなしで実行 | Tx内での実行を明確に禁止。 |
| NESTED | ネスト(Savepoint)作成 | 新規作成 | JDBC Savepoint機能に依存。JPA/Hibernateでは動作しない場合がある。 |
主要設定の詳細分析
REQUIRED (The Default)
最も一般的ですが、例外ハンドリングにおいて注意が必要です。呼び出された内部メソッドで例外が発生しロールバックマークが付くと、たとえ呼び出し元(Caller)でその例外をcatchしても、論理トランザクション全体が「ロールバック専用(Rollback-Only)」としてマークされているため、コミット時にUnexpectedRollbackExceptionが発生します。
REQUIRES_NEW (Isolation vs Resource)
外部サービスの監査ログ保存や、メイン処理の成否に関わらず実行したい処理に使用します。しかし、以下のアーキテクチャ上のコストを考慮しなければなりません。
- リソース枯渇: 親トランザクションが中断され、子トランザクションのために新しいDBコネクションが取得されます。高負荷時にコネクションプールが枯渇する直接的な原因となります。
- ロック競合: 親と子が同じテーブル行を操作する場合、データベースレベルでのデッドロックが発生する可能性があります。
// REQUIRES_NEW の使用例:監査ログ
// 呼び出し元のトランザクションがロールバックされても、ログは残る
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(String action) {
auditRepository.save(new AuditLog(action));
}
NESTED (Savepoints)
NESTEDは物理トランザクションを一つだけ使用し、JDBCのSAVEPOINT機能を利用して部分的なロールバックを可能にします。REQUIRES_NEWと比較してリソース効率が良いですが、Hibernate等のORMを使用する場合、JPA仕様との不整合により正しく動作しないケースがあるため、DataSourceTransactionManager環境下での利用が推奨されます。
3. 実装上のアンチパターンと解決策
プロキシパターンの特性を理解していない実装は、設定が無効化される原因となります。
Self-Invocation (自己呼び出し) 問題
同一クラス内のメソッド呼び出し(this.method())はプロキシを経由しません。そのため、呼び出されたメソッドに@Transactional(propagation = Propagation.REQUIRES_NEW)が付与されていても、その設定は無視され、既存のトランザクション(あるいはトランザクションなし)で実行されます。
同一クラス内でのトランザクションメソッド呼び出しは避け、別クラス(Service)に分離するか、自己注入(Self-Injection)を利用する必要があります。
@Service
public class OrderService {
// 解決策: 自分自身をInjectする(またはLombokの@RequiredArgsConstructor等を利用)
@Autowired
private OrderService self;
@Transactional
public void createOrder() {
// NG: プロキシを通らないため、REQUIRES_NEWが機能しない
// this.generateInvoice();
// OK: プロキシ経由で呼び出されるため、新しいトランザクションが開始される
self.generateInvoice();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void generateInvoice() {
// 独立したトランザクション処理
}
}
例外によるロールバック制御
Springのデフォルトでは、RuntimeException(非チェック例外)およびErrorのみがロールバックのトリガーとなります。Exception(チェック例外)が発生してもコミットされる挙動は、Javaの例外設計思想に基づくものですが、実務上は予期せぬデータ不整合を招くことがあります。
ビジネスロジックでチェック例外を使用する場合は、明示的に
rollbackFor属性を指定してください。例:
@Transactional(rollbackFor = Exception.class)
結論: 適切な戦略の選択
トランザクション伝播の選択は、単なるコードの書き方ではなく、システム全体の整合性とパフォーマンスを決定づけるアーキテクチャ上の意思決定です。
- 基本は
REQUIREDを使用し、論理的な一塊の処理を定義する。 REQUIRES_NEWは、監査ログや通知など、メイン処理とライフサイクルが明確に異なる場合にのみ限定的に使用する(コネクションプール設定を見直すこと)。- パフォーマンス要件が厳しい読み取り専用操作には、
SUPPORTSやreadOnly=trueを活用し、ORMのダーティチェック等のオーバーヘッドを回避する。
フレームワークの隠蔽された複雑性を理解し、意図を持って制御することが、堅牢なバックエンドシステム構築への第一歩です。
Post a Comment