1. Zwei Ansätze zur Konkurrenz
Wenn Entwickler zum ersten Mal die Wörter optimistisch und pessimistisch hören, entsteht das Gefühl, dass dies eine Art Philosophie auf dem Niveau „Das Glas ist halb leer“ ist. Tatsächlich sind es zwei sehr bodenständige Arten, den Zugriff auf dieselbe Zeile in der Datenbank zu organisieren, wenn es viele Benutzer (und Transaktionen) gibt, aber nur eine Zeile. Wir haben das optimistische Sperren mit @Version bereits „angefasst“, und jetzt ist es logisch, es mit dem Ansatz zu vergleichen, bei dem der Konflikt nicht am Ausgang abgefangen, sondern am Eingang verhindert wird.
Ganz menschlich gesprochen ist optimistisches Sperren ein Modus „arbeitet parallel, aber wenn ihr beide dasselbe geändert habt, erhält der Zweite eine Ablehnung“. Das ähnelt der gemeinsamen Bearbeitung: Ihr bearbeitet beide das Dokument, aber das System sagt ehrlich „Versionskonflikt“ und zwingt euch, eine Entscheidung zu treffen. Der pessimistische Ansatz ähnelt der Situation, wenn ihr den einzigen Stift auf dem Tisch nehmt und ihn festhaltet, bis ihr fertig seid: Niemand kann gleichzeitig schreiben, dafür gibt es keine Konflikte „wer zuletzt geschrieben hat“ – alle warten einfach.
Wichtig zu beachten: Beide Ansätze sind „normal“. Es gibt nicht das eine, das für gute Menschen ist, und das andere für Paranoiker. Jeder Ansatz hat seinen Preis: Optimistisch führt häufiger zu Ausnahmen (blockiert aber nicht), pessimistisch führt häufiger zu Wartezeiten (verringert aber die Wahrscheinlichkeit eines Konflikts auf der Anwendungslogik-Ebene).
2. Optimistisches Sperren: Konflikt beim UPDATE
Das optimistische Sperren kennt ihr bereits, aber zum Vergleich müssen wir es in einem Satz festhalten: Wir sperren die Zeile beim Lesen nicht, sondern führen beim Schreiben eine Versionsprüfung durch. Das bedeutet, dass zwei Transaktionen dieselbe Zeile problemlos lesen können, beide „zuversichtlich“ etwas im Java-Code berechnen, und erst zum Zeitpunkt des Schreibens erfährt eine von ihnen, dass ihre Berechnung auf einem veralteten Zustand basierte.
Auf SQL-Ebene wird dies durch einen einfachen, aber sehr wichtigen Trick ausgedrückt: Die Version ist in der WHERE-Klausel enthalten. Vereinfacht sieht das so aus:
-- Wichtiger Punkt: Die Version ist in WHERE enthalten.
-- Wenn jemand die Zeile bereits aktualisiert hat, ändert sich die Version und UPDATE betrifft 0 Zeilen.
update stock_item
set available_quantity = ?,
version = ?
where id = ?
and version = ?;
Der Satz „0 Zeilen aktualisiert“ wird zur Bedeutung „Die Zeile ist nicht mehr die, mit der ihr gearbeitet habt“. Und dann entscheidet die Anwendung (oder ihr im Service), was zu tun ist: Fehler melden, erneut versuchen, den Zustand neu lesen, den Benutzer bitten, die Seite zu aktualisieren – es gibt viele Varianten, aber die Tatsache des Konflikts wird nicht mehr in den Daten „versteckt“. Das ist bereits ein großer Fortschritt im Vergleich zum verlorenen Update, das Änderungen still und ohne Vorwarnung überschreibt.
Ein wichtiger praktischer Punkt: Optimistisches Sperren funktioniert fast immer gut dort, wo Konflikte wirklich selten sind. Zum Beispiel, wenn StockItem nicht jede Millisekunde von hundert Benutzern geändert wird. Dann erhält man maximale Parallelität und seltene, aber ehrliche Konflikte.
3. Pessimistisches Sperren: Zeilensperre
Das pessimistische Sperren beginnt mit einem anderen Gedanken: „Lasst uns annehmen, dass ein Konflikt möglich ist, und tun wir nicht so, als ob es ihn nicht gäbe.“ Deshalb holen wir die Sperre zum Zeitpunkt des Lesens, noch bevor wir etwas ändern. In der Praxis bedeutet das, dass die Leseabfrage zu einer Leseabfrage mit Zeilensperre auf der Datenbankseite wird.
Wenn wir über PostgreSQL sprechen (und unser Kurs lebt darauf), dann ist die typische SQL-Form SELECT ... FOR UPDATE. Ganz vereinfacht so:
-- FOR UPDATE nimmt eine Zeilensperre für die Dauer der Transaktion:
-- Eine andere Transaktion, die ebenfalls FOR UPDATE für diese Zeile möchte, wird warten.
select *
from stock_item
where id = ?
for update;
Was bringt das? Wenn Transaktion A die Zeile stock_item.id = 42 „zum Schreiben“ übernommen hat, wird Transaktion B, die ebenfalls versucht, dieselbe Zeile „zum Schreiben“ zu übernehmen, warten, bis A commit/rollback abgeschlossen hat. Das heißt, der Konflikt wird nicht zu einer Ausnahme, sondern zu einer Warteschlange. Das klingt sogar gemütlich: „Na gut, warten wir ein bisschen, dafür ohne Fehler.“ Aber hier ist der Preis zu bedenken: Warten ist Zeit. Und Zeit im Backend-Service bedeutet einen belegten Thread, eine belegte Verbindung, eine belegte Transaktion und potenziell eine ganze Kette von „warten-warten-warten“.
Und noch ein Punkt, der oft verwechselt wird: Das pessimistische Sperren ist nicht synchronized und kein „Monitor“ in Java. Es ist eine Sperre auf Datenbankebene, also auf Zeilenebene der Tabelle. Sie funktioniert sogar, wenn ihr zwei verschiedene Anwendungsinstanzen, zwei verschiedene JVMs und überhaupt „alles auf verschiedenen Maschinen verteilt“ habt. In diesem Sinne ist das DB-Lock eine fundamentalere Sache als Java-Sperren, weil die Datenbank der gemeinsame Punkt ist, an dem alle Transaktionen aufeinandertreffen.
4. @Lock in Spring Data
Jetzt bringen wir die Idee auf Code-Ebene. In Spring Data JPA arbeitet ihr normalerweise nicht direkt mit dem EntityManager, sondern über ein Repository. Und für die Anfrage „gib mir die Entität und setze ein DB-Lock darauf“ gibt es in Spring Data die Annotation @Lock. Wir können sie auf eine Repository-Methode setzen, und dann teilt Spring Data dem JPA-Provider (Hibernate) mit: „Diese Abfrage führe in diesem Lock-Modus aus.“
Im Rahmen des Tages benötigen wir genau einen Modus: LockModeType.PESSIMISTIC_WRITE. Er bedeutet „Ich benötige exklusiven Zugriff zum Ändern, konkurrierende Änderungen sollen warten“. In JPA-Begriffen ist dies ein Modus, der normalerweise in FOR UPDATE (für PostgreSQL) übersetzt wird.
So könnte ein Repository für StockItem in unserem Projekt shop-data-jpa aussehen:
import com.example.shopdatajpa.inventory.entity.StockItem;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
public interface StockItemRepository extends JpaRepository<StockItem, Long> {
// Bitten den JPA-Provider (Hibernate), ein DB-Lock beim Lesen zu nehmen.
// Für PostgreSQL wird dies normalerweise zu SELECT ... FOR UPDATE.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select s
from StockItem s
where s.id = :id
""")
Optional<StockItem> findForUpdate(@Param("id") Long id);
}
Einige wichtige Bedeutungen verstecken sich sogar im Namen findForUpdate. Das ist kein „einfaches find“. Es ist eine Methode, die dem Code-Leser explizit sagt: „Ich lese die Zeile so, dass sie für Änderungen gesperrt wird.“ Und das ist, ehrlich gesagt, die Hälfte des ingenieurtechnischen Erfolgs. Im Concurrency-Code ist der häufigste Fehler nicht technisch, sondern menschlich: Jemand hat die falsche Methode verwendet, weil „sie doch beide die Entität lesen“.
Beachtet, dass die Abfrage JPQL (über Entitäten) ist, aber die Sperre durchaus „basisnah“ auf Datenbankebene ist. ORM fungiert hier als Übersetzer: Ihr habt den Lock-Mode angefordert, Hibernate hat das SQL so gebildet, dass die Datenbank tatsächlich die gewünschte Sperre nimmt.
5. Pessimistisches Lock lebt in der Transaktion
Die schmerzhafteste Falle beim pessimistischen Sperren ist die Annahme, dass die Sperre am Objekt „klebt“. Tatsächlich lebt sie genau so lange, wie die DB-Transaktion lebt. Sobald die Transaktion abgeschlossen ist (commit/rollback), ist die Sperre aufgehoben. Daher macht eine Repository-Methode mit @Lock nur dann Sinn, wenn es eine Transaktion um sie herum gibt, die danach weiterläuft – also normalerweise eine Service-Methode mit @Transactional.
In unserem Projekt ist es logisch, dies im InventoryService (oder einem ähnlichen Use-Case-Service) zu tun. Zur Demonstration können wir eine solche Methode schreiben:
Die Reservierungsoperation selbst ändert sich dabei nicht: Im kanonischen StockItem.reserve(qty) transferieren wir die Menge ebenso von availableQuantity in reservedQuantity. Es ändert sich nur die Art und Weise, wie wir dieses Write-Szenario vor konkurrierenden Transaktionen schützen.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void reserveWithLock(Long stockItemId, int qty) {
// In diesem Moment nehmen wir das DB-Lock (PESSIMISTIC_WRITE) und halten es bis zum Ende der Transaktion.
StockItem item = stockItemRepository.findForUpdate(stockItemId).orElseThrow();
// Die eigentliche Domänenoperation bleibt gleich:
// reserve(qty) prüft den Bestand und transferiert die Menge zwischen available und reserved
item.reserve(qty);
}
Was in diesem Fragment wichtig ist, auch wenn es „zu einfach“ aussieht:
Erstens wird die Sperre beim Lesen mit findForUpdate(...) genommen. Wenn eine andere Transaktion dasselbe versucht, stößt sie auf eine Wartezeit. Das heißt, ihr erhaltet direkt im Systemverhalten eine „Warteschlange für Bestandsänderungen“.
Zweitens machen wir unter der Sperre nichts Überflüssiges. Keine HTTP-Aufrufe, keine Dateioperationen, kein „ach ja, lass uns schnell eine E-Mail senden“. Je länger ihr die Sperre haltet, desto mehr bremst ihr alle anderen aus. Die pessimistische Sperre ist wie eine besetzte Kasse im Supermarkt: Wenn der Kassierer Kaffee trinken geht, wird die Schlange nicht glücklicher.
Drittens ist die Sperre kein Ersatz für Invarianten. Selbst unter PESSIMISTIC_WRITE müsst ihr dennoch die Geschäftsregeln prüfen, selbst wenn innerhalb von item.reserve(qty). Die Sperre schützt vor konkurrierenden Änderungen, aber nicht vor logischen Fehlern innerhalb derselben Transaktion.
Und noch ein wichtiger Punkt aus unserem vorherigen Modul: flush() hebt die Sperre nicht auf. flush() sendet SQL an die Datenbank, aber die Transaktion läuft weiter, also bleibt die Sperre erhalten. Die Sperre wird erst bei commit oder rollback aufgehoben.
6. Optimistisch vs. pessimistisch: Vergleich
Damit der Unterschied nicht nur „Worte aus dem Lehrbuch“ bleibt, ist es nützlich, beide Ansätze am selben Szenario zu vergleichen: zwei parallele Änderungen an einem StockItem.
Konflikt beim optimistischen Sperren
Beim optimistischen Sperren können beide Transaktionen frei lesen. Der Konflikt wird spät sichtbar – beim Schreiben:
sequenceDiagram
%% Beide lesen dieselbe Version: Der Konflikt wird erst beim UPDATE sichtbar.
participant A as Tx A
participant DB as PostgreSQL
participant B as Tx B
A->>DB: "SELECT stock_item (version=5)"
B->>DB: "SELECT stock_item (version=5)"
A->>DB: "UPDATE ... WHERE id=42 AND version=5"
DB-->>A: "1 row updated (version becomes 6)"
B->>DB: "UPDATE ... WHERE id=42 AND version=5"
DB-->>B: "0 rows updated"
B-->>B: "OptimisticLockException"
Das Verhalten ist „schnell und parallel“, endet aber manchmal mit einer Ausnahme. Aus UX-Sicht kann das bedeuten: „Bitte versuchen Sie es erneut“ oder „Der Bestand hat sich geändert, aktualisieren Sie die Daten“.
Konkurrenz beim pessimistischen Sperren
Beim pessimistischen Sperren wird der Konflikt früh sichtbar – beim Lesen. Die zweite Transaktion erhält die Zeile nicht, bis die erste abgeschlossen ist:
sequenceDiagram
%% Hier wird der Konflikt zu einer Wartezeit: Der zweite SELECT ... FOR UPDATE „hängt“, bis die erste Transaktion COMMIT ausführt.
participant A as Tx A
participant DB as PostgreSQL
participant B as Tx B
A->>DB: "SELECT ... FOR UPDATE (id=42)"
DB-->>A: "row locked for write"
B->>DB: "SELECT ... FOR UPDATE (id=42)"
Note over B,DB: "B waits (row is locked)"
A->>DB: "UPDATE ..."
A->>DB: "COMMIT"
DB-->>B: "lock acquired, row returned"
Hier gibt es fast keine „Fehler“, aber es gibt Wartezeit. Wenn die Wartezeit kurz ist – alles gut. Wenn die Wartezeit lang ist – beginnt der Service zu „trödeln“, und unter Last wird daraus eine Warteschlange, die den Durchsatz töten kann.
Vergleichstabelle:
| Frage | Optimistisch (@Version) | Pessimistisch (@Lock(PESSIMISTIC_WRITE)) |
|---|---|---|
| Wann tritt der Konflikt auf? | Spät, bei flush/commit (bei UPDATE/DELETE) | Früh, bei SELECT ... FOR UPDATE |
| Wie äußert sich der Konflikt? | Ausnahme (OptimisticLockException) | Wartezeit (zweite Transaktion „hängt“) |
| Was macht die Datenbank? | Sperrt die Zeile nicht vorab | Sperrt die Zeile bis zum Ende der Transaktion |
| Preis des Fehlers | Man muss Fehler behandeln/wiederholen können | Man muss die Transaktionszeit kontrollieren, sonst „Warteschlange“ |
| Guter Standard für CRUD | Ja, oft beste Basisvariante | Nein, meist eine bewusste Ausnahme |
| Was spürt der Benutzer/API-Client | Manchmal „Konflikt, bitte wiederholen“ | Manchmal „lange Antwortzeit“, aber kein Konflikt |
Und hier wird deutlich: Optimistisches Sperren geht um ehrliche Prüfung, pessimistisches um Konfliktvermeidung durch Sperren.
In unserem Kurs und Projekt ist die Basiswahl das optimistische Sperren für StockItem mittels @Version, weil es das richtige Denken lehrt: Konflikte sind real, man darf sie nicht verstecken, und wir müssen sie entdecken. Den pessimistischen Ansatz behalten wir als Werkzeug, das nützlich zu wissen und punktuell einzusetzen ist, das aber gefährlich ist, wenn man es zum „Standard-Stil“ macht.
Für den Mini Shop belassen wir es dabei: Basis bleibt der versionierte StockItem mit optimistischem Sperren. Im Anwendungscode ist es wichtiger, nicht alles pauschal durch @Lock zu ersetzen, sondern den optimistischen Konflikt zu einem verständlichen Service-Ergebnis zu führen.
Nuance: Disziplin beim pessimistischen Sperren
Es gibt eine Feinheit, die die Erwartungen eines Anfängers zerstört (und genau deshalb sollte sie jetzt angesprochen werden, bevor alles im Chaos versinkt). Die pessimistische Sperre „schützt nicht vor allem“, wenn der konkurrierende Code anders arbeitet.
Stellt euch eine Situation vor: Transaktion A macht SELECT ... FOR UPDATE, ändert den Bestand und committet. Transaktion B hat beim Lesen keine Sperre genommen, sondern hat den Bestand einfach mit einem normalen SELECT gelesen, hat „10 - 4 = 6“ gerechnet und versucht dann, ein UPDATE durchzuführen. Ihr UPDATE wird zwar warten, bis A die Zeile freigibt. Aber sobald die Zeile freigegeben ist, schreibt B „6“. Das heißt, ein verlorenes Update kann erneut auftreten, weil B sich auf den alten Wert gestützt hat.
Daraus folgt eine praktische Regel: Wenn ihr die Strategie gewählt habt, das Szenario durch pessimistisches Sperren zu schützen, dann müsst ihr alle konkurrierenden Write-Szenarien schützen, nicht nur das eine „wichtigste“. Andernfalls erhaltet ihr eine unangenehme Mischung: Ein Teil der Abfragen wartet, ein Teil nicht, und das Ergebnis kann trotzdem seltsam sein.
In unserem Schulungsprojekt wird dieses Problem dadurch gemildert, dass StockItem bereits über @Version versioniert ist. Selbst wenn ihr irgendwo vergesst, eine pessimistische Sperre zu nehmen, kann die optimistische Versionsprüfung den Konflikt trotzdem abfangen. Aber das ist kein Grund, nachlässig zu sein – es ist ein Grund zu verstehen, was ihr tut: @Lock ist eine Möglichkeit, den konkurrierenden Zugriff vorab zu steuern, und kein „Superkleber über beliebigem Code“.
7. Typische Fehler beim Sperren
Fehler Nr. 1: @Lock als Java-Sperre (synchronized) auffassen.
Manchmal scheint es, dass @Lock(PESSIMISTIC_WRITE) ist „als ob wir die Methode synchronisiert hätten“. Nein: Es ist eine Zeilensperre in der Datenbank. Sie wirkt zwischen verschiedenen JVMs und sogar verschiedenen Maschinen, lebt aber ausschließlich innerhalb einer DB-Transaktion. Und sie schützt euren Java-Code nicht vor paralleler Ausführung – sie schützt die Daten in einer bestimmten Zeile.
Fehler Nr. 2: Die Lock-Methode des Repositories ohne Service-Transaktion aufrufen und denken, dass die Sperre „hält“.
Wenn es keine lange Transaktion gibt, wird die Sperre genommen und sofort beim Verlassen der Repository-Methode wieder freigegeben. Im besten Fall erhaltet ihr nicht den erwarteten Effekt, im schlimmsten Fall beginnt ihr, eine detached-Entität zu ändern, und wundert euch, warum die Änderungen nicht gespeichert wurden. Auf Architekturebene wird dies normalerweise mit einem Gedanken geheilt: Die Sperre ist Teil des Use Cases, also sollte sie auf der Service-Ebene unter @Transactional leben.
Fehler Nr. 3: Die Sperre zu lange halten, weil „es ja funktioniert“.
Die pessimistische Sperre gibt den Komfort „ohne Konflikte“, bezahlt aber mit Wartezeiten für andere Transaktionen. Wenn ihr unter der Sperre zusätzliche Arbeit erledigt, macht ihr die Datenbank zum Flaschenhals. Von außen sieht das aus wie „das API hängt manchmal“. Innen drin sieht es aus wie eine Warteschlange von Transaktionen, die auf denselben stock_item warten.
Fehler Nr. 4: Versuchen, alle Lesevorgänge „für alle Fälle“ pessimistisch zu machen.
Das ist eine klassische „defensive“ Reaktion: Da es verlorene Updates gibt, lasst uns alles sperren. In der Praxis führt das oft zu Leistungseinbußen und dazu, dass das System die Parallelität dort verliert, wo sie sicher gewesen wäre. Das optimistische Sperren existiert nicht aus Schönheitsgründen: Es erlaubt genau das – die meisten Operationen parallel laufen zu lassen und Konfliktsituationen gezielt zu erkennen und zu behandeln.
Fehler Nr. 5: Erwarten, dass beim pessimistischen Sperren der „Konflikt verschwindet“, und die Geschäftsinvarianten vergessen.
Die Sperre schützt vor konkurrierenden Schreibvorgängen, macht den Code aber nicht automatisch korrekt. Selbst unter FOR UPDATE kann man availableQuantity = availableQuantity - qty machen und einen negativen Wert erhalten, wenn man die Regeln nicht geprüft hat. Lock geht es um Konkurrenz, nicht um die Bedeutung der Daten.
GO TO FULL VERSION