1. Erfolg von placeOrder(): commit wichtiger als save(...)
Fast jeder Anfänger hat irgendwann das Gefühl: „Ich habe doch save(...) aufgerufen – also ist alles gespeichert.“ Das ist ein sehr menschlicher Gedanke: unser Gehirn liebt es, Häkchen zu setzen. Aber in transaktionalem Code ist er gefährlich. In placeOrder() wird der Erfolg einer Operation nicht durch die erste erfolgreiche Zeile bestimmt, nicht durch den letzten save(...) und nicht einmal dadurch, dass das Objekt bereits eine id hat, sondern dadurch, dass die Transaktion bis zum Ende durchgelaufen und erfolgreich committed wurde.
In Spring mit @Transactional auf einer öffentlichen Methode der Service-Schicht ist das Basismodell folgendes: Zu Beginn der Methode öffnet Spring eine Transaktion, am Ende – wenn die Methode fehlerfrei beendet wurde – führt sie ein commit durch, und wenn ein Fehler ausgelöst wurde (im typischsten Fall ein Runtime-Fehler) – macht sie ein rollback. Die Repositories innerhalb der Methode können zehnmal aufgerufen werden, aber das ist immer noch eine einzige Operation, ein einziger „Kassenbon“, nicht zehn verschiedene Einkäufe.
Stellen Sie sich eine einfache Alltagsanalogie vor: Sie packen einen Koffer für den Urlaub. Socken eingepackt? Das ist noch nicht „der Urlaub hat stattgefunden“. Ladegerät eingepackt? Auch nicht. Der Urlaub „hat stattgefunden“ erst in dem Moment, in dem Sie die Wohnung verlassen und die Tür abgeschlossen haben. Eine Transaktion funktioniert ähnlich: Die Zwischenschritte sind wichtig, aber das Ergebnis „Operation erfolgreich“ tritt im Moment des Abschlusses der Transaktion ein.
Mini-Illustration (achten Sie auf den Kommentar – er zerstört absichtlich die Intuition):
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
@Transactional
public Long placeOrder(/* ... */) {
// WICHTIG: Die Transaktion startet beim Eintritt in die Methode,
// aber commit/rollback erfolgt erst beim Verlassen der Methode.
// Alles, was darin enthalten ist, ist ein „Entwurf“ der Operation.
// ... Bestellung und Positionen erstellen
// Auch wenn orderRepository.save(...) aufgerufen wurde,
// bedeutet das noch nicht, dass das Ergebnis "für immer" in der Datenbank ist.
// „Für immer“ tritt erst nach dem commit am Ende der Methode ein.
return 42L;
}
}
Der Rückgabewert, z. B. orderId, ist ein bequemes Ergebnis für den aufrufenden Code, aber er ist noch kein „Denkmal aus Granit“. Das Denkmal wird bei commit errichtet.
2. Teilweiser Erfolg und „Halb-Bestellungen“
Wenn Transaktionen „Alles oder nichts“ sind, dann ist ein teilweiser Erfolg ein Zustand, in dem „etwas geschafft wurde, etwas nicht“. Auf der Geschäftsebene ist das fast immer schlechter als ein ehrlicher Fehler, weil ein solches „Teilergebnis“ in der Datenbank weiterlebt, als wäre es echt, und dann alles um sich herum vergiftet. Und das Unangenehmste: Es bringt die Anwendung nicht sofort zum Absturz. Manchmal liegt es still in den Daten und taucht erst nach einer Woche in Form eines seltsamen Berichts oder eines Anrufs eines Kollegen auf: „Warum haben wir minus drei Artikel auf Lager, aber keine Bestellung?“
In unserem mini-shop sieht ein teilweiser Erfolg normalerweise so aus: Der Bestand wurde bereits reduziert, aber die Bestellung wurde nicht gespeichert; die Bestellung wurde gespeichert, aber die Positionen nicht; die Bestellung und die Positionen wurden gespeichert, aber der Endbetrag stimmt nicht mit den Zeilen überein; die Bestellung wurde gespeichert, aber der Bestand wurde nicht reduziert, und wir haben „Luft verkauft“. Das sind vier verschiedene Formen von „Halbsiegen“, und jede davon ist praktisch ein Datenfehler.
Um dies nicht als Horrorgeschichte, sondern als technisches Modell zu betrachten, ist es praktisch aufzuschreiben, welche Daten gemeinsam geändert werden müssen. Nachfolgend eine kompakte Tabelle. Sie beschreibt nicht, wie man Code schreibt, sondern was ein „korrektes Operationsergebnis“ bedeutet.
| Entität/Tabelle | Was ändern wir in placeOrder() | Warum atomar |
|---|---|---|
| customer_order | Bestellung erstellen, Status setzen, totalAmount | Bestellung ist Wurzel der Operation |
| order_item | Bestellpositionen erstellen | Bestellung ohne Positionen ist „leer“ |
| stock_item | availableQuantity verringern | sonst verkaufen wir mehr als vorhanden |
| (Logik) | Bestände vor Abbuchung prüfen | sonst negative Bestände oder Fehler |
Eine Transaktion existiert genau dafür, dass diese Änderungen entweder gemeinsam stattfinden oder gar keine.
Es ist wichtig, einen subtilen Punkt zu verstehen: Ein einzelner save(...) ist nur ein „Schritt im Skript“, keine „Garantie für das Ergebnis“. Innerhalb von placeOrder() können Sie zwei, drei, fünf Speicherungen durchführen – und befinden sich immer noch in einem Zustand, in dem die Operation noch nicht abgeschlossen ist. Genau deshalb bezieht sich das Wort „Erfolg“ im transaktionalen Denken auf die gesamte Operation, nicht auf ihre Teile.
3. Rollback in der Praxis: placeOrder() kaputt machen
Der beste Weg, um aufzuhören, an Magie zu glauben, ist, sie einmal zu zerlegen und zu sehen, was unter der Haube steckt. Deshalb erstellen wir jetzt absichtlich ein „schlechtes“ Szenario: Wir führen mehrere Aktionen aus und werfen dann eine Exception. Und wir betrachten die Idee: Was passiert mit der Datenbank – soll sie in einem Zwischenzustand bleiben oder zum Ausgangszustand zurückgesetzt werden?
Wir simulieren eine Situation, in der wir es geschafft haben, die Bestellung zu speichern und den Bestand zu ändern, aber dann stellt sich plötzlich etwas Kritisches heraus. Der Grund ist unwichtig: Es kann ein „Datenfehler“, eine „unerwartete Bedingung“ oder ein „verletztes Invariant“ sein. Wichtig ist nur eines: Der Fehler ist mittendrin aufgetreten.
import java.math.BigDecimal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final CustomerOrderRepository orderRepository;
private final StockItemRepository stockItemRepository;
public OrderService(CustomerOrderRepository orderRepository,
StockItemRepository stockItemRepository) {
this.orderRepository = orderRepository;
this.stockItemRepository = stockItemRepository;
}
@Transactional
public Long placeOrderAndFail(CustomerOrder order, StockItem stockItem) {
// Daten vorbereitet: Diese Änderungen leben vorerst innerhalb der Transaktion
// und sind noch nicht die „endgültige Wahrheit“ der Datenbank.
order.setTotalAmount(BigDecimal.TEN);
// Aufruf von save(...) ist nicht gleich commit:
// wir registrieren lediglich die Änderungen, die später festgeschrieben werden.
orderRepository.save(order);
// Eine weitere Änderung in derselben Transaktion.
stockItemRepository.save(stockItem);
// Hier wird das Szenario absichtlich „kaputt gemacht“.
// Eine Runtime-Exception führt zum Rollback der gesamten Transaktion.
throw new IllegalStateException("Fehler in der Mitte von placeOrder()");
}
}
Hier wurde absichtlich das gemacht, was normalerweise verwirrt: Wir haben es geschafft, save(...) aufzurufen, aber dann stürzen wir trotzdem ab. Wenn es keine Transaktion gibt, erhalten Sie mit hoher Wahrscheinlichkeit genau dieses „Teilergebnis“: Ein Teil der Daten wurde gespeichert, ein Teil nicht. Wenn es jedoch eine Transaktion gibt und diese richtig konfiguriert ist, erfolgt ein rollback, und die Datenbank kehrt in den Zustand „wie vor dem Eintritt in die Methode“ zurück.
Damit im Kopf nicht das Gefühl „das ist ja nur Theorie“ bleibt, ist es hilfreich, sich vorzustellen, dass man in den SQL-Logs beim Versuch der Operation logische Schritte sehen könnte wie:
-- So sieht der Aktionsfluss etwa aus (vereinfacht)
-- WICHTIG: Dass SQL-Befehle in den Logs „durchgelaufen“ sind, bedeutet noch nicht,
-- dass sie zum endgültigen Datenzustand geworden sind: Das Endergebnis tritt erst nach COMMIT ein.
insert into customer_order (...) values (...);
update stock_item set available_quantity = ... where id = ...;
-- ... und hier trat ein Fehler auf -> rollback
Wichtig: SQL-Logs können anzeigen, dass einige Befehle bereits „durchgelaufen“ sind. Das widerspricht nicht dem rollback. Rollback bedeutet, dass diese Änderungen nicht zur endgültigen Version der Daten werden. Auf menschliche Sprache übertragen: Sie konnten den Text in einem Dokument schreiben, aber wenn Sie es „ohne zu speichern“ schließen, wird es keine endgültige Version des Dokuments geben.
Eine weitere Falle für Anfänger: Manchmal hat eine Entität bereits eine id erhalten, und das wird als „na gut, dann ist die Zeile auf jeden Fall in der DB“ wahrgenommen. In Wirklichkeit ist die id nur eine Kennung, die das System während der Operation vergeben konnte. Wenn die Transaktion zurückgerollt wird, kann eine Zeile mit dieser id möglicherweise nie existieren.
Eine kleine Demonstration genau dieser Verwirrung:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public Long createOrderThenFail(CustomerOrder order) {
// Entity speichern: JPA kann bereits vor dem commit eine ID zuweisen
// (z.B. beim flush oder bei der ID-Generierungsstrategie).
CustomerOrder saved = orderRepository.save(order);
// Diese Ausgabe beweist NICHT, dass der Datensatz „für immer“ in der DB ist.
System.out.println("order id = " + saved.getId()); // Bestell-ID = 100
// Nach der Exception wird die Transaktion zurückgerollt, und der Eintrag in der Tabelle könnte fehlen.
throw new IllegalStateException("alles abbrechen");
}
Die Konsolenausgabe kann vorhanden sein, die id kann vorhanden sein, aber die Zeile in der Tabelle nicht, weil das „Denkmal aus Granit“ bei commit errichtet wird, nicht bei println.
Und genau deshalb ist der transaktionale Ansatz für placeOrder() so wichtig: Er garantiert, dass nach einem Fehler keine „halbe Bestellung“, kein „negativer Bestand ohne Bestellung“ und andere lustige Artefakte zurückbleiben, die später von Hand, unter Tränen und mit SQL-Skripten namens fix_prod_final_final_v3.sql repariert werden müssen.
4. cancelOrder() vs rollback: unterschiedliche Operationen
An dieser Stelle kommt es oft zu einer logischen Vertauschung, besonders bei denen, die zum ersten Mal ernsthaft mit Transaktionen zu tun haben. Es scheint, dass „Stornierung einer Bestellung“ und rollback dasselbe sind, nur mit anderen Worten. Tatsächlich sind es zwei völlig verschiedene Ideen. Rollback ist ein technischer Rückbau der aktuellen, noch nicht abgeschlossenen Transaktion aufgrund eines Fehlers. cancel ist eine bewusste geschäftliche Aktion, die auf eine bereits existierende Bestellung angewendet wird, die zuvor erfolgreich aufgegeben wurde.
Wenn Sie eine Bestellung aufgeben und innerhalb von placeOrder() etwas schiefgeht – wollen wir ein rollback, damit es so aussieht, als hätte die Operation nicht stattgefunden. Das ist vergleichbar mit der Situation: „Ich habe angefangen, einen Kauf zu tätigen, aber auf dem Bezahlbildschirm ist alles abgestürzt – wir betrachten den Kauf als nicht getätigt.“
cancelOrder() hingegen ist die Situation: „Der Kauf wurde getätigt, der Kassenbon wurde gedruckt, und dann hat der Kunde es sich anders überlegt.“ Hier kann man nicht mehr so tun, als wäre nichts gewesen. Es gibt einen Datensatz der Bestellung, möglicherweise ist sie bereits in Berichte eingeflossen, möglicherweise haben Sie sie dem Benutzer angezeigt. Daher ist eine Stornierung kein Verschwindenlassen der Tatsache, sondern eine Änderung des Zustands: Die Bestellung erhält den Status CANCELLED, und das Lager gibt die Menge zurück.
Es ist sehr praktisch, dies als zwei verschiedene Zeitlinien zu sehen:
sequenceDiagram
participant U as Benutzer
participant S as OrderService
participant DB as Datenbank
U->>S: "placeOrder()"
S->>DB: "Einfügen customer_order + order_item + Bestand aktualisieren"
alt alles OK
S->>DB: COMMIT
S-->>U: orderId
else Fehler
S->>DB: ROLLBACK
S-->>U: "Fehler (Bestellung nicht erstellt)"
end
U->>S: "cancelOrder(orderId)"
S->>DB: Bestellstatus aktualisieren + Bestand wiederherstellen
S->>DB: COMMIT
Das Diagramm zeigt das Wesentliche: cancelOrder() ist überhaupt nicht an der Geschichte „Fehler innerhalb von placeOrder“ beteiligt. Es beginnt später, wenn die Bestellung bereits existiert, und hat seine eigene Transaktion, eigene Prüfungen, eigene Regeln.
Ein wichtiger praktischer Gedanke für unseren mini-shop: cancelOrder() sollte sich nicht auf externe „Eingabedaten“ stützen, sondern auf das, was tatsächlich gespeichert wurde in OrderItem. Denn eine Stornierung ist die Umkehroperation zur Bestellungserstellung, und sie sollte in Bezug auf die Daten, die wir einst festgehalten haben, „symmetrisch“ sein.
5. Skizze von cancelOrder(): was wir ändern
Um diese beiden Operationen nicht zu verwechseln, genügt ein kurzes Bild: cancelOrder() ist nicht der Versuch, placeOrder() rückwirkend „zurückzurollen“, sondern eine neue geschäftliche Operation auf einer Bestellung, die zuvor erfolgreich erstellt wurde. Sie arbeitet nicht mit der Eingabeliste von Artikeln, sondern mit dem, was tatsächlich in CustomerOrder und OrderItem gespeichert ist.
Datenmäßig bedeutet das eine ziemlich direkte Sache:
— bereits bestehende Bestellung laden;
— sicherstellen, dass sie überhaupt storniert werden kann;
— die Mengen aus den gespeicherten OrderItem an den Bestand zurückgeben;
— die Bestellung auf CANCELLED setzen.
Zur Unterscheidung von rollback ist für uns nicht die Feinschliff des Codes wichtig, sondern die Zusammensetzung der Änderungen. Es ist eine separate Service-Transaktion mit eigenem commit oder rollback, und nicht eine Möglichkeit, die vorherige Bestellungstransaktion rückgängig zu machen.
| Parameter | rollback | cancelOrder() |
|---|---|---|
| Wann passiert es | bei Fehler innerhalb der aktuellen Operation | nach erfolgreicher Erstellung, aufgrund geschäftlicher Entscheidung |
| Was „sieht“ die Datenbank | Ergebnis der Operation verschwindet vollständig | Ergebnis bleibt bestehen, aber Status/Bestände ändern sich |
| Ist es eine neue Transaktion? | nein, es ist ein Rollback der aktuellen | ja, es ist eine separate Operation |
| Was ändern wir | nichts soll übrig bleiben | Bestellstatus + Bestände (und manchmal andere Felder) |
| „Sinn“ | „Hat nicht geklappt – vergessen“ | „Hat geklappt, aber storniert“ |
6. Typische Fehler bei der Arbeit mit Transaktionen
Fehler Nr. 1: zu glauben, dass der Erfolg der Operation beim ersten save(...) eintritt.
Solcher Code sieht normalerweise „logisch“ aus: Wir haben die Bestellung gespeichert – dann können wir ja nach außen melden „alles fertig“. Aber wenn innerhalb der Transaktion danach ein Fehler auftritt, wird ein rollback ausgeführt, und die Bestellung ist nicht in der Datenbank. Daher sollten alle Aktionen, die die Tatsache nach außen bestätigen, nicht auf einem frühen save(...) basieren, sondern darauf, dass die Methode tatsächlich erfolgreich beendet wurde.
Fehler Nr. 2: nicht zu bemerken, dass ein Teilerfolg eine Beschädigung der Daten ist, nicht „na ja, fast geschafft“.
Ein Teilerfolg ist gefährlich, weil er neue Zustände schafft, mit denen das System überhaupt nicht umgehen kann. „Bestand reduziert, Bestellung nicht vorhanden“ – das ist nicht „fast eine Bestellung“, das ist ein Vorfall. Wenn Sie sich dabei ertappt haben, zu denken „ach, das korrigieren wir später“, wissen Sie: So entstehen Legenden von Nacht-Releases und stillen Flüchen in Richtung Datenbank.
Fehler Nr. 3: rollback mit cancelOrder() verwechseln und versuchen, eine „erstellte Bestellung“ durch ein Transaktions-Rollback zu stornieren.
Rollback kann nur das zurückrollen, was innerhalb der aktuellen Transaktion passiert ist und noch nicht final ist. Wenn die Bestellung bereits erstellt wurde, ist es unmöglich, die vorherige Transaktion rückwirkend zurückzurollen. Eine Stornierung ist eine neue Operation: Sie muss die Bestellung laden, den Status ändern und die Bestände zurückgeben. Das ist eine andere Logik und eine andere Verantwortung des Service.
Fehler Nr. 4: cancelOrder() nur durch Statusänderung durchführen.
Eine Statusänderung ohne Rückgabe der Bestände sieht so aus, als hätten wir „die Bestellung auf dem Papier storniert, aber die Ware ist trotzdem aus dem Lager verschwunden“. In unserer Domäne ist das eine direkte Verletzung der Invariante: Eine Stornierung muss ein Spiegelbild der Bestellungserstellung sein. Wenn wir in placeOrder() die Bestände reduziert haben, sind wir in cancelOrder() verpflichtet, sie zurückzugeben.
Fehler Nr. 5: Bestände auf der Grundlage von „Eingabedaten“ zurückgeben, anstatt auf der Grundlage der gespeicherten OrderItem.
Manchmal möchte man cancelOrder(email, address, items) machen und dieselben Daten übergeben, die bei der Bestellung verwendet wurden. Das sieht bequem aus, aber es ist eine neue Möglichkeit für Abweichungen: „Sind das wirklich dieselben Positionen?“, „Hat jemand die Eingabedaten manipuliert?“. Eine Stornierung sollte sich auf das stützen, was tatsächlich in der Bestellung gespeichert ist: auf die OrderItem und ihre quantity. Dann wird die Stornierung vorhersagbar und überprüfbar.
GO TO FULL VERSION