1. Tabellen des Bestellszenarios
Bevor man auch nur ein INSERT schreibt, ist es nützlich, innezuhalten und das Bild von oben zu betrachten: Welche Tabellen beteiligt sind und wie sie zusammenhängen. Das ist wie Kochen in der Küche: Wenn man nicht weiß, wo das Salz liegt, fängt man irgendwann an, mit Zucker zu salzen – optisch weiß, aber das Ergebnis… bleibt lange in Erinnerung.
Im Bestellungsaufgabe-Szenario des mini-shop werden wir vier Tabellen berühren: product (um den Preis zu holen), stock_item (um den Bestand zu prüfen und abzubuchen), customer_order (Bestellungswurzel) und order_item (Bestellpositionen). Wichtig zu merken: Eine Bestellung ist nicht eine Zeile, sondern ein Verbund von Zeilen. Genau deshalb kehren wir ständig zum Thema Fremdschlüssel und Transaktionen zurück.
Unten eine vereinfachte Beziehungsübersicht (ohne Kategorien, um nicht abzulenken):
erDiagram
PRODUCT ||--|| STOCK_ITEM : "1 to 1"
CUSTOMER_ORDER ||--o{ ORDER_ITEM : "1 to many"
PRODUCT ||--o{ ORDER_ITEM : "1 to many"
PRODUCT {
bigint id PK
numeric price
}
STOCK_ITEM {
bigint id PK
bigint product_id FK
int available_quantity
}
CUSTOMER_ORDER {
bigint id PK
varchar order_number UK
varchar status
varchar customer_email
numeric total_amount
}
ORDER_ITEM {
bigint id PK
bigint order_id FK
bigint product_id FK
int quantity
numeric unit_price
numeric line_total
}
Wenn du einen kurzen „Spickzettel" möchtest, hier ist der Zweck jeder Tabelle in diesem Szenario:
| Tabelle | Was wir davon brauchen | Warum die Bestellung ohne das nicht aufgegeben werden kann |
|---|---|---|
| product | price | Der Preis muss in der Bestellposition (unit_price) festgeschrieben werden, sonst wird die Summe zur Lotterie „was steht gerade im Katalog?" |
| stock_item | available_quantity + Update | Wir müssen prüfen, dass die Ware vorhanden ist, und den Bestand im Rahmen derselben Operation abbuchen |
| customer_order | Bestellungs-Wurzelzeile | Die Bestellpositionen müssen auf etwas verweisen (order_id) |
| order_item | Positionszeilen | Genau sie sagen, dass die Bestellung aus bestimmten Produkten und Mengen besteht |
2. Skelett der Operation placeOrder
Jetzt machen wir das, was am Anfang des Lernens selten gemacht wird, in der echten Entwicklung aber fast immer: Zuerst das Skelett der Operation zeichnen und erst dann SQL-Muskeln daran befestigen. Dieser Ansatz rettet vor Chaos, weil man vorab die Grenze der Aktion versteht: Wo die Operation beginnt und wo sie zwingend enden muss – entweder mit commit oder mit rollback.
Die Bestellungsaufgabe kann man als kleinen Algorithmus mit Prüfungen darstellen. Zuerst lesen wir Daten, dann erstellen wir die Bestellungswurzel, danach die Positionen, und bei jedem Produkt versuchen wir, den Bestand abzubuchen. Wenn mindestens eine Abbuchung nicht funktioniert, rollen wir alles zurück. Die Bestellsumme entweder berechnen wir vorab oder finalisieren wir am Ende, wenn wir sicher sind, dass alles erfolgreich lief.
Ein einfaches Flussdiagramm:
flowchart TD
A[Transaktion beginnen] --> B[Preis und Bestand lesen]
B --> C[INSERT customer_order]
C --> D[Für jede Position: INSERT order_item]
D --> E[UPDATE stock_item mit Prüfung available_quantity]
E -->|0 Zeilen aktualisiert| X[Fehler: Ware nicht ausreichend] --> R[ROLLBACK]
E -->|1 Zeile aktualisiert| F[total_amount summieren]
F --> G[UPDATE customer_order.total_amount]
G --> K[COMMIT]
Und jetzt derselbe Sinn in einem Java-Skelett. Details von PreparedStatement sind vorerst unwichtig; wir brauchen die Transaktionsgrenze und die Schrittfolge:
import java.math.BigDecimal;
import java.sql.Connection;
// Wichtig: Autocommit deaktivieren, um die Operationsgrenze manuell zu steuern
conn.setAutoCommit(false);
try {
// 1) "Wurzel" der Bestellung erstellen und ihre ID holen
long orderId = insertOrder(conn);
// 2) Positionen erstellen und dabei Bestände abbuchen versuchen
BigDecimal total = insertItemsAndUpdateStock(conn, orderId);
// 3) Bestellsumme finalisieren, erst wenn alle Schritte erfolgreich waren
updateOrderTotal(conn, orderId, total);
// 4) Ein commit ganz am Ende: entweder alles oder nichts
conn.commit();
} catch (Exception ex) {
// Obligatorischer Rollback bei jedem Fehler, sonst bleiben "Halbdaten" zurück
conn.rollback();
throw ex;
}
Beachte die Disziplin: commit strikt am Ende, und rollback ist ein obligatorischer Teil des Fehlerzweigs. Das ist keine Option, sondern die halbe Bedeutung der Transaktion.
3. Welche Daten wir zu Beginn des Szenarios lesen
Zu Beginn des Szenarios brauchen wir zwei Dinge: den Produktpreis und den verfügbaren Bestand. Bei der tatsächlichen Ausführung läuft dieses SELECT bereits innerhalb derselben Bestellungstransaktion. Es ist für sich selbst wichtig: Ohne es ist unklar, welchen Preis wir danach in order_item festschreiben und was genau wir in stock_item prüfen.
Psychologisch möchte man sofort „irgendwas einfügen" – die Hände zucken zum INSERT. Aber im Bestellszenario ist es besser, zuerst die Ausgangsdaten zu sammeln: Produktpreis und verfügbarer Bestand. Sonst riskiert man, eine Bestellposition mit unklarem Preis zu erfassen oder zu versuchen, das abzubuchen, was nicht vorhanden ist. Ja, letztendlich muss die Datenbank sich trotzdem schützen, aber man will, dass das Szenario bewusst abläuft und nicht im Modus „die Datenbank hat gemeckert – ich habe es nachträglich gefixt".
Für ein Produkt müssen wir den Preis aus product und den Bestand aus stock_item holen. Da stock_item über product_id mit product verknüpft ist, machen wir einen normalen JOIN:
String readProductAndStock = """
select p.id, p.price, s.available_quantity
from product p
join stock_item s on s.product_id = p.id
where p.id = ?
""";
Wenn man ein minimales JDBC-Fragment ohne unnötige Details zeigt, sieht das ungefähr so aus:
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
try (PreparedStatement ps = conn.prepareStatement(readProductAndStock)) {
// Produktkennung als Parameter in den Query übergeben
ps.setLong(1, productId);
// Query ausführen und Ergebnis holen
ResultSet rs = ps.executeQuery();
// Der Einfachheit halber nehmen wir an, das Produkt existiert.
// Im echten Code muss hier die Situation behandelt werden, wenn rs.next() == false ist.
rs.next();
}
Hier gibt es zwei wichtige Feinheiten. Erstens: Den Preis liest man nicht, weil man dem Katalog nicht vertraut, sondern weil in der Bestellung der Preis festgeschrieben sein muss. Deshalb speichern wir in order_item das Feld unit_price und verlassen uns nicht darauf, dass der Preis immer aus product geholt wird.
Zweitens: Selbst wenn man available_quantity gelesen hat, ist das noch keine Erfolgsgarantie. Die echte Prüfung findet im Moment von UPDATE stock_item ... where available_quantity >= ... statt, weil dieser atomisch die Frage „reicht es oder nicht" entscheidet. Daher ist das Lesen Vorbereitung und Kalkulation, die endgültige Prüfung liegt im Datenänderungsbefehl.
4. Wo die Transaktionsgrenze verläuft
Nach dem Lesen der Ausgangsdaten ist es wichtig, eine Sache festzuhalten, die man nicht „später ohne Folgen hinzufügen" kann: Alle weiteren Schritte leben innerhalb einer Transaktion der Bestellungsaufgabe. Deshalb steht setAutoCommit(false) vor der Erstellung von customer_order, dem Einfügen von order_item und der Bestandsabbuchung.
Warum ist es wichtig, das vor all den schönen Details zu erklären? Weil eine Transaktion ein Container des Sinns ist. Innerhalb von ihr sind jegliche INSERT- und UPDATE-Aktionen Schritte einer Operation, und außerhalb sind das einfach separate Änderungen. Man will, dass die Datenbank weiß: „Das ist alles eine Aktion."
Und noch ein praktischer Aspekt: Eine Transaktion ist nicht über Codekomplexität, sondern über Datenintegrität. Selbst wenn man 20 Java-Zeilen geschrieben hat, aber dort drei SQL-Befehle sind, die entweder zusammen durchgehen oder überhaupt nicht, ist das bereits ein Transaktionskandidat.
5. customer_order erstellen
Jetzt können wir die Bestellung in customer_order einfügen. Das muss vor den Positionen geschehen, weil order_item.order_id auf eine bereits existierende Bestellung verweisen muss. Das ist wie zuerst einen Ordner anlegen und dann Dokumente hineinlegen: Ohne Ordner kann man nicht sagen, zu welchem Zweck jedes Blatt gehört.
Der Einfachheit halber – um nicht in die Generierung von Identifikatoren zu geraten – nehmen wir an, dass id und order_number wir bereits auf Anwendungsebene erhalten haben. Das Einfügen kann so aussehen:
String insertOrder = """
insert into customer_order (id, order_number, status, customer_email, total_amount)
values (?, ?, 'NEW', ?, 0)
""";
Warum ist total_amount vorerst 0? Weil wir noch nicht sicher sind, dass alle Positionen erfolgreich erstellt werden und der Bestand abgebucht wird. Wir können die Summe im Code beim Einfügen der Positionen akkumulieren und die Bestellung am Ende aktualisieren. Das sind etwas mehr SQL-Schritte, aber die Logik wird ordentlich: Die Bestellsumme ist das Ergebnis der Positionen, keine „vorab erdachte und bei Fehler vergessene Zahl".
Mini-Einfügen über executeUpdate() sieht so aus:
import java.sql.PreparedStatement;
try (PreparedStatement ps = conn.prepareStatement(insertOrder)) {
// 1) Bestellungs-ID (im Rahmen des Beispiels selbst festgelegt)
ps.setLong(1, orderId);
// 2) Bestellnummer (muss eindeutig sein)
ps.setString(2, orderNumber);
// 3) Kunden-E-Mail
ps.setString(3, customerEmail);
// INSERT soll genau 1 Zeile betreffen – das ist unser „Erfolgssignal"
ps.executeUpdate();
}
Hier prüfen wir das Ergebnis nicht, behalten aber im Kopf eine wichtige Regel: Auch INSERT kann eine Zeile nicht einfügen – zum Beispiel wegen Verletzung von UNIQUE auf order_number. Im echten Code würde man entweder eine Ausnahme fangen oder die Anzahl betroffener Zeilen prüfen. Im Rahmen der Transaktion ist das perfekt: Wenn das Einfügen der Bestellung nicht klappte, landen wir sofort im rollback-Zweig.
6. order_item und Bestellsumme
Wenn die Bestellungs-Wurzelzeile bereits vorhanden ist, können Positionen hinzugefügt werden. Genau in order_item schreiben wir den „Kauf-Schnappschuss" fest: Welches Produkt, wie viele Stücke und zu welchem Preis. Das ist wichtig, weil der Katalog sein eigenes Leben führen kann – Preise und Namen ändern sich –, aber eine Bestellung muss historisch korrekt bleiben.
Das SQL für das Einfügen einer Position sieht recht geradlinig aus:
String insertOrderItem = """
insert into order_item (id, order_id, product_id, quantity, unit_price, line_total)
values (?, ?, ?, ?, ?, ?)
""";
Arithmetisch wird die Bestellsumme normalerweise aus line_total aufgebaut, und line_total aus quantity * unit_price. In Java wird fast immer BigDecimal verwendet, weil Geld Präzision liebt und double Überraschungen liebt (ja, das sind genau jene Überraschungen, wenn 0,1 + 0,2 plötzlich nicht 0,3 ist). Mini-Berechnungsfragment:
import java.math.BigDecimal;
// Produktpreis, den wir aus product gelesen haben (oder aus dem DTO erhalten haben)
BigDecimal unitPrice = productPrice;
// Menge, die der Kunde kaufen möchte
int quantity = 2;
// Positionsbetrag: Menge * Stückpreis
BigDecimal lineTotal = unitPrice.multiply(BigDecimal.valueOf(quantity));
// Gesamtbestellsumme über alle Positionen aufakkumulieren
totalAmount = totalAmount.add(lineTotal);
Und das Einfügen einer Position – stark vereinfacht – kann so aussehen:
import java.sql.PreparedStatement;
try (PreparedStatement ps = conn.prepareStatement(insertOrderItem)) {
// Positions-ID (im Beispiel „kommt von oben")
ps.setLong(1, orderItemId);
// FK auf Bestellung
ps.setLong(2, orderId);
// FK auf Produkt
ps.setLong(3, productId);
// Wie viele Stücke gekauft
ps.setInt(4, quantity);
// Preis zum Kaufzeitpunkt festschreiben (historische Korrektheit)
ps.setBigDecimal(5, unitPrice);
// Positionsbetrag festschreiben, damit er geprüft und summiert werden kann
ps.setBigDecimal(6, lineTotal);
ps.executeUpdate(); // wir erwarten 1 Zeile
}
Beachte: Wir legen unit_price in order_item, auch wenn der Preis bereits in product vorhanden ist. Das ist keine Doppelung um der Doppelung willen, sondern das Festschreiben einer historischen Tatsache: „Der Käufer hat zu diesem Preis gekauft." Ohne das ändert man morgen den Preis im Katalog – und plötzlich werden alte Bestellungen „neu bewertet", als hätte man das vorjährige Telefon zum heutigen Preis verkauft.
7. Bestandsabbuchung über UPDATE
Und jetzt sind wir beim Schritt angelangt, bei dem die naive Bestellungsimplementierung am häufigsten scheitert: die Bestandsabbuchung. Die Hauptidee hier ist nicht einfach available_quantity = available_quantity - 2 zu machen, sondern es so zu machen, dass die Datenbank selbst garantiert: Wir gehen nicht ins Minus. Sonst bekommt man sehr leicht den Zustand „2 verkauft, obwohl 1 vorhanden war", und erklärt dem Kunden dann heroisch, warum die Lieferung storniert wird.
Der praktischste Kunstgriff: Den Bestand mit einem bedingten UPDATE abbuchen:
String updateStock = """
update stock_item
set available_quantity = available_quantity - ?
where product_id = ?
and available_quantity >= ?
""";
Der Sinn hier ist, dass die Datenbank entweder eine Zeile ändert – das bedeutet, es war genug Ware vorhanden – oder null Zeilen ändert – das bedeutet, es war nicht genug Ware vorhanden. Das ist ein unglaublich wichtiges Signal, das man nicht ignorieren darf.
Die Mini-Prüfung sieht so aus:
import java.sql.PreparedStatement;
try (PreparedStatement ps = conn.prepareStatement(updateStock)) {
// 1) Um wie viel wird der Bestand reduziert
ps.setInt(1, quantity);
// 2) Welches Produkt wird abgebucht
ps.setLong(2, productId);
// 3) Prüfung in WHERE: der Bestand muss mindestens so groß sein wie die benötigte Menge
ps.setInt(3, quantity);
int updatedRows = ps.executeUpdate();
// Wenn 0 Zeilen aktualisiert wurden – die WHERE-Bedingung nicht erfüllt: Ware nicht ausreichend
if (updatedRows == 0) throw new IllegalStateException("Nicht genug Bestand");
}
Hier steckt ein kleiner „erwachsener" Gedanke, den man mitnehmen sollte: Null aktualisierte Zeilen ist ein normales SQL-Ergebnis. Das ist kein „Treiberfehler", keine „eingefrorene Datenbank" und keine „Magie". Das bedeutet einfach, dass die WHERE-Bedingung mit keiner Zeile übereingestimmt hat. In unserem Szenario wird daraus ein klarer geschäftlicher Sinn: Die Ware reicht nicht, die Bestellung kann nicht aufgegeben werden.
Und genau hier beginnt die Transaktion ihre Hauptrolle zu spielen. Wir könnten bereits customer_order und mehrere order_item-Einträge eingefügt haben. Aber wenn ein UPDATE stock_item nicht klappte, werfen wir eine Ausnahme, fangen sie außen ab und rufen rollback auf. Die Datenbank macht dabei alles rückgängig: sowohl die Bestellung als auch die Positionen als auch eventuelle erfolgreiche Bestandsabbuchungen für vorherige Produkte.
8. Summe schreiben und commit
Wenn alle Positionen eingefügt und die Bestände erfolgreich abgebucht wurden, hat sich im Code totalAmount akkumuliert. Jetzt kann man die Bestellung in den abgeschlossenen Zustand bringen: Die Gesamtsumme in customer_order schreiben und die Transaktion bestätigen. In diesem Moment wissen wir bereits, dass die Operation nicht mittendrin unterbrochen wurde, und die Summe tatsächlich den Positionen entspricht.
Finalisierendes UPDATE:
String updateOrderTotal = """
update customer_order
set total_amount = ?
where id = ?
""";
Und ein minimales Ausführungsfragment:
import java.sql.PreparedStatement;
try (PreparedStatement ps = conn.prepareStatement(updateOrderTotal)) {
// Gesamtsumme der Bestellung (nach Positionen berechnet)
ps.setBigDecimal(1, totalAmount);
// Konkrete Bestellung aktualisieren
ps.setLong(2, orderId);
ps.executeUpdate(); // wir erwarten 1 Zeile
}
Danach machen wir commit. Und genau in diesem Moment wird die Operation als „abgeschlossen" sichtbar: Die Bestellung existiert, die Positionen existieren, der Bestand ist abgebucht, die Summe stimmt. Bis zum commit war das alles wie ein Entwurf, der durch rollback weggeworfen werden kann. Nach dem commit ist das bereits Teil der Datengeschichte.
Wenn man mit den Augen prüfen möchte, dass alles wirklich zusammengepasst hat, macht man normalerweise einen einfachen Query à la „zeige Bestellung und ihre Positionen" oder schaut zumindest auf die Anzahl der Zeilen in order_item nach order_id. Das ist kein separates Thema, aber als Denkvorgehen sehr nützlich: Stell dir immer vor, welchen Tabellenzustand du nach dem commit erwartest.
9. Rollback-Szenario und Rücknahme von Änderungen
Über rollback lässt sich leicht abstrakt reden, aber wichtig ist, seine Mechanik zu spüren. Stelle dir vor, wir geben eine Bestellung aus zwei Positionen auf: Beim ersten Produkt reichte der Bestand, beim zweiten nicht. Ohne Transaktion bekommt man eine „halbe Bestellung": Die erste Position wurde erfasst und der Bestand abgebucht, aber dann ist alles gescheitert. Mit Transaktion wird die Welt ehrlicher: Ein UPDATE stock_item gibt 0 zurück, wir werfen eine Ausnahme, und außen löst sich ein rollback der gesamten Operation aus.
Wichtiger Gedanke: rollback macht nicht den letzten Befehl rückgängig, sondern alle Änderungen innerhalb der Transaktion, die noch nicht bestätigt wurden. Deshalb wird die Datenbank sowohl customer_order als auch bereits eingefügte order_item-Einträge als auch die erfolgreiche Abbuchung für die vorherige Position stornieren. Und deshalb halten wir so hartnäckig commit ans Ende: Solange das Szenario nicht bis zum Ende gelangt ist, gilt alles als Entwurf.
Und noch eine Disziplin, die man im Kopf behalten sollte: Nach einem Fehler und rollback muss die Verbindung in den Normalzustand zurückversetzt werden. In Lernbeispielen wird das oft ignoriert, aber im echten Leben kann eine „beschädigte Verbindung" weitertreiben und andere Operationen zu beschädigen beginnen. Selbst wenn man keine komplexe Anwendung schreibt, ist die Gewohnheit, Ressourcen sauber zu schließen und autocommit zu kontrollieren, eine Investition in ein ruhiges Leben.
10. Typische Fehler bei der Bestellungsaufgabe
Fehler Nr. 1: Transaktion „unterwegs" statt am Ende bestätigen.
Manchmal macht ein Anfänger commit() nach dem Einfügen von customer_order, weil „na, die Bestellung ist ja schon erstellt". Und dann scheitert beim Einfügen der Position oder bei der Bestandsabbuchung etwas – und rollback kann die Datenbank nicht mehr in den Ausgangszustand zurückversetzen. Als Ergebnis bleibt eine „Geisterbestellung" übrig: Sie existiert, aber hat keine Positionen. Wenn eine Operation logisch eine ist, muss commit einer sein – am Ende.
Fehler Nr. 2: Die Anzahl betroffener Zeilen beim kritischen UPDATE nicht prüfen.
Der Befehl update stock_item set available_quantity = available_quantity - 2 where product_id = 10 and available_quantity >= 2 ist nicht nur eine Aktualisierung, sondern gleichzeitig eine Prüfung. Wenn 0 Zeilen aktualisiert wurden, bedeutet das „Ware nicht ausreichend". Wenn man diese Tatsache ignoriert und das Szenario fortsetzt, gibt man eine Bestellung ohne echte Reservierung oder Abbuchung der Ware auf – das heißt, man erstellt einen schönen Eintrag in der Datenbank, der nicht der Realität entspricht.
Fehler Nr. 3: total_amount als „eigenständige" Zahl betrachten.
Oft berechnet man die Bestellsumme irgendwo separat, erfasst Positionen irgendwo separat, und diese zwei Realitäten weichen langsam voneinander ab. Auf SQL-Ebene ist eine einfache Disziplin so: total_amount muss aus den Positionen hervorgehen. Selbst wenn man die Summe im Code berechnet, muss die Logik verknüpft bleiben: Position → line_total → Bestellsumme, nicht „ich erinnere mich irgendwo, dass die Summe 399,98 war".
Fehler Nr. 4: order_item vor customer_order einfügen und sich über FK-Fehler wundern.
Das ist genau der Reihenfolge-Fehler, für den die Datenbank absolut zu Recht schimpft. Wenn eine Bestellposition auf eine Bestellung verweist, aber die Bestellung noch nicht existiert, kann die Referenz nicht korrekt sein. Die Lösung ist fast immer eine: Zuerst den Wurzeleintrag erstellen, dann die abhängigen.
Fehler Nr. 5: rollback in der Ausnahmebehandlung vergessen.
Manchmal wird im catch-Block der Fehler geloggt, die Ausnahme weitergeleitet, aber rollback nicht aufgerufen. Als Ergebnis bleibt die Verbindung in einer unvollendeten Transaktion, und Änderungen können bis zum Schließen der Verbindung hängen oder einen unerwarteten Effekt erzeugen. Wenn man autocommit deaktiviert hat, muss man bei einem Fehler explizit wählen: entweder commit oder rollback. Ein Drittes gibt es nicht – das ist keine Philosophie, das ist die Physik der Transaktionen.
GO TO FULL VERSION