↩️

Spring Framework 6.2 になると @Transactionalで検査例外もロールバックするための設定が増える (予定)

2024/05/06に公開

Spring Frameworkの @Transactional は、デフォルトで
「非検査例外(RuntimeExceptionおよびそのサブクラス)ならロールバックする」
という挙動です。(2024/05現在)

Spring Framework 6.2からは設定項目が増え、アプリ全体での設定として「全ての例外でロールバックする」と指定できるようになる見込みなので、現行の挙動の整理とあわせて紹介します。

対象読者

  • Spring Frameworkの @Transactional でトランザクション制御をしてDBアクセスしている
  • 次のいずれかにあてはまる
    • Kotlinを使っている
    • Javaの検査例外のために rollbackFor 属性を毎回固定で指定している、もしくは検査例外/非検査例外での挙動の違いを気にしていない(不具合リスクに気づいていない)

おことわり

背景

@Transactional をつけたときの動き

Springの @Transaction は、そのメソッドがエラーになったら(例外がスローされてメソッドを抜けたら)ロールバックする という動きをさせるために使われている。
しかし、EJBの仕様とそろえたという歴史的経緯により、「検査例外はコミットする」「非検査例外はロールバックする」というのがデフォルトの動きとなっている。

例外の種類によらずロールバックさせるためには、

/* Java */
@Transactional(rollbackFor=Exception.class)
public void hoge() throws IOException {
    ...
}

のように、rollbackFor 属性を毎回指定する必要がある。

これでもいいのだが、最近のJavaは(あるいはSpring自体も)基本的には非検査例外を使うことにしていて、
メソッドに throws 節が付いていないことを確認して、rollbackFor は特に指定しない という規約にしていることが多いかと思う。

/* Java */
@Transactional
public void hoge() { // throws節がない = 非検査例外しかスローされない = 例外スローされたら必ずロールバックされる
    ...
}

SpringのKotlinサポートにより発生した問題

Kotlinの言語仕様との相性

Spring FrameworkはKotlinを公式にサポートしている。
Kotlinは言語仕様として「検査例外」「非検査例外」を区別しておらず、throws 節が存在しない。

そのため、例えば次のコードは例外スローされてコミットされるが、メソッドのシグネチャ等から判断することができない。

/* Kotlin */
@Transactional
fun hoge() {
    // ... なにかDB書き込みを行う操作...
    throw IOException("error")  // IOExceptionはJava検査例外
}

検査例外はJava/Kotlin標準ライブラリを代表とする多数の由緒正しいライブラリでスローすることがある。
また、発生原因はディスクIOエラーやネットワークIOエラーなど、発生頻度が低いものが多い。

つまり、Kotlinで予期せぬトランザクション不整合を防ぎたければ、毎回 @Transactional(rollbackFor = [Exception::class])rollbackFor を指定する必要がある。

このことは、Spring FrameworkのIssueとしても2019/08に起票されていた。が、フレームワークとしての対応は長らくされていなかった。
https://github.com/spring-projects/spring-framework/issues/23473

対する Kotlin界隈での知名度

上のことについて サーバーサイドKotlin LT大会 vol.10 というイベントで発表したことがあり、
そのときに「ヤベ、知らなかった」という反応がちらほらあった。

https://speakerdeck.com/wkwkhautbois/20230929-javatokotlindeli-wai-chu-li-noxiang-xing-gae-inatosi-tutashun-jian

その後、ラクス Advent Calendar 2023 でも 「KotlinにおけるJava検査例外の扱いとSpringの@Transactionalの罠」として、同様の内容の記事が書かれていた。
https://qiita.com/chorei/items/30f9e6d9983ce8265c4b

Java + Spring Framework の組み合わせは鉄板であるが、Kotlin の場合は他のフレームワークを使うことも多くSpringの話題は多くないこともあってか、
Kotlin + Spring Transaction で注意が必要な点は、そこそこ知られているがまだ注意喚起が必要 といった感覚である。

新しい設定の登場

2019/08の起票なので5年越しの解決になるが、2024/11リリース予定の Spring Framework 6.2 に向けてPull Requestがマージされた。
https://github.com/spring-projects/spring-framework/commit/7d4c8a403e411d8e807c1d669a1257fc132ddaf6

これにより、アプリ全体でのロールバック判定方法を

  • 非検査例外をロールバック(これまで通り、6.2でもデフォルト)
  • 全ての例外をロールバック

の二択で選択できるようになる。

使い方

残念ながらSpring Bootでの自動構成の方法はまだ議論されていないようであった。
そのため、自分でアノテーションで指定する方法を記載する。

といっても簡単で、@EnableTransactionManagementrollbackOn 属性値を1つ指定するだけである。
org.springframework.transaction.annotation.RollbackOn という enum があるのでそれを使い指定する。 上で述べた二択がそれぞれ、

  • RollbackOn.RUNTIME_EXCEPTIONS
  • RollbackOn.ALL_EXCEPTIONS

と対応する。

/* Java */
@Configuration
@EnableTransactionManagement(rollbackOn =RollbackOn.ALL_EXCEPTIONS)
public class AppConfig {}
/* kotlin */
@Configuration
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
class AppConfig

ただし、Spring Boot の自動構成の動きに合わせるなら、 proxyTargetClass = true とCGLibを使う設定が入っているので合わせるのが無難と思う。
(※ここで指定をしなくても、他の設定でどこか1箇所でも true なら全体が true になる。)

/* Java */
@Configuration
@EnableTransactionManagement(
    proxyTargetClass = true,  // CGLibプロキシを利用する
    rollbackOn = RollbackOn.ALL_EXCEPTIONS  // 全ての例外でロールバック
)
public class AppConfig {}
/* kotlin */
@Configuration
@EnableTransactionManagement(
    proxyTargetClass = true,
    rollbackOn = RollbackOn.ALL_EXCEPTIONS
)
class AppConfig

なお、現行バージョンである Spring Boot 3.2 では、TransactionAutoConfiguration@EnableTransactionManagement の設定をしているので、 Spring Boot 3.4 ではこのあたりの実装が変わるのではないかと思われる。

感想

これまでも、@Transactional(rollbackFor=Exception.class) と必ず rollbackFor を指定するルールとしていれば不具合を避けることができていました。
また、TransactionAttributeSource をBean登録することで全体設定を自前で指定するワークアラウンドも前述のIssueには紹介されていました。
そのため、対応の優先度も低く5年かかったのでしょう。

しかし、トランザクション制御というとても気を遣う部分で(EJBに親しみのない身としては)不自然と感じるルールで、毎回 rollbackFor を指定するのは煩わしくまた忘れそうだと思っていました。
TransactionAttributeSource をBean登録する方法でも、今度は細かい制御が効かなくなったり、メジャーアップデート時に破壊的変更が入るリスクがあったりしたので、
公式で解決方法を用意してくれたのは助かります。

欲を言うと...

あまり話を聞きませんが、@Transactional は例外によるロールバックのほかに、vavr を使った宣言的なトランザクション制御も対応しています。
https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/rolling-back.html

ただ、

  • vavrが開発停滞して代わりのメンテナを探している状態である
  • Kotlin向けとしては arrow の方が名前を聞く

など合って、そのあたりまで手が入るかと期待した部分もありましたが…さすがに過剰でしたね。

https://github.com/vavr-io/vavr/issues/2756#issuecomment-1855448427
https://github.com/arrow-kt/arrow

Discussion