🗂

Springの@TransactionalでハマるUnexpectedRollbackExceptionの原因と回避方法

に公開

概要

Springの@Transactionalアノテーションによるトランザクション管理には、少し注意が必要な点があります。特にController層で@Transactionalを使うと、例外処理後にUnexpectedRollbackExceptionが発生するケースがあります。この記事では、実際に起きた例をもとにその原因と回避方法を整理します。

予備知識

  • @Transactionalアノテーション
    • ここからトランザクションを始めるという目印程度に思ってください
  • BeanとかProxyとか
    • springにおけるインスタンス程度に思ってもらって大丈夫です
  • DBへのコミットとトランザクション
    以上へのなんとなくの理解があると読めると思います。

本題

@Transactionalが付いた状態の処理でエラーが発生するとトランザクションがrollback-onlyモード※となります。トランザクションがrollback-onlyの場合、トランザクションを適切に終了させずに正常なレスポンスを返そうとするとUnexpectedRollbackExceptionが発生します。

※トランザクション内で例外がスローされると、そのトランザクションは内部的に「もうコミットできない状態(rollback-only)」にマークされます。

実行例

簡単なUsersテーブルとユーザ情報を返す簡単なAPIを用いた例です。下記リンクの一部のコードを抜粋して説明します。
https://github.com/Tamashoo/spring_transaction_demo

Users.java
package com.example.demo;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "users", uniqueConstraints = @UniqueConstraint(columnNames = "username"))
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Users {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(nullable = false, unique = true)
    private String username;
}
UsersRepository.java
package com.example.demo;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UsersRepository extends JpaRepository<Users, Integer> {
    boolean existsByUsername(String username);
}
UsersService.java

package com.example.demo;

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

import java.util.List;

@Service
public class UsersService {

    private final UsersRepository usersRepository;

    public UsersService(UsersRepository usersRepository) {
        this.usersRepository = usersRepository;
    }

    @Transactional
    public Users addUser(String username) {
        if (usersRepository.existsByUsername(username)) {
            throw new RuntimeException("Username already exists.");
        }
        Users user = new Users();
        user.setUsername(username);
        return usersRepository.save(user);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public List<Users> getAllUsers() {
        return usersRepository.findAll();
    }
}
UsersController.java
package com.example.demo;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;


import java.util.List;

@RestController
@RequestMapping("/users")
public class UsersController {

    private static final Logger log = LoggerFactory.getLogger(UsersController.class);
    private final UsersService usersService;

    public UsersController(UsersService usersService) {
        this.usersService = usersService;
    }

    @PostMapping
//    @Transactional
    public List<Users> createUser(@RequestBody UserRequest request) {
        List<Users> users;
        try {
            usersService.addUser(request.getUsername());
        } catch (RuntimeException e) {
            log.error("ユーザの追加に失敗");
        } finally {
            users = usersService.getAllUsers();
            log.info("ユーザ一覧取得");
        }
        return users;
    }

    @GetMapping
    public List<Users> listUsers() {
        return usersService.getAllUsers();
    }

    // リクエストボディ用クラス
    public static class UserRequest {
        private String username;

        public String getUsername() {
            return username;
        }

        public void setUsername(String username) {
            this.username = username;
        }
    }
}

POST /usersをしたときに、登録の成功の可否に関わらずユーザ一覧を返す処理を想定したコードとなっています。また、DBには以下の1レコードだけ情報が存在しているとします。

$ curl -X GET http://localhost:8080/users
[{"id":1,"username":"alice"}]

トランザクションの扱いが適切でない場合

UsersControllerの@Transactionalのコメントアウトを外して既に存在するレコードと同じ情報をPOSTしようとすると以下のようになります
UsersController.java

    @PostMapping
    @Transactional
    public List<Users> createUser(@RequestBody UserRequest request) {
        List<Users> users;
        try {
            usersService.addUser(request.getUsername());
        } catch (RuntimeException e) {
            log.error("ユーザの追加に失敗");
        } finally {
            users = usersService.getAllUsers();
            log.info("ユーザ一覧取得");
        }
        return users;
    }

POST /users

$ curl -X POST http://localhost:8080/users \ -H "Content-Type: application/json" \ -d '{"username": "alice"}'
{"timestamp":"2025-06-29T08:30:55.874+00:00","status":500,"error":"Internal Server Error","path":"/users"}
log
2025-06-29T17:53:04.163+09:00 ERROR 18600 --- [demo] [nio-8080-exec-1] com.example.demo.UsersController : ユーザの追加に失敗
2025-06-29T17:53:04.198+09:00 INFO 18600 --- [demo] [nio-8080-exec-1] com.example.demo.UsersController : ユーザ一覧取得
2025-06-29T17:53:04.205+09:00 ERROR 18600 --- [demo] [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only] with root cause
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only

これは、UsersControllerのプロキシ(spring上のインスタンスのようなもの)に紐づけられたトランザクションがrollback-onlyのままreturnをしようとしたためにエラーとなっています。

本来rollback-onlyモードの場合はSELECTの実行もできませんが、@Transactional(propagation = Propagation.REQUIRES_NEW)としているためクエリの実行が可能となっています。この設定により、現在のトランザクションとは独立した新しいトランザクションが開始されます。 つまり、外側のトランザクションがロールバックされても、このメソッド内のトランザクションには影響しません。

UsersService.java
    @Transactional(propagation = Propagation.REQUIRES_NEW) // <-ここで新たなトランザクションを設定している
    public List<Users> getAllUsers() {
        return usersRepository.findAll();
    }

トランザクションを適切に扱う場合

Controller層でトランザクションを管理しようとすると以上のような問題が発生するので、Service層でトランザクションを完結するようにします。今回の場合、変更としてはControllerの@Transactionalをコメントアウトするだけです。

Controllerの@Transactionalを外すことで、Service層でエラーが起きた場合でもトランザクションの処理を終えてくれるようになります。これは、Service層でトランザクションを開始・終了することで、Controller層にはトランザクション状態が伝播しないようになるためです。よって、Controllerのレスポンス時にはトランザクションが処理済みになっていてレスポンスを返すことができるようになります。
POST /users

$ curl -X POST http://localhost:8080/users -H "Content-Type: application/json" -d '{"username": "alice"}'
[{"id":1,"username":"alice"}]
log
2025-06-29T17:57:37.490+09:00 ERROR 23432 --- [demo] [nio-8080-exec-1] com.example.demo.UsersController : ユーザの追加に失敗
2025-06-29T17:57:37.522+09:00 INFO 23432 --- [demo] [nio-8080-exec-1] com.example.demo.UsersController : ユーザ一覧取得

まとめ

springのトランザクションの特殊な場合の扱い方について説明しました。間違い等ありましたら指摘していただけると幸いです。なお、本来はエラーが発生した場合に正常なレスポンスを返すのは望ましくありません。このような問題に直面した際には、処理の設計そのものを見直すことも選択肢の一つです。

Discussion