一括登録の処理で例外が出たのに、途中まで登録したデータがデータベースに残っていました。登録のメソッドには @Transactional を付けています。
調べると、ロールバックされない原因は2つありました。どちらもエラーは出ず、テストで例外の発生だけを確かめていると気付けません。
結論:
- 同じクラスの中から
@Transactionalのメソッドを呼ぶと、トランザクションがかからない。 呼び出し元を別のクラスに分けます - チェック例外(
Exceptionなど)では、既定ではロールバックされない。@Transactional(rollbackFor = Exception.class)を指定します
躓いたポイント
環境
| 項目 | バージョン |
|---|---|
| OS | Windows 11 |
| JDK | Eclipse Temurin 21.0.12 |
| Spring Boot | 3.5.16(Spring Framework 6.2.19) 4.1.1(Spring Framework 7.0.9) |
| データベース | H2(JdbcTemplate で操作) |
| Maven | 3.9.16 |
起きたこと
登録の処理を次のように書いていました。
@Service
public class AccountService {
private final JdbcTemplate jdbc;
public AccountService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
// 原因1: 同じクラスの中から @Transactional のメソッドを呼ぶ
public void registerAll(List<String> names) {
for (String name : names) {
register(name);
}
}
@Transactional
public void register(String name) {
jdbc.update("INSERT INTO account (name) VALUES (?)", name);
if (name.startsWith("NG")) {
throw new IllegalStateException("invalid name: " + name);
}
}
// 原因2: チェック例外を投げる
@Transactional
public void registerWithCheckedException(String name) throws Exception {
jdbc.update("INSERT INTO account (name) VALUES (?)", name);
throw new Exception("checked exception: " + name);
}
}
呼び方ごとに、例外が出た後にデータが残っているかを確かめました。
| 呼び方 | 例外 | データ |
|---|---|---|
外から register("NG-direct") を直接呼ぶ |
IllegalStateException |
残らない(ロールバックされた) |
外から registerAll(...) を呼び、その中で register を呼ぶ |
IllegalStateException |
残る |
外から registerWithCheckedException(...) を呼ぶ |
Exception |
残る |
Spring Boot 3.5.16 と 4.1.1 のどちらでも同じ結果でした。
ログで原因を確かめる
application.properties で次のログを出すと、トランザクションの開始と終了が見えます。
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.support.JdbcTransactionManager=DEBUG
外から register を直接呼んだときは、トランザクションを開始し、例外の後にロールバックしています。
Creating new transaction with name [com.example.demo.AccountService.register]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Getting transaction for [com.example.demo.AccountService.register]
Completing transaction for [com.example.demo.AccountService.register] after exception: java.lang.IllegalStateException: invalid name: NG-direct
Initiating transaction rollback
Rolling back JDBC transaction on Connection [...]
registerAll の中から呼んだときは、トランザクションのログが1行も出ません。トランザクションがかかっていないということです。
チェック例外のときは、トランザクションは開始していますが、例外の後に コミット しています。
Creating new transaction with name [com.example.demo.AccountService.registerWithCheckedException]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Completing transaction for [com.example.demo.AccountService.registerWithCheckedException] after exception: java.lang.Exception: checked exception: checked
Committing JDBC transaction on Connection [...]
どうやって解決したか
原因1:呼び出し元を別のクラスに分ける
Spring は @Transactional の付いたクラスを「プロキシ」という別のクラスで包み、外からの呼び出しを受けたときにトランザクションを開始します。注入された AccountService のクラス名を出力すると、プロキシになっていることが分かります。
AccountService bean class = com.example.demo.AccountService$$SpringCGLIB$$0
同じクラスの中の register(name) は this.register(name) のことで、プロキシを通りません。そこで、一括登録を別のクラスに移し、注入した AccountService を通して呼びます。
@Service
public class AccountBatchService {
private final AccountService accountService;
public AccountBatchService(AccountService accountService) {
this.accountService = accountService;
}
public void registerAll(List<String> names) {
for (String name : names) {
accountService.register(name);
}
}
}
AccountBatchService.registerAll から呼ぶと、ログにトランザクションの開始とロールバックが出て、データは残りませんでした。
原因2:rollbackFor でチェック例外もロールバック対象にする
@Transactional(rollbackFor = Exception.class)
public void registerWithRollbackFor(String name) throws Exception {
jdbc.update("INSERT INTO account (name) VALUES (?)", name);
throw new Exception("checked exception: " + name);
}
ログに -java.lang.Exception(Exception でロールバックする設定)が付き、例外の後にロールバックしました。
Creating new transaction with name [com.example.demo.AccountService.registerWithRollbackFor]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-java.lang.Exception
Initiating transaction rollback
業務システムでは、チェック例外を投げる既存の部品を呼ぶことが多いので、@Transactional を付けるときに rollbackFor が要るかを確認する習慣にしておくと安全です。
解決できた根拠
プロキシを通らない同じクラス内の呼び出しにはトランザクションがかからない
Spring Framework のリファレンスに、次のように書かれています。
In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted. This means that self-invocation (in effect, a method within the target object calling another method of the target object) does not lead to an actual transaction at runtime even if the invoked method is marked with @Transactional.
(既定のプロキシモードでは、プロキシを通る外部からの呼び出しだけが対象になります。つまり、自己呼び出し(対象のオブジェクトのメソッドが、同じオブジェクトの別のメソッドを呼ぶこと)は、呼ばれる側に @Transactional が付いていても、実行時にトランザクションになりません)
同じページでは、自己呼び出しにもトランザクションをかけたい場合は AspectJ モードを検討するよう書かれています。AspectJ モードはクラスファイルの書き換え(ウィービング)の設定が必要になるため、今回はクラスを分ける方法を選びました。
チェック例外は既定ではロールバックされない
Checked exceptions that are thrown from a transactional method do not result in a rollback in the default configuration.
(トランザクションのメソッドから投げられたチェック例外は、既定の設定ではロールバックになりません)
同じページに、既定でロールバックされるのは RuntimeException とそのサブクラス、および Error であること、@Transactional の rollbackFor などでロールバックの条件を指定できることが書かれています。