一つのトレイのロックがかかり、注文と審査を表す2列がまとめて戻る様子を表したイラスト

実装・エラー解決

SpringのUnexpectedRollbackExceptionの原因|catchしてもロールバックされるとき

この記事でわかること

Springの内側の @Transactional がトランザクションを rollback-only にすると、外側で例外を捕まえてもコミットできません。UnexpectedRollbackException が出る場所と、注文・審査をどこまで取り消すかを、H2に残る行で確かめます。

新人SE
新人SE
審査の例外はcatchしました。注文登録のメソッドもreturnまで進むのに、呼び出し元でUnexpectedRollbackExceptionが出ます。

ポンコツSE
ポンコツSE
審査から例外が出た時点と、注文側がコミットする時点を分けよう。DBに残すべきものも先に決めたい。

注文を1件登録し、別のServiceで審査結果を記録する処理を考えます。審査の途中で失敗したので、呼び出し側は例外を捕まえて、注文だけ ACCEPTED にしようとしました。しかし、呼び出し元へ戻ると例外になり、注文も審査もDBにありません。今回の出発点は、例外処理が実行されたことと、DBの更新が確定したことが食い違う場面です。

実行用サンプル

ZIPには、2種類のトランザクション境界を持つサービス、H2のDDL、RollbackContractTest.java、pom.xml、READMEを収録。JDK 17とMaven 3.9系で mvn test を実行できます。外部DBやWebサーバーは不要です。

catchのあとreturnまで進むのに、注文も審査も戻る

外側の OrderRegistration は注文を保存し、別のSpring Beanである ReviewService を呼びます。審査側は結果を書いた直後に、再現用の ReviewFailedException を投げます。これは RuntimeException のサブクラスです。実際の業務エラーを模すため、SQLの構文エラーや外部サービスの通信失敗は起こしていません。

OrderRegistration.java の対象メソッドを抜粋

    @Transactional
    public String registerAndCatch(String requestId, boolean failReview) {
        store.insertOrder(requestId);
        try {
            reviews.reviewInSameTransaction(requestId, failReview);
        } catch (ReviewFailedException failure) {
            boolean rollbackOnly = TransactionAspectSupport.currentTransactionStatus().isRollbackOnly();
            LOG.info("outer caught review failure; rollbackOnly={}", rollbackOnly);
        }
        store.updateOrder(requestId, "ACCEPTED");
        LOG.info("outer method reached its return");
        return "ACCEPTED";
    }

呼び出し元からSpringが管理する OrderRegistration を呼ぶと、ログには下の2行が出ます。メソッド内では例外を捕まえ、return "ACCEPTED" の直前まで進みます。JUnitでは、呼び出し元が ACCEPTED を受け取らず、UnexpectedRollbackException を受け取ることを確認します。処理後に2テーブルをSELECTすると、どちらにも行がありません。

outer caught review failure; rollbackOnly=true
outer method reached its return

この構成で外側の境界から出るメッセージは Transaction rolled back because it has been marked as rollback-only です。ログの return 到達とこの例外が両方見えたら、メソッド本体と、Springがそのあと処理するトランザクション境界を分けて追います。

外側がコミットするとき、rollback-onlyが残っている

最初に、外側の @Transactional でトランザクションを開始します。内側も REQUIRED なので、すでにあるトランザクションへ参加します。Springが管理する内側のメソッドからロールバック対象の例外が出ると、そのトランザクションに「コミットせず戻す」という印が付きます。この状態が rollback-only です。

次に外側の catch が実行されます。ここで例外の伝播は止まりますが、共有中のトランザクションに付いた印は残ります。外側のメソッド本体が正常にreturnしようとしたとき、Springは境界でコミットを試みます。その時点で印を確認し、ロールバックしたことを UnexpectedRollbackException で外の呼び出し元へ知らせます。 Spring Framework 6.2のトランザクション伝播

調査するときは、最後に見えた UnexpectedRollbackException だけで修正箇所を決めず、先に失敗した内側のメソッドを探します。この例で見たい順序は「審査結果をINSERT」「審査の例外が内側の境界を出る」「外側でcatch」「外側のコミットで失敗」です。catchへ入ったかどうかと、どの境界がrollback-onlyにしたかを分けると、原因のコードまで戻れます。

関連記事Spring Bootの@Transactionalはどこに付ける? 複数の保存を一つの業務処理にするとき、Serviceのどこをトランザクション境界にするかを確認できます。 記事を読む

審査が必須なら、元の失敗を呼び出し元まで返す

「審査結果と注文をセットで保存する」と決まっているなら、外側で成功に変換せず、元の失敗を外側のトランザクション境界から出します。呼び出し元は ReviewFailedException を受け取ります。画面用のエラーへ変換する場合も、トランザクションを開始したサービスから制御が戻った場所で行えば、DBが確定する前に成功を返すことを避けられます。

OrderRegistration.java の対象メソッドを抜粋

    @Transactional
    public void registerWithRequiredReview(String requestId, boolean failReview) {
        store.insertOrder(requestId);
        reviews.reviewInSameTransaction(requestId, failReview);
        store.updateOrder(requestId, "REVIEWED");
    }

テストで審査を失敗させると、注文と審査結果は両方とも残りません。審査に成功した場合は、注文が REVIEWED、審査結果が CHECKED として両方残ります。例外名だけで判断せず、失敗時に2件とも取り消される契約を守れているかを確認します。

注文を保留で残せるなら、審査の確定を分ける

業務上「審査が失敗しても注文を PENDING で残し、あとでやり直せる」と決めている場合は、注文と審査で確定の単位を分けられます。サンプルは審査側の別Beanで REQUIRES_NEW を使い、その処理が失敗したら注文側は保留のまま終えます。審査結果のINSERTは取り消され、注文の行だけが残ります。

OrderRegistration.java の対象メソッドを抜粋

    @Transactional
    public void registerWithIndependentReview(
            String requestId, boolean failReview, boolean failOrder) {
        store.insertOrder(requestId);
        try {
            reviews.reviewInOwnTransaction(requestId, failReview);
            store.updateOrder(requestId, "REVIEWED");
        } catch (ReviewFailedException failure) {
            // The contract allows the order to stay PENDING for later review.
            LOG.info("independent review failed; order stays PENDING");
        }
        if (failOrder) {
            throw new IllegalStateException("注文登録の後続処理に失敗");
        }
    }

ReviewService.java の全文

package info.syachiku.rollback;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ReviewService {
    private final RegistrationStore store;

    ReviewService(RegistrationStore store) {
        this.store = store;
    }

    @Transactional(propagation = Propagation.REQUIRED)
    public void reviewInSameTransaction(String requestId, boolean failReview) {
        writeReview(requestId, failReview);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void reviewInOwnTransaction(String requestId, boolean failReview) {
        writeReview(requestId, failReview);
    }

    private void writeReview(String requestId, boolean failReview) {
        store.insertReview(requestId);
        if (failReview) {
            throw new ReviewFailedException("審査処理に失敗");
        }
    }
}

この方式では、逆向きの結果も受け入れる必要があります。内側の審査が先にコミットしてから外側の注文が失敗すると、注文はなくても審査結果は残ります。サンプルの審査行は受信時に発行済みの requestId を使い、未確定の注文行を外部キーで参照しません。注文が存在することを審査行の前提にしている場合は、この分割をそのまま当てはめないでください。審査を別トランザクションにすると接続も別に必要なので、並行実行する環境では接続プールやロック条件を確認します。

サービスから戻った後に、H2から2つの行を読み直す

テスト自身には @Transactional を付けません。サービス呼び出しが成功したか、どの例外を返したかを確かめた後、JdbcTemplate でH2へSELECTします。外側のトランザクションの途中で見えるデータより、処理が終わって別の問い合わせから見えるデータが、ここで確かめたい結果です。

条件呼び出し元注文審査
REQUIREDの審査失敗を外側でcatchUnexpectedRollbackExceptionなしなし
必須審査の失敗を伝えるReviewFailedExceptionなしなし
必須審査が成功正常終了REVIEWEDCHECKED
独立した審査が失敗正常終了PENDINGなし
独立した審査は成功、注文が後から失敗IllegalStateExceptionなしCHECKED

RollbackContractTest.java の全文

package info.syachiku.rollback;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
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(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ExtendWith(OutputCaptureExtension.class)
class RollbackContractTest {
    @Autowired
    private OrderRegistration orders;

    @Autowired
    private JdbcTemplate jdbc;

    @Test
    void 内側の失敗をcatchしても外側コミットで失敗し両方残らない(CapturedOutput output) {
        assertThatThrownBy(() -> orders.registerAndCatch("caught", true))
                .isInstanceOf(UnexpectedRollbackException.class)
                .hasMessageContaining("rollback-only");

        assertThat(output).contains("outer caught review failure; rollbackOnly=true")
                .contains("outer method reached its return");
        assertThat(orderStatuses("caught")).isEmpty();
        assertThat(reviewResults("caught")).isEmpty();
    }

    @Test
    void 審査が必須なら元の失敗を呼び出し元へ返し両方を取り消す() {
        assertThatThrownBy(() -> orders.registerWithRequiredReview("mandatory-fail", true))
                .isInstanceOf(ReviewFailedException.class)
                .hasMessage("審査処理に失敗");

        assertThat(orderStatuses("mandatory-fail")).isEmpty();
        assertThat(reviewResults("mandatory-fail")).isEmpty();
    }

    @Test
    void 必須の審査に成功すれば注文と審査結果を両方保存する() {
        orders.registerWithRequiredReview("mandatory-ok", false);

        assertThat(orderStatuses("mandatory-ok")).containsExactly("REVIEWED");
        assertThat(reviewResults("mandatory-ok")).containsExactly("CHECKED");
    }

    @Test
    void 審査を分けて失敗を許す契約なら注文だけを保留で残す() {
        orders.registerWithIndependentReview("review-fail", true, false);

        assertThat(orderStatuses("review-fail")).containsExactly("PENDING");
        assertThat(reviewResults("review-fail")).isEmpty();
    }

    @Test
    void 外側が後から失敗しても独立して確定した審査結果は残る() {
        assertThatThrownBy(() -> orders.registerWithIndependentReview("order-fail", false, true))
                .isInstanceOf(IllegalStateException.class)
                .hasMessage("注文登録の後続処理に失敗");

        assertThat(orderStatuses("order-fail")).isEmpty();
        assertThat(reviewResults("order-fail")).containsExactly("CHECKED");
    }

    private List<String> orderStatuses(String requestId) {
        return jdbc.queryForList("select status from order_registration where request_id = ?",
                String.class, requestId);
    }

    private List<String> reviewResults(String requestId) {
        return jdbc.queryForList("select result from review_record where request_id = ?",
                String.class, requestId);
    }
}

配布ZIPを展開し、pom.xml のある場所で mvn test を実行します。JDK 17、Spring Boot 3.5.16、H2 2.3.232で5件を確認しました。外部DBやWebサーバーは不要です。

Tests run: 5, Failures: 0, Errors: 0, Skipped: 0

レビューでは、失敗時にどの行を残すかまで書く

審査は注文登録に必須です。審査の失敗は呼び出し元へ伝え、注文と審査結果をどちらも取り消します。内側の例外を外側で捕まえて注文だけ成功にする実装は、この契約では使いません。

保留で受け付ける仕様なら、審査の失敗後に何を再実行するかと、注文が後から失敗した場合に残る審査行を誰が扱うかも書きます。ロールバックの原因がJPAの管理状態やflushにある場合は、Entityの更新を扱う関連記事へ進めます。

サービスの境界、トランザクション、永続化の関係を体系的に学ぶなら『Spring徹底入門 第2版』が参考になります。今回の伝播設定は、利用しているSpringの版と配布コードで確認してください。

書籍「Spring徹底入門 第2版」の表紙
商品画像:Amazon.co.jp
PR 入門後の参考書|Spring Framework 5向け

Spring徹底入門 第2版

株式会社NTTデータ (著), 株式会社NTTデータグループ (編集)

アプリを作った後に、DIやDB更新の仕組みを調べる。

DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。小さなWebアプリを一通り作り、Springの仕組みを詳しく学びたい人に向く参考書です。

  • Springで小さなWebアプリを作ったことがある
  • DIやトランザクションの仕組みを確認したい

Spring Framework 5向けです。Boot 3の実装に使うコード・設定は、採用している版の公式資料と照合してください。

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

関連記事Spring Data JPAでEntityを変更しても更新されない原因|@Transactionalとダーティチェック Entityが管理対象か、flushしたSQLがコミット後も残るかを、別の問い合わせで確かめます。 記事を読む 関連記事SpringのServiceが肥大化したらどこで分ける?計算・保存・通知の分割例 Serviceを分けたときに、処理順やDBと通知の境界が変わっていないかを確かめます。 記事を読む

catchの位置と、DBを確定する境界を見比べる

内側の失敗を外側でcatchしたときは、共有トランザクションがすでにrollback-onlyかを確かめます。注文と審査を一緒に確定するなら失敗を呼び出し元へ返します。保留注文と独立した審査結果を許すなら、それぞれが失敗した後に何が残るかを別々にテストします。

-実装・エラー解決
-, , ,