1. Lock – in der Datenbank, nicht in Java
Sobald das Wiederholen bei einer heißen Zeile keine Freude mehr bereitet, stellt sich die nächste sehr bodenständige Frage: Was genau blockiert die Datenbank überhaupt, wenn wir einen pessimistischen Lock anfordern?
Wenn man beginnt mit LockModeType zu arbeiten, versucht das Gehirn (besonders nach der Arbeit mit Multi‑Threading in Java) oft, sich das wie synchronized auf Entitäten vorzustellen: „Ich habe InventoryItem gelockt, also wird es niemand anrühren.“ Das ist ein falsches Modell. In JPA/Hibernate fordern wir eine Sperre von der Datenbank an, und gesperrt wird nicht das Java‑Objekt, sondern die Zeile (oder Zeilen) auf der PostgreSQL‑Seite – bis zum Ende der Transaktion.
Praktisch bedeutet das zwei Dinge. Erstens ist ein pessimistischer Lock ohne Transaktion fast sinnlos: Die Abfrage wird ausgeführt, der Lock für die Dauer der SQL‑Anweisung genommen … und sofort wieder freigegeben (im Autocommit‑Modus ist jede Anweisung eine eigene Transaktion). Zweitens „lebt“ der Lock nicht dort, wo Sie die Referenz auf das Objekt halten, sondern dort, wo die Transaktion lebt. Wenn Sie aus der @Transactional‑Methode austreten – fertig, die Sperre ist aufgehoben, die „Tür ist offen“, selbst wenn Sie das Objekt noch in einer Variable halten.
Nachfolgend eine kurze Erinnerung, an welcher Entität wir dies beobachten werden (stark verkürzter Ausschnitt, nur für den Kontext):
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
@Entity // JPA-Entität: Zeilen dieser Tabelle werden in der DB gesperrt
public class InventoryItem {
@Id
private Long id; // Primärschlüssel der Zeile, damit punktgenaues Sperren am einfachsten
@Version
private long version; // für optimistisches Locking: pessimistisches hebt es nicht auf
}
@Version bleibt bei uns: Pessimistisches Locking muss optimistisches Locking nicht „aufheben“. In einem echten Projekt leben oft beide Werkzeuge nebeneinander, sie werden nur in unterschiedlichen Szenarien oder unter unterschiedlicher Last eingesetzt.
2. PESSIMISTIC_READ und PESSIMISTIC_WRITE: der Unterschied
Der Unterschied zwischen PESSIMISTIC_READ und PESSIMISTIC_WRITE scheint aufgrund der Namen offensichtlich, aber genau hier entstehen am häufigsten Mythen. Man denkt, dass PESSIMISTIC_READ „einfach gelesen, und niemand kann auch nur hinschauen“ bedeute, und PESSIMISTIC_WRITE „gelesen und für alle alles verboten“. In PostgreSQL ist die Welt jedoch etwas feiner: Wir haben MVCC, und viele Lesevorgänge blockieren sich gegenseitig überhaupt nicht. Sperren werden benötigt, um konkurrierende Schreibvorgänge und konkurrierende Sperren zu verwalten, nicht um jemandem das „reine Anschauen“ zu verbieten.
Eine vereinfachte mentale Modell für heute ist wie folgt. PESSIMISTIC_READ ist eine Shared‑Sperre auf eine Zeile: „Ich lese so, dass niemand stillschweigend in diese Zeile schreiben kann, bis meine Transaktion beendet ist“, und ein anderer Leser kann ebenfalls so eine Shared‑Sperre nehmen. PESSIMISTIC_WRITE ist eine exklusive Sperre auf eine Zeile: „Ich lese und werde etwas ändern, also müssen alle anderen warten, wenn sie ebenfalls diese Zeile ändern (oder mit Schreibsperre belegen) wollen“.
In PostgreSQL wird das normalerweise durch folgende SQL‑Anhänge ausgedrückt:
| LockModeType in JPA | Typisches SQL in PostgreSQL | Intuitive Bedeutung |
|---|---|---|
| PESSIMISTIC_READ | … FOR SHARE | „Gemeinsam lesen, aber nicht schreiben“ |
| PESSIMISTIC_WRITE | … FOR UPDATE | „Ich halte die Zeile zum Schreiben“ |
Und jetzt ein superwichtiges Detail: Ein gewöhnlicher SELECT ohne FOR … wird meistens weder durch das eine noch durch das andere blockiert. Er liest einfach seine Version der Daten via MVCC. Sperren werden relevant, wenn der zweite Teilnehmer versucht ein UPDATE/DELETE durchzuführen oder ebenfalls eine konfligierende FOR UPDATE/FOR SHARE‑Sperre anzufordern.
3. Wie Hibernate LockModeType in SQL umwandelt
Bis Sie das SQL nicht gesehen haben, bleibt pessimistisches Locking eine Art Zauberspruch: „Ich habe PESSIMISTIC_WRITE gesagt, also funktioniert es irgendwie.“ In einem Deep‑Dive‑Kurs darf man so nicht leben (sonst wird man versehentlich zum Schamanen). Daher die grundlegende Disziplin: Aktivieren Sie das Profil sql-trace und schauen Sie, wie genau Hibernate die Abfrage bildet. Nur SQL ist an dieser Stelle die einzig ehrliche Quelle der Wahrheit.
Wenn Sie entityManager.find(…, LockModeType.PESSIMISTIC_WRITE) aufrufen, generiert Hibernate normalerweise ein SELECT über den PK und hängt FOR UPDATE an. Für PESSIMISTIC_READ wird in PostgreSQL normalerweise FOR SHARE angehängt. Die Abfrage mag nicht ideal „schön“ aussehen, weil Hibernate Aliase mag, aber der Sinn ist leicht lesbar: Am Ende steht der gewünschte Lock‑Anhang.
Hier ein Beispielcode, der mit hoher Wahrscheinlichkeit zu FOR UPDATE führt (ich zeige ihn kurz, ohne Geschäftslogik – heute interessiert uns nur die Sperrabfrage):
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryLockingReadService {
private final EntityManager entityManager;
public InventoryLockingReadService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional // ohne Transaktion lebt die Sperre nur für eine Anweisung und verschwindet sofort
public void lockForWrite(Long itemId) {
// wichtig: wir sperren die Zeile in der DB (nicht das Java-Objekt) bis zum Ende der Transaktion
entityManager.find(InventoryItem.class, itemId, LockModeType.PESSIMISTIC_WRITE);
}
}
In den Logs erwarten Sie etwas in der Art:
select …
from inventory_item i
where i.id = ?
for update
— entscheidend: FOR UPDATE bedeutet, dass die Zeile exklusiv gesperrt ist
Wenn Sie kein for update oder for share sehen, wurde entweder der Lock nicht wirklich angefordert, Sie betrachten das falsche Log‑Profil, oder die Entität stammt aus dem Persistenzkontext und Hibernate hat anders entschieden (über solche Feinheiten sprechen wir noch, aber die Regel „Schau auf SQL“ funktioniert schon).
4. PESSIMISTIC_READ: Was gesperrt wird
PESSIMISTIC_READ wird oft als „Ich habe die Zeile zum Lesen gesperrt, also kann niemand sie anfassen“ verstanden. Tatsächlich geht es um etwas anderes: Sie sagen der Datenbank „Ich möchte diese Zeile so lesen, dass ein konkurrierender Schreibvorgang nicht durchkommt, bis ich meine Transaktion beende“. In PostgreSQL ist die typische Folge FOR SHARE. Das ist eine Shared‑Sperre: Mehrere Transaktionen können sie gleichzeitig halten, und das ist in Ordnung. Sie lesen alle und verhindern gemeinsam, dass irgendjemand die Zeile unbemerkt aktualisiert.
Im Verhalten der Konkurrenten sieht das ungefähr so aus. Wenn Transaktion Nr.1 einen PESSIMISTIC_READ genommen hat, kann Transaktion Nr.2 einen normalen SELECT ohne Sperre ausführen und die Daten erhalten (via MVCC). Wenn Transaktion Nr.2 jedoch ein UPDATE dieser Zeile versucht, wird sie warten. Und wenn Transaktion Nr.2 ebenfalls einen PESSIMISTIC_READ nehmen möchte, wird sie normalerweise nicht warten, weil Shared‑Sperren kompatibel sind.
Hier ein „Code‑Bild“ für PESSIMISTIC_READ in unserem Projekt:
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryReadLockService {
private final EntityManager entityManager;
public InventoryReadLockService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional // Die Sperre wird gehalten, bis die Transaktion mit commit/rollback endet
public InventoryItem getLockedForRead(Long itemId) {
// PESSIMISTIC_READ in PostgreSQL wird normalerweise zu SELECT … FOR SHARE
return entityManager.find(InventoryItem.class, itemId, LockModeType.PESSIMISTIC_READ);
}
}
Und hier ein kleines Experiment „auf Datenbankebene“ (sehr empfehlenswert, in zwei psql‑Konsolen auszuprobieren – das fühlt sich fast physisch an). Sitzung A:
begin; — Transaktion beginnen: sonst hält der Lock nicht
select * from inventory_item where id = 1 for share; — Shared Lock auf die Zeile
— Transaktion offen halten
Sitzung B:
update inventory_item set reserved_qty = reserved_qty + 1 where id = 1;
— wird warten, bis Sitzung A commit/rollback macht (Konflikt: Schreiben gegen FOR SHARE)
Hier ist es wichtig, sich die Formulierung zu merken: PESSIMISTIC_READ macht die Zeile nicht „unsichtbar“. Es macht das Schreiben unmöglich (oder wartend), bis die Transaktion des Lesers abgeschlossen ist.
5. PESSIMISTIC_WRITE: Was gesperrt wird
PESSIMISTIC_WRITE ist der Modus, den wir am häufigsten im Szenario der Reservierung von Beständen verwenden. Vom Sinn her klingt es wie „Ich nehme die Zeile zum Schreiben“, und in PostgreSQL wird es normalerweise zu FOR UPDATE. Das ist eine strengere Sperre: Wenn ein anderer Konkurrent ebenfalls FOR UPDATE auf dieselbe Zeile haben möchte, wird er warten. Wenn ein anderer Konkurrent die Zeile aktualisieren möchte, wird er warten. Wenn ein Konkurrent FOR SHARE möchte – in einem vereinfachten Modell wird auch er warten, weil die Write‑Sperre mit vielen anderen Lock‑Modi kollidiert.
In der Praxis ist PESSIMISTIC_WRITE eine Möglichkeit zu sagen: „Wir spielen nicht die optimistische Lotterie beim Commit; wir stellen den Zugriff auf die Zeile im Voraus in eine Warteschlange.“ Ja, jemand wird warten. Ja, das reduziert den Durchsatz. Aber dafür erhalten Sie einen vorhersagbareren Write‑Flow, besonders wenn ein Wiederholungsversuch zu teuer oder der Konflikt zu häufig ist.
Ein Code‑Porträt von PESSIMISTIC_WRITE in einem Service sieht normalerweise so aus (wieder kurz und ohne überflüssige Details, denn die Gestaltung eines kritischen Abschnitts ist Thema der nächsten Vorlesung):
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryWriteLockService {
private final EntityManager entityManager;
public InventoryWriteLockService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional // ohne dies wird die Sperre nicht 'innerhalb' der Geschäftsoperation gehalten
public InventoryItem getLockedForWrite(Long itemId) {
// PESSIMISTIC_WRITE in PostgreSQL wird normalerweise zu SELECT … FOR UPDATE
return entityManager.find(InventoryItem.class, itemId, LockModeType.PESSIMISTIC_WRITE);
}
}
Wenn Sie das „zwei Konsolen“‑Experiment wiederholen, wird es ähnlich aussehen, nur dass Sie statt FOR SHARE FOR UPDATE verwenden:
Sitzung A:
begin; — wichtig: Lock lebt bis zum Ende der Transaktion
select * from inventory_item where id = 1 for update; — exklusiver Lock auf die Zeile
— Transaktion noch nicht abgeschlossen
Sitzung B:
select * from inventory_item where id = 1 for update;
— wird warten, weil FOR UPDATE mit FOR UPDATE auf derselben Zeile kollidiert
Und eine sehr wichtige Beobachtung: select * from inventory_item where id = 1; in Sitzung B wird meistens nicht warten, es liest einfach einen Daten‑Snapshot (möglicherweise einen alten). Warten beginnt erst beim update oder beim Versuch, eine konfligierende Sperre zu nehmen.
Um dies visuell zu festigen, hier ein kleines Warte‑Diagramm:
sequenceDiagram
%% Idee: Die Sperre wird von Tx1 bis COMMIT gehalten, Tx2 wartet auf Schreiben/Sperre
participant T1 as "Tx1 (service A)"
participant DB as PostgreSQL
participant T2 as "Tx2 (service B)"
T1->>DB: "SELECT ... FOR UPDATE (Sperre auf Zeile)"
T2->>DB: "UPDATE derselben Zeile"
Note over T2,DB: "Tx2 wartet auf Freigabe der Sperre"
T1->>DB: COMMIT
DB-->>T2: "Sperre gewährt, UPDATE wird ausgeführt"
6. Wie man eine Sperre anfordert: find(), JPQL, refresh()
Selbst wenn Sie bereits verstanden haben, dass PESSIMISTIC_READ/PESSIMISTIC_WRITE in FOR SHARE/FOR UPDATE umgewandelt werden, bleibt die praktische Frage: „Wo schreibe ich das im Code?“ Im echten Leben sperren wir nicht immer genau über id, und wir möchten nicht immer die Entität neu lesen – manchmal ist sie bereits managed. Daher ist es nützlich, drei grundlegende Wege zur Sperranforderung zu kennen, die tatsächlich in Projekten vorkommen (und direkt in unserem Tagesplan enthalten sind).
Der einfachste Weg ist EntityManager.find() mit Lock‑Modus. Er ist gut, weil er maximal eng ist: Sie sperren genau eine Zeile über den PK und riskieren nicht, versehentlich die halbe Tabelle zu sperren, „weil im where etwas nicht stimmt“.
import jakarta.persistence.LockModeType;
// Sperren genau eine Zeile per PK: das ist die 'schmalste' und vorhersagbarste Methode
InventoryItem item = entityManager.find(
InventoryItem.class,
itemId,
LockModeType.PESSIMISTIC_WRITE // bitten die DB, die Zeile unter FOR UPDATE zu nehmen
);
Der zweite Weg – JPQL‑Abfrage + setLockMode(…). Er ist nützlich, wenn Ihnen die itemId nicht passt und Sie die Zeile über eine Geschäftsbedingung finden. In unserem Projekt ein Beispiel – die Suche nach InventoryItem anhand der productId (zur Erinnerung: nach dem Modell „ein Bestandsdatensatz pro Produkt“).
import jakarta.persistence.LockModeType;
// Wichtig: Es werden so viele Zeilen gesperrt, wie die Abfrage zurückgibt
InventoryItem item = entityManager.createQuery("""
select i
from InventoryItem i
where i.product.id = :productId
""", InventoryItem.class)
.setParameter("productId", productId) // Geschäftsparameter, nach dem die Zeile gesucht wird
.setLockMode(LockModeType.PESSIMISTIC_WRITE) // wird auf SQL‑Ebene zu FOR UPDATE
.getSingleResult();
Hier muss man besonders vorsichtig sein: Wenn Ihre Abfrage letztlich viele Zeilen zurückgibt, sperren Sie auch viele Zeilen. Manchmal ist das beabsichtigt, manchmal eine versehentliche Katastrophe. Daher sollte JPQL in Sperr‑Szenarien „eng und ehrlich“ sein: möglichst wenige Joins, ein verständliches Where, eine vorhersagbare Anzahl von Zeilen.
Der dritte Weg – refresh() mit Lock‑Modus. Er wird benötigt, wenn die Entität bereits managed ist und Sie (a) den aktuellen Zustand aus der DB neu lesen und (b) eine Sperre nehmen möchten, um danach sicher arbeiten zu können. Das ist vom Sinn her etwas „schwerfälliger“, weil Sie Hibernate explizit sagen: „Vergiss, was du im Speicher hast, hol es neu“.
import jakarta.persistence.LockModeType;
InventoryItem item = entityManager.find(InventoryItem.class, itemId); // erhalten managed‑Objekt aus dem Persistenzkontext
entityManager.refresh(item, LockModeType.PESSIMISTIC_READ); // neu aus DB lesen und FOR SHARE bis Transaktionsende nehmen
refresh ist keine Methode „für jeden Tag“, aber in Laborszenarien zeigt sie hervorragend die Grenze zwischen „Ich habe ein Objekt im Speicher“ und „In der Datenbank habe ich eine geschützte Zeile“.
Mini‑Labor: Sperren prüfen
Pessimistische Sperren sind besonders tückisch, weil der Code kompiliert und „streng“ aussieht, selbst wenn die Sperre tatsächlich nicht so funktioniert, wie Sie denken. Daher gilt als minimale Labordisziplin: Sie glauben weder sich selbst, noch mir, noch IntelliJ, solange Sie nicht die SQL‑Spur gesehen und das konkurrierende Verhalten reproduziert haben.
Der einfachste Weg im Rahmen Commerce Persistence Lab – aktivieren Sie das Profil sql-trace, rufen Sie die Servicemethode auf, die find(…, PESSIMISTIC_WRITE) macht, und überzeugen Sie sich, dass Sie for update im SQL sehen. Für PESSIMISTIC_READ suchen Sie for share. Wenn Sie einfach nur select … where id=? ohne Anhang sehen, wurde der Lock nicht angefordert (oder Sie haben ihn an der falschen Stelle angefordert).
Dann können Sie ein „Zwei‑Fenster“‑Experiment auf PostgreSQL machen. Es ist kein Belastungstest, sondern nur eine Möglichkeit, das Warten mit eigenen Augen zu sehen. Im ersten Fenster starten Sie eine Transaktion und nehmen ein SELECT … FOR UPDATE auf eine bestimmte Zeile der inventory_item‑Tabelle. Im zweiten Fenster versuchen Sie, dieselbe Zeile zu aktualisieren. Wenn alles richtig gemacht ist, wird die zweite Abfrage „hängen“ und warten. Sobald Sie im ersten Fenster COMMIT ausführen, setzt die zweite Abfrage fort.
Und die letzte Beobachtung, die hilft, sich nicht zu verwirren: Wenn Sie meinen, „die Sperre funktioniert nicht, weil der andere Thread die Zeile trotzdem liest“, ist das fast immer eine falsche Erwartung. MVCC in PostgreSQL erlaubt vielen SELECT auszuführen, ohne zu warten, selbst wenn die Zeile mit FOR UPDATE gesperrt ist. Die Sperre zeigt sich, wenn der Konkurrent versucht zu schreiben oder eine konfligierende Sperre zu nehmen, nicht wenn er nur „schaut“.
7. Typische Fehler: PESSIMISTIC_READ und PESSIMISTIC_WRITE
Fehler Nr. 1: Erwarten, dass die Sperre einen normalen SELECT verbietet.
Eine sehr häufige Falle: Der Entwickler nimmt PESSIMISTIC_WRITE, führt dann aus einem anderen Thread einen SELECT aus und wundert sich, dass die Daten gelesen werden. In PostgreSQL ist das aufgrund von MVCC normal: Ein Lesevorgang ohne FOR UPDATE/FOR SHARE wartet normalerweise nicht. Überprüfen müssen Sie dies nicht durch Lesen, sondern durch ein konkurrierendes UPDATE oder den Versuch, eine konfligierende Sperre zu nehmen.
Fehler Nr. 2: Eine Sperre außerhalb von @Transactional anfordern und glauben, dass sie „hält“.
Wenn Sie entityManager.find(…, PESSIMISTIC_WRITE) ohne Transaktion aufrufen, kann die Datenbank die Sperre nur für die Dauer der Anweisung nehmen und sie dann sofort wieder freigeben. In einer Spring‑Anwendung sollte pessimistisches Locking fast immer innerhalb von @Transactional auf der Servicemethode leben, sonst erhalten Sie eine „Pseudo‑Sicherheit“.
Fehler Nr. 3: PESSIMISTIC_READ verwenden, obwohl Sie die Zeile sicher ändern werden.
Formal mag PESSIMISTIC_READ „sanfter“ wirken, und manchmal kommt der Wunsch auf, ihn „überall zu verwenden, damit es sicherer ist“. Aber wenn Ihr Szenario eine explizite Änderung von Beständen ist, wählen Sie PESSIMISTIC_WRITE, damit die Aussage ehrlich ist: „Ich werde jetzt schreiben“. Sonst erschweren Sie sich die Analyse selbst, weil das Verhalten der Konkurrenten von den Implementierungsdetails in der DB abhängt.
Fehler Nr. 4: Versehentlich mehr Zeilen sperren als nötig durch einen breiten JPQL.
setLockMode(PESSIMISTIC_WRITE) auf einer Abfrage, die viele Zeilen zurückgibt, sperrt viele Zeilen. Manchmal ist das nötig (z. B. Stapelverarbeitung), aber in einem punktgenauen Szenario „Bestand eines bestimmten Artikels reservieren“ wird daraus eine Warteschlange für das gesamte Lager. In Sperr‑Szenarien muss die Abfrage eng und vorhersagbar in der Anzahl der Zeilen sein.
Fehler Nr. 5: Der Annotation/Methode glauben, nicht dem SQL‑Log.
In Sperr‑Themen gerät man leicht in den Modus „Ich habe LockModeType gesetzt, also ist alles okay“. Nein. Die einzige Überprüfung ist, FOR UPDATE/FOR SHARE im SQL zu sehen und das erwartete Warten bei einem konkurrierenden UPDATE zu reproduzieren. Alles andere ist Raten, und Raten in der Persistenzschicht endet meist mit interessanten Bugs in der Produktion.
GO TO FULL VERSION