Article

🌀 Springの@Transactionalでハマる4つの罠:自己呼び出し・例外・REQUIRES_NEWを整理する

java, spring, springboot, transaction

Spring BootでDB更新を書くとき、@Transactional を付ければ「失敗したら全部ロールバックされる」と考えがちです。

ただし、Springのトランザクションにはいくつか重要なルールがあります。知らずに使うと、@Transactional を付けたのに効いていない、例外が出たのにコミットされた、REQUIRES_NEW を追加したらコネクションプールが詰まった、といった問題につながります。

この記事では、実務で特にハマりやすい4点を整理します。

まず結論

  • @Transactional のデフォルト伝播は REQUIRED
  • デフォルトでは RuntimeExceptionError がロールバック対象
  • checked exception(検査例外)はデフォルトではロールバックされない
  • 同じクラス内から @Transactional メソッドを呼ぶ「自己呼び出し」では、デフォルトのproxy modeではトランザクションが適用されない
  • REQUIRES_NEW は完全に別の物理トランザクションを開始するが、追加のDBコネクションを必要とする点に注意

1. 同じクラス内から呼ぶと@Transactionalが効かない

次のコードを見てみます。

@Service
public class OrderService {

    public void execute() {
        saveOrder();
    }

    @Transactional
    public void saveOrder() {
        // DB更新
    }
}

一見すると saveOrder() はトランザクション内で実行されそうです。

しかし、Springのデフォルトである proxy mode では、この呼び方では @Transactional が適用されません。

proxyとは

proxy(プロキシ)は、対象オブジェクトの前に置かれる「代理オブジェクト」です。

ざっくり書くと次のようになります。

Controller
   ↓
SpringのProxy
   ↓  ← ここでトランザクション開始
OrderService

外部のBeanから OrderService を呼ぶとproxyを通ります。

一方、OrderService 内部から this.saveOrder() 相当の呼び出しをすると、proxyを経由しません。

OrderService.execute()
   ↓
OrderService.saveOrder()

そのため saveOrder() に付けた @Transactional が期待どおり動きません。

Spring公式ドキュメントでも、proxy modeでは外部からproxyを経由する呼び出しだけがintercept(横取り)され、self-invocationでは実行時のトランザクションにつながらないと説明されています。

分かりやすい解決方法

トランザクション境界を別のServiceへ切り出します。

@Service
public class OrderService {

    private final OrderWriter orderWriter;

    public OrderService(OrderWriter orderWriter) {
        this.orderWriter = orderWriter;
    }

    public void execute() {
        orderWriter.saveOrder();
    }
}
@Service
public class OrderWriter {

    @Transactional
    public void saveOrder() {
        // DB更新
    }
}

これなら OrderService -> proxy -> OrderWriter という呼び出しになるため、トランザクション境界もコード上で分かりやすくなります。

2. checked exceptionではデフォルトでロールバックされない

次のコードにも注意が必要です。

@Transactional
public void importFile() throws IOException {
    repository.save(...);

    throw new IOException("読み込み失敗");
}

IOException はchecked exceptionです。

Springのデフォルト設定では、次の場合に自動ロールバックされます。

RuntimeException → rollback
Error            → rollback
checked Exception → 原則rollbackしない

つまり「例外をthrowしたら必ずrollback」という仕様ではありません。

checked exceptionでもロールバックしたい場合は、例えば次のように指定できます。

@Transactional(rollbackFor = Exception.class)
public void importFile() throws IOException {
    repository.save(...);

    throw new IOException("読み込み失敗");
}

ただし、何でも Exception.class にするのではなく、アプリケーションで「どの失敗なら処理全体を取り消すのか」を決めておく方が安全です。

例えば独自例外なら次のようにできます。

@Transactional(rollbackFor = ImportFailedException.class)
public void importFile() throws ImportFailedException {
    // ...
}

なおSpring Framework 6.2以降では、@EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) によってデフォルトのrollback動作を全例外へ変更する選択肢も公式ドキュメントに記載されています。既存アプリでは挙動変更の影響があるため、導入前に例外設計とテストを確認しましょう。

3. REQUIREDとREQUIRES_NEWは名前以上に違う

@Transactional のデフォルト伝播(propagation)は REQUIRED です。

**propagation(伝播)**とは、「すでにトランザクションがある状態で別の@Transactionalメソッドを呼んだらどうするか」を決める設定です。

REQUIRED

@Transactional
public void createOrder() {
    repository.save(...);
    auditService.save(...);
}

auditService.save()REQUIRED なら、基本的に既存のトランザクションへ参加します。

createOrder
┌─────────────────────────┐
│ Transaction A           │
│                         │
│ order保存               │
│ audit保存               │
└─────────────────────────┘

一連の更新をまとめて成功・失敗させたい場合に自然な設定です。

REQUIRES_NEW

一方で次のようにすると、別の物理トランザクションになります。

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog() {
    auditRepository.save(...);
}

イメージは次のとおりです。

Transaction A
   ↓ 一時停止
Transaction B(REQUIRES_NEW)
   ↓ commit / rollback
Transaction A 再開

内側のTransaction Bは、外側とは独立してcommitまたはrollbackできます。

例えば「本処理が失敗しても監査ログだけ残したい」といった要件で候補になります。

4. REQUIRES_NEWの使いすぎはコネクションプールに注意

REQUIRES_NEW は便利ですが、無条件に使うものではありません。

Spring公式ドキュメントでは、外側のトランザクションがDBコネクションを保持したまま、内側の REQUIRES_NEW が新しいコネクションを取得するため、コネクションプール枯渇やデッドロックにつながる可能性があると注意されています。

例えば10スレッドが同時に次の状態になったとします。

Thread 1 → Transaction A → connection 1を保持
Thread 2 → Transaction A → connection 2を保持
...
Thread 10 → Transaction A → connection 10を保持

プールサイズが10で、全スレッドが同時に REQUIRES_NEW を開始すると、追加コネクションを取得できません。

Thread 1 → 新しいconnection待ち
Thread 2 → 新しいconnection待ち
...

しかし既存の10本は外側のトランザクションが保持しています。

これが問題になります。

Spring公式ドキュメントは REQUIRES_NEW を利用する場合、コネクションプールを同時実行スレッド数より少なくとも1つ多く確保するよう注意しています。

ただし実システムの適切なプールサイズは、並列数、ネスト数、DB側の接続上限などによって変わります。「+1にすれば常に安全」という意味ではありません。

よくある判断基準

一連のDB更新を全部まとめたい

@Transactional
public void execute() {
    updateA();
    updateB();
}

まずはデフォルトの REQUIRED を検討します。

checked exceptionでも取り消したい

@Transactional(rollbackFor = MyBusinessException.class)

rollback対象を明示します。

本処理が失敗しても別処理だけcommitしたい

@Transactional(propagation = Propagation.REQUIRES_NEW)

独立トランザクションを検討できます。ただしコネクション使用量も確認します。

同じクラス内のメソッドへ@Transactionalを付けたい

まずServiceの責務とトランザクション境界を見直し、別Beanへの切り出しを検討すると分かりやすくなります。

まとめ

@Transactional はアノテーション1つで使えるため簡単に見えますが、実際にはproxy、例外の種類、propagation、DBコネクションといった仕組みが関係しています。

特に覚えておきたいのは次の4点です。

  1. デフォルトのproxy modeでは自己呼び出しに注意する
  2. checked exceptionはデフォルトではロールバックされない
  3. 通常は REQUIRED、独立させる明確な理由がある場合に REQUIRES_NEW を検討する
  4. REQUIRES_NEW を使う場合はコネクションプールまで考える

「とりあえず@Transactionalを付ける」よりも、「どこからどこまでを1つの処理として成功・失敗させたいか」を先に決めると、トランザクション設計がかなり分かりやすくなります。

参考