1. Konflikt und setter: Dirty Checking und verzögertes SQL
Wenn ein Anfänger zum ersten Mal mit optimistischem Locking in Berührung kommt, erwartet er oft so etwas wie: „Ich habe item.setAvailableQuantity(...) aufgerufen – und sofort in dieser Codezeile müsste ein Konflikt auftreten“. Aber Hibernate funktioniert nicht nach dem Motto „SQL bei jeder Kleinigkeit“. Es arbeitet nach dem Modell der Unit of Work: Sie ändern das Objekt im Speicher, und das SQL wird erst später an die Datenbank gesendet – normalerweise gegen Ende der Transaktion. Und das ist, überraschenderweise, kein Fehler, sondern ein grundlegendes Prinzip von ORM.
Erinnern wir uns, was eine managed-Entität ist. Wenn Sie repository.findById(...) innerhalb von @Transactional aufrufen, erhalten Sie ein Objekt, das an den Persistenzkontext „angehängt“ ist. Sie ändern seine Felder mit gewöhnlichen Settern, und Hibernate entscheidet später: „aha, das Feld hat sich geändert – also muss ein UPDATE erfolgen“. Dieser Mechanismus heißt Dirty Checking. Er muss nicht sofort SQL zum Zeitpunkt des Setters senden. Noch mehr: Wenn er bei jedem Setter SQL senden würde, würde jede Geschäftsoperation zu einem Maschinengewehrfeuer auf die Datenbank werden.
Schauen wir uns den gewöhnlichsten Code aus unserem Projekt shop-data-jpa an. Er enthält weder save() noch explizites SQL – und das ist völlig normal:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryService {
@Transactional // Transaktionsgrenze: commit/rollback erfolgen nach Verlassen der Methode
public void reserve(Long stockItemId, int qty) {
// Hier erhalten wir eine managed-Entität (an den Persistenzkontext angehängt)
StockItem item = repository.findById(stockItemId).orElseThrow();
// Ändern des Java-Objekts im Speicher (Dirty Checking merkt sich die Änderungen)
item.setAvailableQuantity(item.getAvailableQuantity() - qty);
// WICHTIG: SQL wurde möglicherweise noch nicht an die DB gesendet.
// Der Konflikt des optimistischen Lockings kann später auftreten – bei flush/commit.
}
}
Der entscheidende Gedanke: Der Setter ändert nur das Java-Objekt. Ein Konflikt des optimistischen Lockings kann nur dann erkannt werden, wenn Hibernate tatsächlich versucht, ein UPDATE in der DB durchzuführen und feststellt, dass die „alte Version“ nicht mehr aktualisiert werden kann.
Für die Mechanik von flush reicht es hier, die Änderung eines Feldes availableQuantity zu sehen. In der vollständigen StockItem.reserve(qty) aus dem mini-shop werden sowohl availableQuantity als auch reservedQuantity geändert, aber der Version-Check bei flush funktioniert nach denselben Regeln: Hibernate prüft trotzdem, ob es dieselbe Version der Zeile aktualisiert.
2. @Version im Persistenzkontext: Snapshot
Es ist wichtig zu verstehen, dass optimistisches Locking keine „Prüfung im Java-Code“ oder eine magische Thread-Synchronisation ist. Die Grundlage der Mechanik liegt in einer sehr unspektakulären Sache: Der Persistenzkontext speichert einen Snapshot des Zustands der Entität, der aus der Datenbank gelesen wurde, und unter den Feldern dieses Snapshots befindet sich version. Danach läuft alles fast wie in der Buchhaltung ab: „Wir aktualisieren genau das, was zum Zeitpunkt des Lesens aufgezeichnet wurde“.
Wenn StockItem geladen wird, liest Hibernate die Spalte version (und alle anderen benötigten Spalten) und merkt sich: „Die Entität mit id = 10 hat die Version = 5“. Wenn Sie innerhalb der Transaktion availableQuantity geändert haben, erinnert sich Hibernate trotzdem, mit welcher Version Sie gestartet sind. Und genau diese alte Version wird Teil des WHERE im UPDATE.
Man kann einen kleinen „psychologischen Test“ für das Gehirn machen: die Version vor und nach der Änderung ausgeben. Im Normalfall werden Sie sehen, dass Hibernate die Version selbst erhöht (Sie ändern sie nicht manuell).
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void changeAvailableQuantity(Long id, int newQty) {
// Laden der managed-Entität zusammen mit der aktuellen Version
StockItem item = repository.findById(id).orElseThrow();
// Version vor den Änderungen (Wert aus der DB zum Zeitpunkt des Lesens)
System.out.println("Version(vorher) = " + item.getVersion()); // z.B.: 5
// Für die Mechanik des Version-Checks reicht hier ein Feld:
// im vollständigen reserve-flow würden sowohl availableQuantity als auch reservedQuantity geändert
item.setAvailableQuantity(newQty);
// Erzwingen des SQL-Sendens jetzt, um den Moment des Version-Checks zu sehen
entityManager.flush();
// Nach erfolgreichem flush hat Hibernate die Version im Objekt bereits erhöht
System.out.println("Version(nachher) = " + item.getVersion()); // z.B.: 6
}
Ja, flush ist hier absichtlich – um den Moment der Synchronisation zu sehen (darüber sprechen wir im nächsten Abschnitt im Detail). Aber schon jetzt ist es nützlich festzuhalten: Die Version ist Teil der Persistenzmechanik, die ORM genauso im Kopf behält wie id.
3. SQL für den Version-Check: WHERE version = ...
Die Worte „Version-Check“ klingen so, als würde Hibernate ein if in Java machen und die Versionen selbst vergleichen. Tatsächlich findet der wichtigste Teil der Prüfung im SQL statt. Hibernate formuliert das UPDATE so, dass die Datenbank die Zeile nur dann aktualisiert, wenn die Version übereinstimmt. Und genau die Datenbank gibt die Information zurück, wie viele Zeilen aktualisiert wurden.
Vereinfacht (sehr realitätsnah) sieht das so aus:
-- UPDATE wird nur ausgeführt, wenn in der DB immer noch die Version liegt, die wir zuvor gelesen haben
update stock_item
set available_quantity = ?,
reserved_quantity = ?,
version = ? -- neuer Wert der Version (normalerweise old + 1)
where id = ?
and version = ?; -- alte Version (Version-Check)
Beachten Sie zwei Stellen, an denen die Version beteiligt ist. In SET version = ? legt Hibernate den neuen Wert der Version ab (normalerweise old + 1). Und in WHERE ... and version = ? legt er die alte Version ab, die die Entität zum Zeitpunkt des Lesens hatte. Das ist der Version-Check.
Nun zum wichtigsten Punkt des Tages: Wenn jemand anderes diese Zeile bereits aktualisiert und die version erhöht hat, dann wird die Bedingung WHERE id = ? and version = oldVersion nicht zutreffen. Die Datenbank wird ehrlich sagen: „Ich habe 0 Zeilen aktualisiert“. Hibernate betrachtet das und schlussfolgert: „Das Objekt ist veraltet. Es liegt ein Konflikt des optimistischen Lockings vor“.
Eine etwas „menschlichere“ Mini-Illustration, damit das Gehirn nicht in Abstraktionen abdriftet:
long oldVersion = 5;
long newVersion = oldVersion + 1;
// Nur eine Illustration: Hibernate führt ungefähr diese Arithmetik mit der Version durch
System.out.println("alt = " + oldVersion); // alt = 5
System.out.println("neu = " + newVersion); // neu = 6
Wenn Transaktion A die Zeile mit version = 5 aktualisiert und version = 6 geschrieben hat, dann wird Transaktion B, die immer noch versucht, „Version 5“ zu aktualisieren, plötzlich feststellen, dass Version 5 nicht mehr existiert. Und genau das wollen wir: ihr nicht erlauben, stillschweigend die Änderungen eines anderen zu überschreiben.
Genau dieselbe Prüfung wird auch für DELETE einer versionierten Entität durchgeführt: Hibernate fügt die Version zum WHERE hinzu, und auch das Löschen erfolgt „bedingt“. Wenn die Zeile bereits geändert wurde, wird das Löschen nicht stillschweigend durchgeführt.
4. flush: Unterschied zu commit und save
Das Wort flush klingt so, als würde jetzt „alles in die Toilette gespült“ – und manchmal trifft das zu, wenn Sie SQL-Logs aktiviert haben und einen Haufen UPDATE sehen. Aber technisch gesehen ist flush lediglich die erzwungene Synchronisation der Änderungen aus dem Persistenzkontext in die Datenbank. Das heißt, Hibernate nimmt die angesammelten Änderungen an managed-Entitäten und sendet die entsprechenden SQL-Befehle.
Das Wichtigste: Flush beendet die Transaktion nicht. Nach flush() können Sie immer noch einen rollback durchführen, und die Datenbank wird die Änderungen rückgängig machen. Das ist sehr wichtig zu verstehen: Flush bedeutet nicht „für immer speichern“, sondern „SQL jetzt senden, die endgültige Entscheidung kommt bei commit/rollback“.
Halten wir das in einer kurzen Tabelle fest, denn hier werden Anfänger am häufigsten durcheinandergebracht:
| Ereignis im Code | Was tatsächlich passiert | Kann ein Optimistic-Lock-Konflikt auftreten? |
|---|---|---|
| item.setAvailableQuantity(...) | Wir ändern das Java-Objekt, Hibernate merkt sich die Änderung nur | Nein, weil SQL noch nicht gesendet wurde |
| entityManager.flush() | Hibernate sendet UPDATE ... WHERE version = ? | Ja, der Konflikt wird hier sichtbar |
| commit (am Ende von @Transactional) | Vor dem commit erfolgt normalerweise flush + Transaktionsfixierung | Ja, der Konflikt kann hier auftreten |
In Spring Boot erhalten Sie EntityManager meist als Abhängigkeit. In unserem Projekt sieht das standardmäßig und nicht beängstigend aus:
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
@PersistenceContext
private EntityManager entityManager; // EntityManager injizieren, um flush manuell ausführen zu können
// ...
}
Und so wird flush aufgerufen:
// Erzwingen der Synchronisation des Persistenzkontexts mit der DB (die Transaktion wird nicht geschlossen)
entityManager.flush();
Und noch ein wichtiger Punkt für die Praxis: Flush kann auch automatisch erfolgen, nicht nur wenn Sie ihn manuell aufrufen. Zum Beispiel beim Abschluss der Transaktion (commit) müssen Spring/Hibernate die Änderungen auf jeden Fall synchronisieren. Wenn Sie also keinen manuellen flush durchführen, heißt das nicht, dass es keinen gibt – er findet höchstwahrscheinlich einfach „am Ende“ statt, wenn Sie gedanklich schon beim Tee sind.
5. Ort des Konflikts: flush und commit in Spring
Im normalen Lebenscode rufen Sie eine Servicemethode auf, und sie kehrt zurück – fertig, die Operation ist beendet. Aber in Spring gibt es eine wichtige „hinter den Kulissen“-Bühne: @Transactional funktioniert über einen Proxy, und der Commit der Transaktion erfolgt nach dem Verlassen der Methode. Das heißt, technisch gesehen beginnt Spring erst mit dem Abschließen der Transaktion, wenn Sie ein return schreiben oder die Methode einfach endet.
Daher kommt der Effekt, den Anfänger oft so beschreiben: „Ich habe einen try/catch um den Code gelegt, aber die Ausnahme taucht trotzdem irgendwo später und unklar wo auf.“ Das ist keine Verschwörung, sondern die Logik des Transaktionslebenszyklus: Der Konflikt des optimistischen Lockings manifestiert sich oft genau bei flush, der beim commit stattfindet – und der commit erfolgt bereits nach der Ausführung des Methodenrumpfs.
Um den Konflikt an einem vorhersagbaren Punkt zu sehen (und überhaupt zu verstehen, wo er auftritt), ist es nützlich, manchmal flush() manuell aufzurufen. Dann wird der Zeitpunkt des SQL-Sendens direkt innerhalb der Methode an einer bestimmten Codezeile sichtbar.
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void updateQuantity(Long id, int newQty) {
// 1) Entität (zusammen mit version) in den Persistenzkontext laden
StockItem item = repository.findById(id).orElseThrow();
// 2) Für die Mechanik von flush reicht hier ein Feld:
// im vollständigen reserve-flow würden sowohl availableQuantity als auch reservedQuantity geändert
item.setAvailableQuantity(newQty);
// 3) Flush durchführen, damit der Konflikt genau hier auftritt (und nicht „nach Verlassen der Methode“)
entityManager.flush(); // Bei Konflikt tritt er direkt hier auf
// 4) Diese Zeile wird nicht ausgeführt, wenn flush eine Optimistic-Lock-Exception auslöst
System.out.println("Flush durchgeführt");
}
Und nun ein Schema, das alles in einem Zeitstrahl verbindet. Es ist praktisch, es im Kopf zu behalten, wenn Sie debuggen, warum „der Setter durchgelaufen ist, aber dann alles abgestürzt ist“.
flowchart LR
%% Zeitstrahl einer Transaktion: wo das SQL tatsächlich entsteht und wo ein Konflikt möglich ist
A["findById(): StockItem geladen (version = 5)"] --> B["setAvailableQuantity(): Objekt im Speicher geändert"]
B --> C["flush(): UPDATE ... WHERE version = 5 gesendet"]
C --> D["commit(): Transaktion fixieren"]
Wenn zwischen A und C eine andere Transaktion version = 6 erzeugt hat, wird die Datenbank bei Schritt C 0 Zeilen aktualisieren, und Hibernate löst eine Optimistic-Lock-Exception aus. Nicht bei Schritt B. Nicht bei „irgendwo im Setter“. Sondern dort, wo das SQL war.
6. Mini-Szenario: SQL-Logs und flush()
Jetzt fassen wir alles in einem Miniszenario zusammen, genau im Kontext unseres Projekts. Wir werden nicht in die Java-Multithreading-Tiefe eintauchen und keinen komplexen Stand aufbauen – das kommt in der praktischen Vorlesung des Tages. Aber wir werden das Wichtigste tun: Beobachtbarkeit einschalten und lernen, anhand der Logs zu verstehen, wann genau der Version-Check stattfindet.
Zunächst ist es wichtig, dass SQL sichtbar ist. Im Kurs haben wir bereits SQL-Logs im dev-Profil aktiviert; ich erinnere an das typische minimale Stück (bei Ihnen kann es etwas anders sein, der Sinn ist derselbe):
logging:
level:
org.hibernate.SQL: DEBUG # Zeigt das SQL selbst (SELECT/UPDATE/INSERT/DELETE)
org.hibernate.orm.jdbc.bind: TRACE # Zeigt das Binden der Parameter (u.a. id und version)
Jetzt erstellen wir in InventoryService eine Methode, die den Zeitpunkt des SQL explizit festnagelt, damit wir ihn nicht mit den Augen in der gesamten Anwendung suchen müssen. Sie ist nicht ideal als Produktionscode (manueller flush wird nicht immer benötigt), aber ideal als Lehr-Mikroskop.
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void setAvailableQuantityWithFlush(Long id, int newQty) {
// Entität laden: hier wird ein SELECT erfolgen (und das Lesen der version)
StockItem item = repository.findById(id).orElseThrow();
// Objekt im Speicher ändern: vorerst noch kein SQL
item.setAvailableQuantity(newQty);
// Erzwingen des UPDATE mit Version-Check genau hier
entityManager.flush(); // UPDATE mit Version-Check wird hier gesendet
}
Was Sie in den Logs bei erfolgreicher Aktualisierung sehen werden? Etwa folgendes Bild: zunächst ein select (beim Laden von StockItem), dann update stock_item ... where id=? and version=?. Bei keinem Konflikt wird 1 Zeile aktualisiert, die Version erhöht sich, die Transaktion wird erfolgreich committed.
Und was sehen Sie bei einem Konflikt (wenn parallel ein anderer Request dieselbe StockItem geändert hat)? In den Logs sehen Sie trotzdem das UPDATE ... WHERE version = old, aber das Ergebnis der Aktualisierung ist 0 Zeilen. Und in diesem Moment versteht Hibernate: „Ich habe versucht, die ‚alte Realität‘ zu aktualisieren, aber sie existiert nicht mehr.“ Danach wird eine Optimistic-Lock-Exception ausgelöst.
Um das noch „greifbarer“ zu machen, ist es hilfreich, die folgende Sequenz zweier konkurrierender Transaktionen im Kopf zu behalten:
sequenceDiagram
%% Zwei konkurrierende Transaktionen lesen dieselbe Version, aber nur die erste kann aktualisieren
participant TxA as "Tx A (Operation A)"
participant TxB as "Tx B (Operation B)"
participant DB as PostgreSQL
TxA->>DB: SELECT stock_item (version=5)
TxB->>DB: SELECT stock_item (version=5)
TxA->>DB: UPDATE ... WHERE id=10 AND version=5 (Erfolg, version=6)
TxB->>DB: UPDATE ... WHERE id=10 AND version=5 (0 rows)
TxB-->>TxB: Optimistic Lock Exception
Beachten Sie, dass hier keine „magische Synchronisation“ stattfindet. Die Datenbank muss nicht verstehen, dass Sie Hibernate verwenden. Sie führt einfach ehrliches SQL mit ehrlichem WHERE aus. Und Hibernate interpretiert einfach das ehrliche Ergebnis „0 Zeilen aktualisiert“ als Konflikt.
Und noch ein praktisches Detail, das oft Stunden Lebenszeit spart. Wenn Sie versuchen, eine Optimistic-Lock-Exception innerhalb der Methode zu „fangen“ und einen try/catch zu schreiben, können Sie ohne flush die Exception möglicherweise gar nicht dort abfangen, wo Sie es erwarten, weil die Ausnahme beim commit nach Verlassen der Methode ausgelöst wird. Daher rufen wir in Lehrszenarien flush explizit auf: Er macht den Moment des Version-Checks vorhersagbar und sichtbar. Das reicht bereits aus, um zur nächsten technischen Frage überzugehen: Wie man einen solchen Konflikt in ein verständliches Ergebnis einer Service-Operation umwandelt.
7. Typische Fehler bei der Arbeit mit flush und optimistischem Locking
Das Thema flush und optimistisches Locking erscheint komplex, nicht weil dort „viel Code“ steht, sondern weil es viele versteckte Grenzen gibt: die Transaktionsgrenze, die Grenze des Persistenzkontexts, die Grenze „im Speicher“ vs. „in der Datenbank“. Daher sind die Fehler hier oft nicht Kompilierungsfehler, sondern semantische Fehler – die Anwendung startet, verhält sich aber „komisch“.
Fehler Nr. 1: Erwarten einer Exception in der Zeile setAvailableQuantity(...).
Das ist ein typischer Effekt des „JDBC-Denkens“: Es scheint, dass die Feldänderung sofort SQL bedeutet. In JPA ist das nicht so. Der Setter ändert nur das Objekt. Der Konflikt wird beim Senden von SQL erkannt, also normalerweise bei flush() oder bei commit. Wenn man sich an Dirty Checking und verzögertes SQL erinnert, werden die Erwartungen realistischer.
Fehler Nr. 2: flush() als „commit“ oder „für immer speichern“ auffassen.
Flush sendet zwar SQL an die Datenbank, wirkt daher beängstigend. Aber die Transaktion kann immer noch zurückgerollt werden. Wenn nach dem flush ein Fehler auftritt und ein rollback erfolgt, verschwinden die Änderungen. Flush ist eine Synchronisation, keine endgültige Entscheidung über das Schicksal der Daten.
Fehler Nr. 3: Versuchen, optimistisches Locking durch Hinzufügen überflüssiger save() zu „heilen“.
Wenn Sie eine managed entity ändern, ist save oft nicht nötig: Dirty Checking erledigt alles selbst. Und ein Optimistic-Lock-Konflikt bedeutet nicht, dass „vergessen wurde zu speichern“. Es ist die erkannte Tatsache, dass die Daten in einer anderen Transaktion bereits geändert wurden. Ein zusätzlicher save macht den Konflikt nicht weniger konfliktreich, er fügt lediglich Verwirrung hinzu.
Fehler Nr. 4: Code in try/catch zu wickeln, aber nicht zu verstehen, dass die Exception beim commit nach Verlassen der Methode ausgelöst werden kann.
In Spring wird die Transaktion nach Beendigung der @Transactional-Methode committed. Daher kann eine Optimistic-Lock-Exception „nach der letzten Zeile der Methode“ auftreten. Wenn Sie zu Lehrzwecken den Konflikt innerhalb der Methode sehen möchten, verwenden Sie flush(). Wenn Sie den Konflikt architektonisch behandeln möchten – das ist bereits ein Thema der Service-Reaktion (darauf gehen wir in der nächsten Vorlesung ein).
Fehler Nr. 5: Zu denken, optimistisches Locking habe etwas mit „Java-Threads“ zu tun, und es mit synchronized behandeln zu wollen.
Optimistisches Locking betrifft die Konkurrenz von Transaktionen in der Datenbank. Selbst wenn Sie nur einen Thread in einem JVM-Prozess haben, kann es daneben eine zweite Anwendungsinstanz, einen zweiten Service oder einfach eine zweite Anfrage an denselben Service geben. synchronized schützt nur einen Speicherbereich innerhalb eines Prozesses und löst das Problem auf DB-Ebene nicht.
GO TO FULL VERSION