Transaktionen in Spring: @Transactional und häufige Fallstricke

1. Überblick

@Transactional ist die am häufigsten verwendete Annotation in Spring Boot. Sie ist auch Quelle vieler schwer erkennbarer Bugs: Daten werden nicht zurückgerollt, Transaktionen “verschwinden”, oder UnexpectedRollbackException erscheint ohne klaren Grund.

Dieser Artikel erklärt, wie @Transactional über einen Proxy funktioniert. Dann gehen wir durch die häufigsten Fallstricke, jeder mit Beispielcode und Lösungsansatz. Danach wirst du genau wissen, wann eine Transaktion wirklich geöffnet wird und wann sie ein Rollback macht.

2. Einrichtung

Wir verwenden Spring Data JPA mit H2 In-Memory-Datenbank:

<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>

Aktiviere Log für den Transaction Interceptor, um zu sehen, wann Transaktionen geöffnet und geschlossen werden:

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

Entity und Repository, die in diesem Artikel verwendet werden:

@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> {
}

Die Tabelle heißt orders, da order ein SQL-Schlüsselwort ist.

3. Wie @Transactional funktioniert

3.1. Basis-Beispiel

@Service
public class PaymentService {

    public void charge(String customer, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000")) > 0) {
            throw new IllegalStateException("Zahlungslimit überschritten");
        }
    }
}

@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;
    }
}

Wenn du placeOrder("An", new BigDecimal("5000")) aufrufst, wird die Bestellung erst gespeichert. Dann wirft charge eine IllegalStateException. Da dies eine RuntimeException ist, rollt Spring alles zurück, sodass die Tabelle orders leer bleibt.

3.2. Der Proxy ist zentral

Spring ändert keinen Bytecode der OrderService-Klasse. Stattdessen erstellt es einen Proxy (standardmäßig eine CGLIB-Subclass in Spring Boot), der die Bean umhüllt. Andere Beans, die OrderService injizieren, erhalten tatsächlich diesen Proxy. Jeder Aufruf durchläuft den TransactionInterceptor:

Aufrufer → Proxy → [Transaktion öffnen] → OrderService.placeOrder() → [Commit/Rollback] → Aufrufer

Das TRACE-Log zeigt dies deutlich:

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: Zahlungslimit überschritten

Die meisten der folgenden Fallstricke entstehen aus einem Grundprinzip: Wenn ein Aufruf nicht durch den Proxy läuft, gibt es keine Transaktion.

4. Fallstrick 1: Self-Invocation

@Service
public class OrderService {

    // ... Constructor wie oben

    public void placeOrders(List<String> customers) {
        for (String customer : customers) {
            placeOrder(customer, new BigDecimal("100")); // über this aufgerufen
        }
    }

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

Der Aufruf von orderService.placeOrders(List.of("An", "Bình")) vom Controller gibt aus:

Transaktion aktiv: false
Transaktion aktiv: false

placeOrders hat keine @Transactional-Annotation, also leitet der Proxy den Aufruf direkt an das echte Objekt weiter. Darin ist placeOrder(...) äquivalent zu this.placeOrder(...), aber this ist das Basis-Objekt, nicht der Proxy. Die Annotation wird komplett ignoriert. Jedes save läuft in der eigenen Transaktion des SimpleJpaRepository und committed sofort. Wenn die zweite Bestellung fehlschlägt, ist die erste bereits in der DB.

Es gibt drei Lösungswege, sortiert nach Priorität:

  1. Verschiebe placeOrder in einen separaten Bean, z.B. OrderPlacementService, und injiziere ihn.
  2. Versehe placeOrders selbst mit @Transactional, wenn du die ganze Charge als eine Transaktion möchtest.
  3. Self-Inject über @Lazy. Dies funktioniert, ist aber schwer lesbar und sollte nur als letzter Ausweg verwendet werden.
@Service
public class OrderBatchService {

    private final OrderService orderService; // Dies ist der Proxy

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

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

Jetzt durchläuft jeder Aufruf den Proxy, und das Log gibt Transaktion aktiv: true aus.

5. Fallstrick 2: Method private oder final

Seit Spring Framework 6.0 funktioniert @Transactional bei klassenbasiertem Proxy (CGLIB) auf public-, protected- und Package-Private-Methoden. Es gibt aber immer noch zwei Fälle, in denen es nicht funktioniert:

  • private Methode: Die Subclass kann diese nicht überschreiben, der Proxy kann den Aufruf nicht abfangen.
  • final Methode: CGLIB kann diese auch nicht überschreiben. Der Aufruf läuft direkt zum Original-Code, ohne Transaktion.
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // KEINE Transaktion
    return orderRepository.save(new Order(customer, amount));
}

Spring wirft keinen Fehler in diesem Fall. Du siehst nur eine Log-Zeile auf INFO- oder DEBUG-Level, die leicht übersehen wird.

Hinweis: Kotlin macht alle Klassen und Methoden standardmäßig final. Verwende das Plugin kotlin-spring (all-open), um Spring-Beans automatisch zu öffnen.

6. Fallstrick 3: Checked Exception verursacht kein Rollback

Spring rollt standardmäßig nur zurück, wenn eine RuntimeException oder Error auftritt. Checked Exceptions lassen die Transaktion committen.

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("Ungültiger Betrag");
    }
    return order;
}

Wenn du mit amount = -5 aufrufst, wird die Exception weitergeworfen, aber die negative Bestellung ist bereits gespeichert. Die Lösung ist, es explizit zu deklarieren:

@Transactional(rollbackFor = PaymentDeclinedException.class)

Viele Teams verfolgen die Konvention, rollbackFor = Exception.class auf alle Service-Methoden zu setzen. Ein einfacherer Weg ist, Business-Exceptions von RuntimeException erben zu lassen. Das Gegenteil funktioniert auch: noRollbackFor = SomeRuntimeException.class hält den Commit trotz Fehler.

7. Fallstrick 4: Exception schlucken und UnexpectedRollbackException

Dies ist der verwirrendste Fallstrick. Betrachte einen Service, der Lagerbestand reserviert:

@Service
public class InventoryService {

    @Transactional // Propagation Standard: REQUIRED
    public void reserve(String sku) {
        throw new IllegalStateException("Artikel nicht verfügbar: " + sku);
    }
}

Und OrderService ruft dies auf und fängt den Fehler ab mit der Absicht “Wenn Reservierung fehlschlägt, erstelle trotzdem die Bestellung”:

@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("Lagerfehler ignoriert: " + ex.getMessage());
    }
    return order;
}

Ergebnis:

Lagerfehler ignoriert: Artikel nicht verfügbar: SKU-01
org.springframework.transaction.UnexpectedRollbackException:
  Transaction silently rolled back because it has been marked as rollback-only

Das passiert so: reserve nimmt an der gleichen physikalischen Transaktion teil wie placeOrderWithInventory. Wenn die Exception den Proxy von InventoryService durchläuft, markiert Spring die Transaktion als rollback-only. Dein catch draußen kann diese Markierung nicht entfernen. Beim Commit erkennt Spring die Markierung, rollt zurück und wirft UnexpectedRollbackException, um zu signalisieren, dass der Aufrufer einen Commit dachte, aber kein Rollback bekam.

Die Lösung hängt von deiner Absicht ab:

  • Wenn Lagerfehler die ganze Bestellung heben sollen, fang die Exception nicht. Lass sie nach oben propagieren.
  • Wenn reserve unabhängig ist, gib ihm eine eigene Transaktion mit REQUIRES_NEW (siehe Abschnitt 8).
  • Wenn reserve nicht in die DB schreibt, entferne @Transactional von ihm.

Hinweis: Exception innerhalb der gleichen Methode schlucken markiert die Transaktion nicht. Die Markierung wird nur gesetzt, wenn die Exception durch einen @Transactional-Proxy drinnen durchgeht.

8. Propagation REQUIRES_NEW: nützlich, aber teuer

Ein sinnvoller Anwendungsfall für REQUIRES_NEW ist das Schreiben eines Audit-Logs. Das Log muss gespeichert werden, selbst wenn das Haupt-Business rollback macht:

@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 pausiert die äußere Transaktion und öffnet eine neue auf einer anderen Verbindung. Wenn record fertig ist, committed diese Transaktion unabhängig.

Der Preis:

  • Jeder Request hält gleichzeitig zwei Verbindungen. Bei hoher Last kann der Connection-Pool (HikariCP hat standardmäßig 10 Verbindungen) erschöpft sein und Deadlock auf Pool-Ebene verursachen: alle Threads halten eine Verbindung und warten auf eine zweite.
  • Die neue Transaktion sieht nicht die ungecomitten Daten der äußeren Transaktion. Wenn AuditLog einen Foreign Key zur gerade erstellten Order hat, triffst du auf Lock-Wartezeiten oder Fehler.

Nutze REQUIRES_NEW nur, wenn du wirklich einen unabhängigen Commit brauchst, nicht als “Notfall-Heilmittel” für jeden Rollback-Only-Fehler.

9. readOnly und zu lange Transaktionen

@Transactional(readOnly = true) weist Hibernate an, Flush-Mode auf MANUAL zu setzen und Dirty Checking zu überspringen. Bei Abfragen, die viele Entities zurückgeben, sinken Speicher- und CPU-Verbrauch merklich:

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

Zwei Dinge solltest du wissen:

  • readOnly ist ein Optimierungs-Hinweis, keine Sicherheitsmechanik. Je nach Driver und Datenbank kann ein natives UPDATE trotzdem ausgeführt werden.
  • Rufe keine HTTP-APIs auf, versende E-Mails oder nutze Thread.sleep innerhalb der Transaktion. Die Verbindung wird die ganze Zeit über gehalten, und auch Locks auf geschriebenen Rows. Hol externe Daten vorher, öffne die Transaktion nur kurz zum Schreiben.

10. Verhalten beim Rollback testen

Ein weiterer Fallstrick steckt im Test. Wenn du @Transactional auf die Test-Klasse packst, läuft alles in der Transaktion des Tests und wird am Ende zurückgerollt. Das verbirgt das echte Verhalten des Service beim Commit oder Rollback. Bei Tests zu Transaktionen, lasse die Annotation weg und räume selbst auf:

@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);
    }
}

Der dritte Test “dokumentiert” das Standardverhalten mit Checked Exceptions. Wenn du rollbackFor hinzufügst, wird dieser Test rot, und du änderst die Erwartung auf isZero(). Dies ist eine gute Möglichkeit, die Rollback-Entscheidung direkt im Code zu dokumentieren.

11. Fazit

@Transactional sieht simpel aus, aber sein ganzes Verhalten hängt vom Proxy ab. Zusammenfassung der wichtigsten Fallstricke:

  • Self-Invocation: über this aufgerufen umgeht den Proxy. Teile den Bean auf.
  • private/final Methode: wird nicht abgefangen, Spring gibt keinen Fehler.
  • Checked Exception: committiert standardmäßig. Nutze rollbackFor oder RuntimeException.
  • Rollback-Only: Exception durch Proxy drinnen markiert die ganze Transaktion, catch draußen hilft nicht.
  • REQUIRES_NEW: kostet eine Extra-Verbindung, nutze es nur bei Bedarf für unabhängigen Commit.
  • Test mit @Transactional: verbirgt echtes Commit/Rollback-Verhalten.

Bei Unsicherheit schalte den TransactionInterceptor auf TRACE-Level und prüfe TransactionSynchronizationManager.isActualTransactionActive(). Diese beiden Tools zeigen dir genau, ob eine Transaktion wirklich existiert.

spring-boottransactionjpaspring-datatesting