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つあり、優先順位順に列挙されています:
placeOrderを別のbean(例:OrderPlacementService)に分離し、それをinjectします。placeOrders自体に@Transactionalを配置します(ただし、バッチ全体が1つのtransactionである場合)。@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が実際に存在するかどうかを正確に知ることができます。