これは、なにをしたくて書いたもの?
Spring Frameworkを使っていると、トランザクション管理を@Transactionalアノテーションを使って宣言的に書いていることが
多いと思います。
@Transactionalを使った場合、例外(デフォルトではRuntimeExceptionのサブクラス)がスローされた時にロールバックされる
ことになっています。ここで、@Transactionalアノテーションが付与されたメソッドがネストし、かつ途中で例外を
捕捉した場合にどういう挙動になるのか、確認してみたいなと思いまして。
トランザクションの伝播については、PROPAGATION_REQUIREDを主な対象にしています。
Springの宣言的トランザクションとトランザクションの伝播
Springの宣言的トランザクションに関するドキュメントとしては、こちらですね。
Declarative Transaction Management
また、@Transactionalアノテーションについては、こちらに記載されています。
ここで、トランザクションの伝播に関するドキュメントを読んでみます。
最初に疑問に書いたことの答えが、実はここに書いています。
Understanding PROPAGATION_REQUIRED
PROPAGATION_REQUIREDを使ったトランザクションがネストし、内側のトランザクションのロールバックが確定した場合の
ことが書いてあります。
When the propagation setting is PROPAGATION_REQUIRED, a logical transaction scope is created for each method upon which the setting is applied. Each such logical transaction scope can determine rollback-only status individually, with an outer transaction scope being logically independent from the inner transaction scope. In the case of standard PROPAGATION_REQUIRED behavior, all these scopes are mapped to the same physical transaction. So a rollback-only marker set in the inner transaction scope does affect the outer transaction’s chance to actually commit.
However, in the case where an inner transaction scope sets the rollback-only marker, the outer transaction has not decided on the rollback itself, so the rollback (silently triggered by the inner transaction scope) is unexpected. A corresponding UnexpectedRollbackException is thrown at that point. This is expected behavior so that the caller of a transaction can never be misled to assume that a commit was performed when it really was not. So, if an inner transaction (of which the outer caller is not aware) silently marks a transaction as rollback-only, the outer caller still calls commit. The outer caller needs to receive an UnexpectedRollbackException to indicate clearly that a rollback was performed instead.
つまり、内側のトランザクションのロールバックが確定していると外側のトランザクションはUnexpectedRollbackExceptionを
スローする、ということになりそうです。
内側のトランザクションを独立させたい場合は、PROPAGATION_REQUIRES_NEWを使うわけですね。
Understanding PROPAGATION_REQUIRES_NEW
今回は、ここまで含めて確認してみようかな、と思います。
環境
今回の環境は、こちらです。
$ java --version openjdk 11.0.11 2021-04-20 OpenJDK Runtime Environment (build 11.0.11+9-Ubuntu-0ubuntu2.20.04) OpenJDK 64-Bit Server VM (build 11.0.11+9-Ubuntu-0ubuntu2.20.04, mixed mode, sharing) $ mvn --version Apache Maven 3.8.1 (05c21c65bdfed0f71a2f2ada8b84da59348c4c5d) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 11.0.11, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.4.0-73-generic", arch: "amd64", family: "unix"
データベースはMySQL 8.0.25を使い、172.17.0.2で動作しているものとします。
Spring Bootプロジェクトを作成する
最初に、Spring Bootプロジェクトを作成します。Spring Bootのバージョンは2.5.0で、依存関係にはjdbcとmysqlを加えます。
$ curl -s https://start.spring.io/starter.tgz \ -d bootVersion=2.5.0 \ -d javaVersion=11 \ -d name=transactional-nested-required \ -d groupId=org.littlewings \ -d artifactId=transactional-nested-required \ -d version=0.0.1-SNAPSHOT \ -d packageName=org.littlewings.spring.jdbc \ -d dependencies=jdbc,mysql \ -d baseDir=transactional-nested-required | tar zxvf - $ cd transactional-nested-required $ find src -name '*.java' | xargs rm
Maven依存関係などは、こんな感じです。
<properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
確認はテストコードで行おうと思いますが、@SpringBootApplicationアノテーションを付与したクラスは作成して
おきます。
src/main/java/org/littlewings/spring/jdbc/App.java
package org.littlewings.spring.jdbc; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class App { }
application.propertiesは、こんな設定にしておきました。
src/main/resources/application.properties
spring.datasource.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8 spring.datasource.username=kazuhira spring.datasource.password=password
お題
@Transactionalアノテーションを付与したメソッドを持つクラスを作成し、ふつうに処理を完了させたり、例外をスローして
キャッチしたりしなかったり…といくつかバリエーションをつけて確認してみましょう。
トランザクションの伝播はPROPAGATION_REQUIRESから始め、PROPAGATION_REQUIRES_NEWも織り交ぜて
いくようにします。
テストコードの雛形
まずはテストコードの雛形を作成しましょう。こんな感じにしました。
src/test/java/org/littlewings/spring/jdbc/TransactionalTest.java
package org.littlewings.spring.jdbc; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.UnexpectedRollbackException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest public class TransactionalTest { @Autowired JdbcTemplate jdbcTemplate; @BeforeEach public void createTable() { jdbcTemplate.execute("drop table if exists sample"); jdbcTemplate.execute("create table sample(word varchar(25));"); } // ここに、テストを書く }
テストごとに、テーブルをDROP & CREATEします。
Serviceクラス
今回は、Serviceクラスを2つ用意します。それぞれ、@Transactionalアノテーションを付与したメソッドを持ち、
外側のトランザクション、内側のトランザクションを表現します。
外側に該当するものは、こちら。JdbcTemplateと、内側のServiceクラスを使用します。
src/main/java/org/littlewings/spring/jdbc/MyService.java
package org.littlewings.spring.jdbc; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service public class MyService { Logger logger = LoggerFactory.getLogger(MyService.class); JdbcTemplate jdbcTemplate; NestedService nestedService; public MyService(JdbcTemplate jdbcTemplate, NestedService nestedService) { this.jdbcTemplate = jdbcTemplate; this.nestedService = nestedService; } // メソッドを書く }
MyServiceクラスから呼び出されるのは、こちら。同じく、JdbcTemplateを使用します。
src/main/java/org/littlewings/spring/jdbc/NestedService.java
package org.littlewings.spring.jdbc; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service public class NestedService { Logger logger = LoggerFactory.getLogger(NestedService.class); JdbcTemplate jdbcTemplate; public NestedService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } // メソッドを書く }
これらのクラスとテストコードを使って、確認してきましょう。
テストコード側では、上記のMyServiceクラスを使用します。
@SpringBootTest public class TransactionalTest { @Autowired JdbcTemplate jdbcTemplate; @BeforeEach public void createTable() { jdbcTemplate.execute("drop table if exists sample"); jdbcTemplate.execute("create table sample(word varchar(25));"); } @Autowired MyService myService;
ここから先は、MyService、NestedService、テストコードの順でいろいろ書いて確認していきます。
PROPAGATION_REQUIRES
では、確認していきましょう。
コミットする
まずはふつうにトランザクションが完了する(=コミットする)パターン。指定されたwordを登録するinsert文を実行して、
そのwordを二重にして次のServiceクラスを呼び出します。
@Service public class MyService { @Transactional public int insertRequired(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequired(word + " " + word); } }
クラスの定義は、抜粋して書いていきます。
呼び出し先。
@Service public class NestedService { @Transactional public int insertRequired(String word) { return jdbcTemplate.update("insert into sample(word) values(?)", word); } }
どちらも更新件数を返すので、両方の更新がうまくいった場合は戻り値が2になりますね。
テストコード。データが登録されているのが確認できます。
@Test public void transactionalNormally() { assertThat(myService.insertRequired("Hello!!")).isEqualTo(2); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEqualTo(List.of("Hello!!", "Hello!! Hello!!")); }
ロールバックする
次は、ロールバックするパターン。
外側のServiceクラスはふつうですが
@Service public class MyService { @Transactional public int insertRequiredAndNestedThrown(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequiredAndThrown(word + " " + word); } }
内側のServiceクラスでは例外をスローします。
@Service public class NestedService { @Transactional public int insertRequiredAndThrown(String word) { jdbcTemplate.update("insert into sample(word) values(?)", word); throw new RuntimeException("Oops!!"); } }
テストコード側。ロールバックですね。
@Test public void transactionalNestedFailed() { assertThatThrownBy(() -> myService.insertRequiredAndNestedThrown("Hello!!")) .isInstanceOf(RuntimeException.class) .hasMessage("Oops!!"); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEmpty(); }
内側のServiceクラスが例外をスローし、外側のServiceクラス内で捕捉する
続いては、内側のServiceクラスのメソッドが例外をスローして、外側のServiceクラスでその例外を補足するパターン。
つまり、外側のServiceクラスはこんな感じです。
@Service public class MyService { @Transactional public int insertRequiredAndNestedThrownAndCatch(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); try { nestedService.insertRequiredAndThrown(word + " " + word); } catch (RuntimeException e) { logger.error("insert failed", e); } return result; } }
内側のServiceクラスは、先ほどと同じです。
@Service public class NestedService { @Transactional public int insertRequiredAndThrown(String word) { jdbcTemplate.update("insert into sample(word) values(?)", word); throw new RuntimeException("Oops!!"); } }
テストコードは外側のServiceクラスから例外がスローされない…と思いきや、UnexpectedRollbackExceptionがスローされる
ことになります。
こんなことを言われつつ。
Transaction rolled back because it has been marked as rollback-only
@Test public void transactionalNestedFailedAndCatch() { assertThatThrownBy(() -> myService.insertRequiredAndNestedThrownAndCatch("Hello!!")) .isInstanceOf(UnexpectedRollbackException.class) .hasMessage("Transaction rolled back because it has been marked as rollback-only"); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEmpty(); }
ドキュメント通りですね。
内側のServiceクラスで例外は発生するものの、メソッドを例外で抜けない
なにを言っているかというと、内側のServiceクラスで例外は発生するものの、トランザクションの境界となるメソッドは
例外で脱出しない、と。
外側のServiceクラスは、こんな感じです。
@Service public class MyService { @Transactional public int insertRequiredAndNestedIgnoreFailed(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequiredAndIgnoreFailed(word + " " + word); } }
内側のServiceクラスは誤ったSQL文を実行させて例外が発生しますが、メソッド自体は例外では抜けません。
@Service public class NestedService { @Transactional public int insertRequiredAndIgnoreFailed(String word) { try { // 構文誤りで実行に失敗するSQL jdbcTemplate.update("insert into sample(word) v(?)", word); } catch (RuntimeException e) { logger.error("sql error", e); } return 0; } }
テストコードでは、外側のServiceクラスの処理結果だけが反映(=コミット)されていることが確認できます。
@Test public void transactionalNestedIgnoreFailed() { assertThat(myService.insertRequiredAndNestedIgnoreFailed("Hello!!")).isEqualTo(1); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEqualTo(List.of("Hello!!")); }
予想できる話ではありますが、トランザクションの境界を例外で抜けていませんからね。
つまり、トランザクションの伝播がPROPAGATION_REQUIREDの積み重ねとなりえる場合は、中途半端なところで
例外を捕まえずにトランザクションの境界外で捕捉するか、トランザクションの境界を跨がないうちに捕捉すること、という
感じにした方が良さそうですね。
ソースコードから確認する
このあたりの動作を、ソースコード上でも確認してみましょう。
トランザクション境界となるメソッドを例外で抜けた時点で、ロールバックのマークが行われます。
ResourceHolderSupportでのrollbackOnlyがtrueに設定されるのが、そのマークですね。
この状態になると、外側のトランザクション境界をコミットしようとしたタイミングで、ロールバックを行うようにマークされて
いることが検出されます。
そして、UnexpectedRollbackExceptionがスローされます、と。
こちらが、ソースコード上での確認ですね。
あとは、もう少しバリエーションを確認してみましょう。
PROPAGATION_REQUIRESとPROPAGATION_REQUIRES_NEWの組み合わせ
今度は、外側をPROPAGATION_REQUIRES、内側をPROPAGATION_REQUIRES_NEWにしてみます。
外側と内側で、トランザクションが別になりますね。
コミットする
外側のServiceクラス。
@Service public class MyService { @Transactional public int insertRequiredAndNestedNew(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequiredNew(word + " " + word); } }
内側のServiceクラス。トランザクションの伝播レベルが、REQUIRES_NEWです。
@Service public class NestedService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNew(String word) { return jdbcTemplate.update("insert into sample(word) values(?)", word); } }
とはいえ、コミットするので結果はまあふつうです。
@Test public void transactionalNormallyNestedNew() { assertThat(myService.insertRequiredAndNestedNew("Hello!!")).isEqualTo(2); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEqualTo(List.of("Hello!!", "Hello!! Hello!!")); }
ロールバックする
続いて、ロールバック。こちらも変わったことはありません。
外側のServiceクラス。
@Service public class MyService { @Transactional public int insertRequiredAndNestedNewThrown(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequiredNewAndThrown(word + " " + word); } }
内側のServiceクラス。
@Service public class NestedService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndThrown(String word) { jdbcTemplate.update("insert into sample(word) values(?)", word); throw new RuntimeException("Oops!!"); } }
両方のメソッドを例外で抜けるので、ロールバックします。
@Test public void transactionalNestedNewFailed() { assertThatThrownBy(() -> myService.insertRequiredAndNestedNewThrown("Hello!!")) .isInstanceOf(RuntimeException.class) .hasMessage("Oops!!"); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEmpty(); }
内側のServiceクラスが例外をスローし、外側のServiceクラス内で捕捉する
こちらは、PROPAGATION_REQUIREDの時と変化があります。
外側のServiceクラス。内側のServiceクラスがスローした例外を捕捉します。
@Service public class MyService { @Transactional public int insertRequiredAndNestedNewThrownAndCatch(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); try { nestedService.insertRequiredNewAndThrown(word + " " + word); } catch (RuntimeException e) { logger.error("insert failed", e); } return result; } }
内側のServiceクラスは、先ほどと同じです。
@Service public class NestedService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndThrown(String word) { jdbcTemplate.update("insert into sample(word) values(?)", word); throw new RuntimeException("Oops!!"); } }
テストコード。PROPAGATION_REQUIREDがネストしている時とは異なり、UnexpectedRollbackExceptionはスローされず
外側のトランザクションはコミットされます。
@Test public void transactionalNestedNewFailedAndCatch() { assertThat(myService.insertRequiredAndNestedNewThrownAndCatch("Hello!!")).isEqualTo(1); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEqualTo(List.of("Hello!!")); // 例外にならない }
別々のトランザクションになっているので、こうなりますよね。
内側のServiceクラスで例外は発生するものの、メソッドを例外で抜けない
こちらも試してみます。
外側のServiceクラス。
@Service public class MyService { @Transactional public int insertRequiredAndNestedNewIgnoreFailed(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequiredNewAndIgnoreFailed(word + " " + word); } }
内側のServiceクラス。
@Service public class NestedService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndIgnoreFailed(String word) { try { // 構文誤りで実行に失敗するSQL jdbcTemplate.update("insert into sample(word) v(?)", word); } catch (RuntimeException e) { logger.error("sql error", e); } return 0; } }
テストコード。こちらについては、それぞれ別のトランザクションがコミットされただけ、となります。
@Test public void transactionalNestedNewIgnoreFailed() { assertThat(myService.insertRequiredAndNestedNewIgnoreFailed("Hello!!")).isEqualTo(1); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEqualTo(List.of("Hello!!")); }
そりゃあ、そうなりますよね、と。
両方ともPROPAGATION_REQUIRES_NEWにする
今回の確認方法でこれをやる意味はない気がしますが、網羅的な意味では一応…ということで。
ソースコードと結果だけ載せます。
PROPAGATION_REQUIRES、PROPAGATION_REQUIRES_NEWの組み合わせと同じ結果になります。それぞれが
独立することを明示しているだけなので。
コミットする
外側のServiceクラス。
@Service public class MyService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndNestedNew(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequiredNew(word + " " + word); } }
内側のServiceクラス。
@Service public class NestedService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNew(String word) { return jdbcTemplate.update("insert into sample(word) values(?)", word); } }
テストコード。
@Test public void transactionalNormallyNewNestedNew() { assertThat(myService.insertRequiredNewAndNestedNew("Hello!!")).isEqualTo(2); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEqualTo(List.of("Hello!!", "Hello!! Hello!!")); }
ロールバックする
外側のServiceクラス。
@Service public class MyService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndNestedNewThrown(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequiredNewAndThrown(word + " " + word); } }
内側のServiceクラス。
@Service public class NestedService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndThrown(String word) { jdbcTemplate.update("insert into sample(word) values(?)", word); throw new RuntimeException("Oops!!"); } }
テストコード。
@Test public void transactionalNewNestedNewFailed() { assertThatThrownBy(() -> myService.insertRequiredNewAndNestedNewThrown("Hello!!")) .isInstanceOf(RuntimeException.class) .hasMessage("Oops!!"); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEmpty(); }
内側のServiceクラスが例外をスローし、外側のServiceクラス内で捕捉する
外側のServiceクラス。
@Service public class MyService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndNestedNewThrownAndCatch(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); try { nestedService.insertRequiredNewAndThrown(word + " " + word); } catch (RuntimeException e) { logger.error("insert failed", e); } return result; } }
内側のServiceクラス。
@Service public class NestedService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndThrown(String word) { jdbcTemplate.update("insert into sample(word) values(?)", word); throw new RuntimeException("Oops!!"); } }
テストコード。
@Test public void transactionalNewNestedNewFailedAndCatch() { assertThat(myService.insertRequiredNewAndNestedNewThrownAndCatch("Hello!!")).isEqualTo(1); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEqualTo(List.of("Hello!!")); // 例外にならない }
内側のServiceクラスで例外は発生するものの、メソッドを例外で抜けない
外側のServiceクラス。
@Service public class MyService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndNestedNewIgnoreFailed(String word) { int result = jdbcTemplate.update("insert into sample(word) values(?)", word); return result + nestedService.insertRequiredNewAndIgnoreFailed(word + " " + word); } }
内側のServiceクラス。
@Service public class NestedService { @Transactional(propagation = Propagation.REQUIRES_NEW) public int insertRequiredNewAndIgnoreFailed(String word) { try { // 構文誤りで実行に失敗するSQL jdbcTemplate.update("insert into sample(word) v(?)", word); } catch (RuntimeException e) { logger.error("sql error", e); } return 0; } }
テストコード。
@Test public void transactionalNewNestedNewIgnoreFailed() { assertThat(myService.insertRequiredNewAndNestedNewIgnoreFailed("Hello!!")).isEqualTo(1); assertThat(jdbcTemplate.queryForList("select word from sample order by word", String.class)) .isEqualTo(List.of("Hello!!")); }
まとめ
Spring Frameworkを使った時の、REQUIREDな伝播レベルのトランザクションがネストした時に、例外をどう扱うかで
どのような挙動するのか、確認してみました。
ドキュメントに答えは書いてあるのですが、REQUIREDがネストしている時に中途半端な場所で例外を捕捉したりすると
厄介なことになりそうですね。
伝播レベルをわけるか、例外を捕まえるか、トランザクション境界を抜けてしまうか、このあたりをちゃんと考えて
書かないといけないですね。