はじめに
先日、「Spring の @Transactional を付けてトランザクションを分離したつもりが、狙いどおりに分離されず、エラーによって処理全体がロールバックされてしまった。」といったことを経験し、私はまた一つ学びを得たのでした。。
ということで、本記事では、Spring Boot でバッチ処理やループ処理を実装する際に初心者が陥りがちな、@Transactional が「静かに無視される」典型パターンとその回避策をまとめます。@Transactional が効く条件を仕組みから理解し、同じ轍を踏まないよう整理します。
結論だけ知りたい方へ:@Transactional は「public メソッド」を「Bean(プロキシ)経由で呼んだとき」だけ効きます。 private メソッドや、同一クラス内からの自己呼び出し(self-invocation)では、アノテーションはコンパイルエラーにもならず、静かに無視されるので要注意です。
発生した事象(症状)
以下のような、複数件をループで処理するバッチを想定します。1件ずつ独立したトランザクションで登録し、1件が失敗しても他の件はコミットさせたい、という要件です。
@Service
public class ImportService {
// 外側の入り口。ここは Bean 経由で呼ばれるので @Transactional が効く
@Transactional
public void importAll(final List<Item> items) {
for (final Item item : items) {
try {
// ★ 1件ごとに独立トランザクションにしたい
processOne(item);
} catch (final Exception e) {
// 失敗した1件はログだけ出してスキップ(したいつもり)
log.warn("skip item: {}", item.getId(), e);
}
}
}
// 1件を独立トランザクションで処理する「つもり」のメソッド
@Transactional(propagation = Propagation.REQUIRES_NEW)
private void processOne(final Item item) {
repository.insert(item);
// ... 何らかの外部連携やDB更新 ...
}
}
この実装の意図は「processOne を REQUIRES_NEW で毎回新しいトランザクションにし、1件失敗しても catch で握りつぶして次に進む」です。しかし、実際の挙動は以下の様になります。
- 1件でも
processOneの中でRuntimeExceptionが発生すると、その回だけでなく全件が最終的にロールバックされる。 -
catchで握りつぶしているのに、importAllの最後(コミット時)に次の例外が出る。
org.springframework.transaction.UnexpectedRollbackException:
Transaction rolled back because it has been marked as rollback-only
期待した「独立トランザクション」がまったく効いていない状態になります。
特に、システムで一意の識別子や連携する外部サービス側に渡すIDの採番などのロジックでこのような実装をしていると、障害発生時に状況がややこしいことに。。。
環境
- Java 21
- Spring Boot(Spring Framework 6 系)
- トランザクション管理は宣言的トランザクション(
@Transactional)
※バージョンが違っても、Spring AOP がプロキシベースである限り(@Transactional を実現している仕組みが同じであれば)は同じ話が当てはまります。
なぜ効かなかったのか(原因)
原因は2つ重なっていました。どちらか一方でも @Transactional は無効になります。
-
@Transactionalをprivateメソッドに付けていた - 同一クラス内から自己呼び出し(self-invocation)していた
Spring AOP はプロキシで動いている
@Transactional は、Spring が対象の Bean をプロキシ(代理オブジェクト)でラップし、メソッド呼び出しの前後にトランザクションの開始・コミット・ロールバックを差し込むことで実現されています。
ポイントは、プロキシを経由した呼び出しにしかトランザクション制御が挟まらないことです。
プロキシが効かない条件
| 条件 |
@Transactional が効くか |
理由 |
|---|---|---|
public メソッドを Bean(DI)経由で呼ぶ |
〇 (効く) | プロキシを通る |
private / protected / package-private メソッド |
×(効かない) | プロキシがメソッドを差し替えられない |
同一クラス内から this.method()(self-invocation) |
×(効かない) | プロキシではなく実インスタンスを直接呼ぶ |
final メソッド / final クラス |
×(効かない) | サブクラス化できずプロキシ化できない |
今回のコードは「private」かつ「自己呼び出し(self-invocation)」というダブルパンチでした。for ループの中の processOne(item) は暗黙の this.processOne(item) であり、プロキシではなく実インスタンスを直接呼びます。そのため REQUIRES_NEW は発動せず、processOne は外側の importAll のトランザクションにそのまま乗っかるだけになります。
なぜ「握りつぶし」も効かないのか
さらに厄介なのが UnexpectedRollbackException です。
processOne が独立トランザクションになっていれば、その中の例外はその小さなトランザクションだけをロールバックし、catch で握りつぶせば importAll 側は無傷でコミットできるはずでした。しかし実際には全部が1つのトランザクションなので、processOne 内で RuntimeException が発生した時点で、その1つのトランザクションが「rollback-only(ロールバック確定)」に印を付けられます。
catch で例外を握りつぶしても、この「rollback-only」フラグは消えません。最終的に importAll がコミットしようとした瞬間、「ロールバック確定なのにコミットしようとした」として UnexpectedRollbackException が投げられます。「握りつぶしたのに全部巻き添えでロールバックされる」という、直感に反する挙動の正体です。
@Transactional が付いていないように見えても、クラスに付いているケースにも注意してください。親クラスやクラス宣言に @Transactional があると、public メソッドすべてに継承されます。「入り口の public メソッドで外側トランザクションが1本張られていた」ことに気づかないと、原因究明が長引きます。
解決策
解決策1:別 Bean に切り出して DI 経由で呼ぶ
もっとも素直で、意図も明確になる方法と言えます。独立トランザクションにしたい処理を別のクラスに移し、DI で注入して呼びます。呼び出しがプロキシを経由するので REQUIRES_NEW が効きます。この際、メソッドは public にするのを忘れずに。
@Service
public class ItemProcessor {
// 別 Bean の public メソッド → 呼び出しがプロキシを通る
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOne(final Item item) {
repository.insert(item);
// ...
}
}
@Service
@RequiredArgsConstructor
public class ImportService {
private final ItemProcessor itemProcessor;
// ★ ここでは @Transactional を外す(外側トランザクションを張らない)
public void importAll(final List<Item> items) {
for (final Item item : items) {
try {
itemProcessor.processOne(item); // Bean 経由なのでプロキシを通る
} catch (final Exception e) {
log.warn("skip item: {}", item.getId(), e);
}
}
}
}
「1件ごとに独立させたい」場合、外側の importAll に @Transactional を付けないのがポイントです。付けてしまうと外側トランザクションが1本張られ、REQUIRES_NEW の内側と親子関係にはなりますが、ループ全体を包む必要がないなら外す方がシンプルです。
解決策2:自己注入(self-injection)で自分のプロキシを呼ぶ
クラスを分けたくない場合は、自分自身のプロキシを注入して、それ経由で呼びます。
@Service
public class ImportService {
@Autowired
private ImportService self; // 自分自身のプロキシ
public void importAll(final List<Item> items) {
for (final Item item : items) {
try {
self.processOne(item); // this ではなくプロキシ経由で呼ぶ
} catch (final Exception e) {
log.warn("skip item: {}", item.getId(), e);
}
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOne(final Item item) { // public 必須
repository.insert(item);
}
}
動きはしますが、やや読みにくく、クラス分離(解決策1)の方が健全です。
解決策3:プログラマティックに TransactionTemplate を使う
アノテーションに頼らず、コードでトランザクション境界を明示する方法です。プロキシの制約から完全に解放されます。
@Service
@RequiredArgsConstructor
public class ImportService {
private final TransactionTemplate requiresNewTemplate;
public void importAll(final List<Item> items) {
for (final Item item : items) {
try {
requiresNewTemplate.executeWithoutResult(status -> processOne(item));
} catch (final Exception e) {
log.warn("skip item: {}", item.getId(), e);
}
}
}
private void processOne(final Item item) { // private でもOK(プロキシに依存しない)
repository.insert(item);
}
}
TransactionTemplate は PROPAGATION_REQUIRES_NEW を設定した Bean として用意しておきます。境界が「どこからどこまで」かがコード上で明確になるのが利点です。
ハマりどころ・学び
-
@Transactionalは失敗を教えてくれない。 private に付けても self-invocation でも、コンパイルエラーも警告も出ず、静かに無視される。だからこそ「効いている前提」で読むと原因にたどり着けない。 -
UnexpectedRollbackExceptionは「独立させたはずが独立していない」サイン。 握りつぶしたのに全体がロールバックされたら、まずトランザクション境界を疑う。 -
「独立トランザクションで1件ずつ」はレビューで意図確認を。 メソッドの可視性と呼び出し経路(Bean 経由か
thisか)はコードレビューのチェックポイントにする価値がある。
まとめ
@Transactional がプロキシで実装されている以上、次の2つを満たさないと効きません。
-
メソッドは
public(private/protected/ package-private は不可) -
呼び出しは Bean(プロキシ)経由(同一クラス内の
this呼び出しは不可)
REQUIRES_NEW で「1件ごとに独立トランザクション」を狙うときは、処理を別 Bean に切り出して DI 経由で呼ぶのが最も確実で読みやすい方法です。アノテーションが黙って無視される仕様を知っておくだけで、原因究明の時間が大きく変わります。