SpringのTransaction:@Transactionalとよくある落とし穴

1. 概要

@TransactionalはSpring Bootで最も使用されているアノテーションです。また、隠しにくいバグが発生しやすい場所でもあります:データがロールバックされない、transactionが「消える」、またはUnexpectedRollbackExceptionが不明確な理由で出現することがあります。

この記事では、@Transactionalがプロキシ経由でどのように機能するかを説明します。その後、各一般的な落とし穴を見ていきます。各落とし穴にはコード例と修正方法があります。読み終わったら、transactionがいつ実際に開始され、いつロールバックされるのかが分かります。

2. セットアップ

Spring Data JPAとH2インメモリを使用します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

transaction interceptorのログを有効にして、transactionがいつ開始され、いつ閉じられるかを確認します:

logging.level.org.springframework.transaction.interceptor=TRACE

この記事で使用するEntityとrepository:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue
    private Long id;
    private String customerName;
    private BigDecimal amount;

    protected Order() {
    }

    public Order(String customerName, BigDecimal amount) {
        this.customerName = customerName;
        this.amount = amount;
    }

    public Long getId() { return id; }
    public String getCustomerName() { return customerName; }
    public BigDecimal getAmount() { return amount; }
}

public interface OrderRepository extends JpaRepository<Order, Long> {
}

テーブルはorderがSQLキーワードであるため、ordersという名前です。

3. @Transactionalはどのように動作するか

3.1. 基本的な例

@Service
public class PaymentService {

    public void charge(String customer, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000")) > 0) {
            throw new IllegalStateException("支払い制限を超えています");
        }
    }
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

placeOrder("An", new BigDecimal("5000"))を呼び出すと、注文が最初にsaveされます。その後、chargeがIllegalStateExceptionをスローします。これはRuntimeExceptionなので、Springは全体をロールバックし、ordersテーブルは空のままです。

3.2. プロキシが中心

SpringはOrderServiceのバイトコードを変更しません。Spring Bootでデフォルトで使用されるCGLIB subclassであるプロキシを作成してbeanをラップします。他のbeanがOrderServiceをinjectすると、実際にはこのプロキシを受け取ります。各呼び出しはTransactionInterceptorを通過します:

caller → proxy → [transactionを開始] → OrderService.placeOrder() → [commit/rollback] → caller

TRACEログにこれが明確に表示されます:

TRACE o.s.t.i.TransactionInterceptor : Getting transaction for [com.nonla.order.OrderService.placeOrder]
TRACE o.s.t.i.TransactionInterceptor : Completing transaction for [com.nonla.order.OrderService.placeOrder] after exception: java.lang.IllegalStateException: 支払い制限を超えています

下記のほとんどの落とし穴は1つの原則から生じます:呼び出しがプロキシを通過しない場合、transactionはありません。

4. 落とし穴1:Self-invocation

@Service
public class OrderService {

    // ... 上記のようなコンストラクタ

    public void placeOrders(List<String> customers) {
        for (String customer : customers) {
            placeOrder(customer, new BigDecimal("100")); // thisを経由して呼び出し
        }
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        System.out.println("Transaction active: "
                + TransactionSynchronizationManager.isActualTransactionActive());
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

controllerからorderService.placeOrders(List.of("An", "Bình"))を呼び出すと、以下が出力されます:

Transaction active: false
Transaction active: false

placeOrdersには@Transactionalがないため、プロキシは呼び出しをそのまま本体のオブジェクトに渡します。内部では、placeOrder(...)はthis.placeOrder(...)と同等で、thisは元のオブジェクトでプロキシではありません。アノテーションは完全に無視されます。各saveはSimpleJpaRepositoryの独自のtransactionで実行され、すぐにコミットされます。2番目の注文が失敗した場合、最初の注文はまだDBに残っています。

修正方法は3つあり、優先順位順に列挙されています:

  1. placeOrderを別のbean(例:OrderPlacementService)に分離し、それをinjectします。
  2. placeOrders自体に@Transactionalを配置します(ただし、バッチ全体が1つのtransactionである場合)。
  3. @Lazyを介してself-injectします。これは機能しますが、読みにくいため、beanを分離できない場合にのみ使用してください。
@Service
public class OrderBatchService {

    private final OrderService orderService; // これはプロキシ

    public OrderBatchService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void placeOrders(List<String> customers) {
        customers.forEach(c -> orderService.placeOrder(c, new BigDecimal("100")));
    }
}

この場合、各呼び出しはプロキシを通過し、ログにはTransaction active: trueと出力されます。

5. 落とし穴2:privateメソッドまたはfinalメソッド

Spring Framework 6.0以降では、class-based proxy(CGLIB)を使用する場合、@Transactionalはpublic、protected、パッケージプライベートメソッドで機能します。ただし、2つのケースではまだ機能しません:

  • privateメソッド:サブクラスはそれをオーバーライドできないため、プロキシは呼び出しをインターセプトできません。
  • finalメソッド:CGLIBもオーバーライドできません。呼び出しは元のコードに直接実行され、transactionはありません。
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // transactionなし
    return orderRepository.save(new Order(customer, amount));
}

Springはこの場合エラーをスローしません。INFOまたはDEBUGレベルのログ行が表示されるだけで、見落としやすいです。

注意: Kotlinはデフォルトではすべてのクラスとメソッドを閉じています(final)。Spring beanが自動的に開かれるように、kotlin-springプラグイン(all-open)を使用してください。

6. 落とし穴3:Checked exceptionはロールバックを引き起こさない

デフォルトでは、SpringはRuntimeExceptionまたはErrorに遭遇した場合にのみロールバックします。Checked exceptionはtransactionをコミットさせます。

public class PaymentDeclinedException extends Exception {
    public PaymentDeclinedException(String message) {
        super(message);
    }
}

@Transactional
public Order placeOrderStrict(String customer, BigDecimal amount) throws PaymentDeclinedException {
    Order order = orderRepository.save(new Order(customer, amount));
    if (amount.signum() <= 0) {
        throw new PaymentDeclinedException("金額が無効です");
    }
    return order;
}

amount = -5で呼び出した場合、例外はまだ呼び出し元にスローされていますが、負の注文は既に保存されています。修正方法は明確に宣言することです:

@Transactional(rollbackFor = PaymentDeclinedException.class)

多くのチームは、サービス全体でrollbackFor = Exception.classを使用する慣例があります。より簡単な方法は、ビジネス例外をRuntimeExceptionを継承するように設計することです。逆も可能です:noRollbackFor = SomeRuntimeException.classはエラーが発生してもコミットを保持します。

7. 落とし穴4:例外をキャッチしてUnexpectedRollbackException

これは最も理解しにくい落とし穴です。倉庫の在庫を保留するサービスを考えます:

@Service
public class InventoryService {

    @Transactional // デフォルトのpropagation:REQUIRED
    public void reserve(String sku) {
        throw new IllegalStateException("在庫なし: " + sku);
    }
}

そしてOrderServiceがそれを呼び出し、エラーをキャッチして「在庫保留に失敗しても注文は作成」という意図です:

@Transactional
public Order placeOrderWithInventory(String customer, String sku) {
    Order order = orderRepository.save(new Order(customer, new BigDecimal("200")));
    try {
        inventoryService.reserve(sku);
    } catch (IllegalStateException ex) {
        System.out.println("在庫エラーを無視: " + ex.getMessage());
    }
    return order;
}

結果:

在庫エラーを無視: 在庫なし: SKU-01
org.springframework.transaction.UnexpectedRollbackException:
  Transaction silently rolled back because it has been marked as rollback-only

以下のことが起こります。reserveはplaceOrderWithInventoryと同じ物理的なtransactionに参加します。例外がInventoryServiceのプロキシを通過すると、Springはtransactionをrollback-onlyにマークします。外側でcatchしても、このマークは外されません。コミット時に、Springはマークを検出し、ロールバックして、呼び出し元がコミットされたと考えているが、実際にはそうではないことを報告するためにUnexpectedRollbackExceptionをスローします。

修正方法はあなたの意図によって異なります:

  • 在庫エラーで注文全体をキャンセルする必要がある場合は、catchしないでください。例外を上流に渡してください。
  • reserveが独立した操作である場合は、REQUIRES_NEWを使用して独自のtransactionを与えます(セクション8を参照)。
  • reserveがDBへの書き込みが必要でない場合は、それから@Transactionalを削除します。

注意: 同じメソッド内で例外をキャッチした場合、transactionはマークされません。マークは、例外が@Transactionalプロキシを通過した場合にのみ設定されます。

8. Propagation REQUIRES_NEW:有用だが高い代償

REQUIRES_NEWの合理的なケースは、監査ログの記録です。主要なビジネスロジックがロールバックされても、ログは保存される必要があります:

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String action) {
        auditLogRepository.save(new AuditLog(action));
    }
}

Springは外部のtransactionを一時停止し、別の接続で新しいtransactionを開きます。recordが終了すると、このtransactionは独立してコミットされます。

支払う代償:

  • 各リクエストは同時に2つの接続を保持します。高負荷時には、プール(HikariCPのデフォルトは10接続)が枯渇し、プールレベルでデッドロックが発生する可能性があります:すべてのスレッドが1つの接続を保持し、2番目の接続を待っています。
  • 新しいtransactionは、外部のtransactionから未コミットデータを見ることができません。AuditLogが新しく作成されたOrderに外部キーを持っている場合、エラーが発生したり、ロック待機が発生したりします。

独立したコミットが実際に必要な場合にのみREQUIRES_NEWを使用してください。すべてのロールバックのみエラーの「応急処置」として使用しないでください。

9. readOnlyと長すぎるtransaction

@Transactional(readOnly = true)はHibernateにflush modeMANUALを設定し、dirty checkingをスキップするように指示します。多くのエンティティを返すクエリの場合、これはメモリとCPUの使用量を大幅に削減します:

@Transactional(readOnly = true)
public List<Order> findOrdersOf(String customer) {
    return orderRepository.findAll().stream()
            .filter(o -> o.getCustomerName().equals(customer))
            .toList();
}

覚えておくべき2つのこと:

  • readOnlyは最適化ヒントであり、保護メカニズムではありません。ドライバとデータベースによっては、ネイティブのUPDATEステートメントが実行される可能性があります。
  • transactionの内部でHTTP APIを呼び出したり、メールを送信したり、Thread.sleepを実行しないでください。その間、接続が保持され、書き込まれた行のロックも同様です。まず外部からデータを取得してから、短いtransactionを開いて書き込みを行ってください。

10. ロールバック動作のテスト

もう1つの落とし穴はテストにあります。テストクラスに@Transactionalを配置した場合、すべてがテストのtransactionで実行され、最後にロールバックされます。これにより、サービスが正しくコミットされるかロールバックされるかが隠されます。transactionに関するテストでは、このアノテーションを削除して、自分でデータをクリーンアップしてください:

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @AfterEach
    void cleanUp() {
        orderRepository.deleteAll();
    }

    @Test
    void whenPaymentFails_thenOrderIsRolledBack() {
        assertThatThrownBy(() -> orderService.placeOrder("An", new BigDecimal("5000")))
                .isInstanceOf(IllegalStateException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenInnerServiceFailsAndIsCaught_thenUnexpectedRollback() {
        assertThatThrownBy(() -> orderService.placeOrderWithInventory("Bình", "SKU-01"))
                .isInstanceOf(UnexpectedRollbackException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenCheckedExceptionWithoutRollbackFor_thenOrderIsCommitted() {
        assertThatThrownBy(() -> orderService.placeOrderStrict("Chi", new BigDecimal("-5")))
                .isInstanceOf(PaymentDeclinedException.class);

        assertThat(orderRepository.count()).isEqualTo(1);
    }
}

3番目のテストはchecked exceptionのデフォルト動作を「ロック」します。rollbackForを追加すると、このテストが失敗し、期待値をisZero()に変更します。これはロールバック決定をコード内で直接文書化する良い方法です。

11. まとめ

@Transactionalは単純に見えますが、そのすべての動作はプロキシに依存しています。主な落とし穴の概要:

  • Self-invocation:this経由で呼び出すとプロキシをスキップします。beanを分離してください。
  • private/finalメソッド:インターセプトされず、Springはエラーを報告しません。
  • Checked exception:デフォルトでコミットします。rollbackForを使用するか、RuntimeExceptionを使用してください。
  • Rollback-only:内部のプロキシを通過する例外は、transaction全体をマークし、外側でcatchしても解決できません。
  • REQUIRES_NEW:追加の接続を消費し、独立したコミットが必要な場合にのみ使用してください。
  • @Transactionalを持つテスト:実際のコミット/ロールバック動作を隠します。

疑わしい場合は、TransactionInterceptorログをTRACEレベルで有効にし、TransactionSynchronizationManager.isActualTransactionActive()を確認してください。これらの2つのツールは、transactionが実際に存在するかどうかを正確に知ることができます。

spring-boottransactionjpaspring-datatesting