Das nimmst du mit
- Anomalien unterscheiden
- Optimistic Locking einsetzen
- Deadlocks reproduzieren
- Retry sicher begrenzen
Kapitelkompass
Isolation entscheidet, welche Zwischenzustände andere Transaktionen sehen und wie Konflikte behandelt werden.
Zwei Bearbeiter aktualisieren dieselbe Bestellung in unterschiedlicher Reihenfolge.
Optimistisch bei seltenen Konflikten; pessimistisch nur bei begründeter hoher Konkurrenz.
Blindes Retry kann fachliche Duplikate erzeugen und Deadlocks nur zeitlich verschieben.
Locking wird in Projekten oft zu spät betrachtet, weil es nach Datenbankdetail klingt. In Wahrheit entscheidet Locking darüber, ob fachliche Zusagen stimmen: ob ein Lagerbestand wirklich reserviert ist, ob eine Rechnung nur einmal erzeugt wird, ob eine Zahlung nicht doppelt bestätigt wird und ob zwei Support-Mitarbeiter dieselbe Bestellung gleichzeitig widersprüchlich ändern können.
In Kapitel ging es um Transaktionsgrenzen. Kapitel geht eine Ebene tiefer: Was passiert, wenn mehrere Transaktionen gleichzeitig auf dieselben Daten zugreifen? Ohne klares Modell entstehen Fehler, die im Unit Test selten auffallen, im Betrieb aber sehr teuer werden.
Merksatz
Eine Transaktion schützt nicht automatisch vor falscher Konkurrenzlogik. Sie sorgt für Atomarität innerhalb einer Grenze. Konsistenz bei parallelen Zugriffen braucht zusätzlich passende Isolation, Locking, Versionen und fachliche Konfliktbehandlung.
Inventory Reservation zeigt, warum Locking nicht abstrakt bleiben darf.
Lost Update ist einer der wichtigsten Konkurrenzfehler. Zwei Transaktionen lesen denselben alten Zustand, berechnen beide einen neuen Zustand und schreiben nacheinander zurück. Die spätere Transaktion überschreibt die frühere, ohne zu merken, dass ihre Grundlage veraltet war.
Die visuelle Abfolge zeigt, warum der finale Datenbankwert plausibel aussehen kann, obwohl die fachliche Wahrheit falsch ist.
Die folgende Klasse ist absichtlich schlecht. Sie wird im Lab benutzt, um den Fehler sichtbar zu machen. Der Fehler liegt nicht darin, dass die Klasse kurz ist, sondern darin, dass der Snapshot beim Commit nicht mehr validiert wird.
NaiveReservationSession.java - bewusstes Gegenbeispiel
package com.example.lockdeepdive.inventory;
// Pattern: Gegenbeispiel - diese Klasse zeigt bewusst den Lost-Update-Fehler.
public final class NaiveReservationSession {
private final InventoryTable table;
private final StockSnapshot snapshot;
private Integer plannedQuantity;
public NaiveReservationSession(InventoryTable table, StockSnapshot snapshot) {
this.table = table;
this.snapshot = snapshot;
}
public boolean planReservation(int requested) {
if (requested <= 0) throw new IllegalArgumentException("requested must be positive");
if (snapshot.quantity() < requested) return false;
plannedQuantity = snapshot.quantity() - requested;
return true;
}
public void commitWithoutVersionCheck() {
if (plannedQuantity == null) throw new IllegalStateException("nothing planned");
table.writeWithoutVersionCheck(snapshot.productId(), plannedQuantity);
}
public StockSnapshot snapshot() {
return snapshot;
}
}
| Situation | Technisch sichtbar | Fachlicher Schaden |
|---|---|---|
| beide Transaktionen committen | kein Fehler | Bestand überverkauft |
| letzter Writer gewinnt | finale Zahl wirkt plausibel | erste Reservierung ist unsichtbar verloren |
| nachgelagerte Events gesendet | Broker meldet OK | Reporting und Kundennachricht widersprechen Lager |
Optimistic Locking basiert auf der Annahme: Konflikte sind möglich, aber nicht der Normalfall. Deshalb hält das System beim Lesen keine lange Sperre. Stattdessen wird beim Schreiben geprüft, ob sich die Version seit dem Lesen verändert hat.
Versionierte Updates machen veraltete Entscheidungen sichtbar.
Optimistic Locking als SQL-Mentalmodell
UPDATE inventory
SET quantity = ?, version = version + 1
WHERE product_id = ?
AND version = ?;
0 rows updated -> jemand war schneller; Konflikt behandeln
1 row updated -> Commit kann fortgesetzt werden
Der wichtige Punkt ist nicht nur der Version Check. Der Use Case muss nach einem Konflikt den neuen Zustand lesen und die fachliche Regel erneut prüfen. Ein technischer Retry ohne fachliche Neubewertung ist gefährlich.
OptimisticInventoryService.java
package com.example.lockdeepdive.inventory;
import com.example.lockdeepdive.shared.*;
// Pattern: Application Service - kapselt Use-Case-Regeln und technische Konfliktbehandlung.
public final class OptimisticInventoryService {
private final InventoryTable table;
private final int maxRetries;
public OptimisticInventoryService(InventoryTable table, int maxRetries) {
this.table = table;
this.maxRetries = maxRetries;
}
public ReservationResult reserve(ProductId productId, int requested) {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
StockSnapshot current = table.read(productId);
if (current.quantity() < requested) {
return new ReservationResult.Rejected(productId, requested, current.quantity());
}
int newQuantity = current.quantity() - requested;
try {
StockSnapshot saved = table.writeIfVersionMatches(productId, current.version(), newQuantity);
return new ReservationResult.Reserved(productId, requested, saved.quantity(), saved.version());
}
catch (OptimisticLockException conflict) {
if (attempt == maxRetries) {
return new ReservationResult.Conflict(productId, conflict.getMessage());
}
// In echter Produktion: kleiner Backoff + Metrik + erneute fachliche Pruefung.
}
}
return new ReservationResult.Conflict(productId, "retry loop exhausted");
}
}
Isolation beschreibt, welche Zwischeneffekte paralleler Transaktionen sichtbar sind. Sie ist nicht nur Performance-Einstellung, sondern beeinflusst fachliche Aussagen wie „Ich habe gerade geprüft, dass genug Bestand vorhanden ist“ oder „Diese Rechnung wurde noch nicht erzeugt“.
Die Matrix fasst typische Phänomene zusammen.
Read Committed verhindert, dass uncommittete Änderungen anderer Transaktionen gelesen werden. Aber dieselbe Query kann innerhalb einer Transaktion später ein anderes Ergebnis liefern, wenn inzwischen jemand anderes committed hat.
Repeatable Read stabilisiert bereits gelesene Zeilen innerhalb derselben Transaktion. Das hilft bei wiederholten Prüfungen, löst aber nicht automatisch alle Bereichsprobleme, insbesondere abhängig von Datenbank und konkreter Abfrageform.
Serializable ist das strengste Modell. Es versucht, parallele Ausführungen so wirken zu lassen, als wären sie nacheinander passiert. Dafür können mehr Sperren, mehr Konflikte oder Abbrüche entstehen. In Enterprise-Systemen nutzt man es gezielt, nicht reflexartig überall.
SimulatedTransaction.java - Read Committed vs Repeatable Read im Lab
package com.example.lockdeepdive.isolation;
import java.util.*;
// Pattern: Unit of Work Mental Model - Transaktion merkt sich je nach Isolation gelesene Snapshots.
public final class SimulatedTransaction {
private final AccountTable table;
private final IsolationLevel isolationLevel;
private final Map<AccountId, AccountSnapshot> repeatableReadCache = new HashMap<>();
public SimulatedTransaction(AccountTable table, IsolationLevel isolationLevel) {
this.table = table;
this.isolationLevel = isolationLevel;
}
public AccountSnapshot read(AccountId id) {
if (isolationLevel == IsolationLevel.REPEATABLE_READ) {
return repeatableReadCache.computeIfAbsent(id, table::committedRead);
}
return table.committedRead(id);
}
public IsolationLevel isolationLevel() {
return isolationLevel;
}
}
| Phänomen | Bedeutung | Beispiel |
|---|---|---|
| Dirty Read | Lesen uncommitteter Daten | Order sieht Payment-Status, der später rollbackt |
| Non-repeatable Read | dieselbe Zeile liefert später anderen Wert | Support liest Order zweimal und sieht verschiedene Stati |
| Phantom Read | Bereichsabfrage liefert neue/fehlende Zeilen | Batch findet beim zweiten Scan zusätzliche Rechnungen |
| Lost Update | Write überschreibt anderen Write | Inventory-Reservierung überschreibt parallele Reservierung |
Pessimistic Locking basiert auf der Annahme: Konflikte sind wahrscheinlich oder teuer. Deshalb wird der kritische Datensatz gesperrt, bevor die fachliche Entscheidung getroffen und gespeichert wird. Das kann korrekt und notwendig sein - aber nur mit sehr kurzen Transaktionen.
Pessimistische Sperren müssen so kurz wie möglich gehalten werden.
Pessimistic Locking in SQL-Denkweise
BEGIN;
SELECT quantity, version
FROM inventory
WHERE product_id = 'SKU-1'
FOR UPDATE;
-- jetzt: pruefen, reduzieren, Outbox schreiben
COMMIT;
RowLockManager.java
package com.example.lockdeepdive.locking;
import com.example.lockdeepdive.shared.ProductId;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
// Pattern: Lock Manager - simuliert DB-Zeilensperren mit Timeout.
public final class RowLockManager {
private final ConcurrentMap<ProductId, ReentrantLock> locks = new ConcurrentHashMap<>();
public Guard lock(ProductId productId, Duration timeout) {
ReentrantLock lock = locks.computeIfAbsent(productId, id -> new ReentrantLock());
try {
if (!lock.tryLock(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
throw new LockTimeoutException("timeout while waiting for lock on " + productId);
}
return new Guard(productId, lock);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockTimeoutException("interrupted while waiting for lock on " + productId);
}
}
public record Guard(ProductId productId, ReentrantLock lock) implements AutoCloseable {
@Override public void close() {
lock.unlock();
}
}
}
PessimisticInventoryService.java
package com.example.lockdeepdive.locking;
import com.example.lockdeepdive.inventory.*;
import com.example.lockdeepdive.shared.*;
import java.time.Duration;
// Pattern: Application Service + Pessimistic Lock - kurze kritische Sektion, keine externen API-Aufrufe im Lock.
public final class PessimisticInventoryService {
private final InventoryTable table;
private final RowLockManager locks;
private final Duration timeout;
public PessimisticInventoryService(InventoryTable table, RowLockManager locks, Duration timeout) {
this.table = table;
this.locks = locks;
this.timeout = timeout;
}
public ReservationResult reserve(ProductId productId, int requested) {
try (RowLockManager.Guard ignored = locks.lock(productId, timeout)) {
StockSnapshot current = table.read(productId);
if (current.quantity() < requested) {
return new ReservationResult.Rejected(productId, requested, current.quantity());
}
StockSnapshot saved = table.writeIfVersionMatches(productId, current.version(), current.quantity() - requested);
return new ReservationResult.Reserved(productId, requested, saved.quantity(), saved.version());
}
catch (LockTimeoutException timeout) {
return new ReservationResult.Conflict(productId, timeout.getMessage());
}
}
}
Produktionsregel
Eine Sperre schützt den kritischen Zustand. Sie darf nicht benutzt werden, um beliebig lange Nebenwirkungen bequem in einer Transaktion zu halten.
Ein Deadlock entsteht, wenn Transaktionen zyklisch auf Ressourcen warten. Klassisch: Transaktion A hält Lock 1 und wartet auf Lock 2, Transaktion B hält Lock 2 und wartet auf Lock 1. Ohne Timeout oder Deadlock Detection kann keine der beiden fortfahren.
Der Wait-for Graph macht den Kreis sichtbar.
DeadlockSimulator.java
package com.example.lockdeepdive.deadlock;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
// Pattern: Deterministic Concurrency Test Helper - erzwingt gegensaetzliche Lock-Reihenfolge reproduzierbar.
public final class DeadlockSimulator {
private final ReentrantLock productOne = new ReentrantLock();
private final ReentrantLock productTwo = new ReentrantLock();
public DeadlockSimulationResult simulateOppositeLockOrder(Duration timeout) {
CountDownLatch bothHoldFirstLock = new CountDownLatch(2);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<Boolean> first = executor.submit(() -> worker(productOne, productTwo, bothHoldFirstLock, timeout));
Future<Boolean> second = executor.submit(() -> worker(productTwo, productOne, bothHoldFirstLock, timeout));
boolean firstTimedOut = first.get(5, TimeUnit.SECONDS);
boolean secondTimedOut = second.get(5, TimeUnit.SECONDS);
return new DeadlockSimulationResult(firstTimedOut, secondTimedOut,
"opposite lock order created a wait cycle; timeout broke at least one worker");
}
catch (Exception e) {
throw new IllegalStateException("deadlock simulation failed", e);
}
finally {
executor.shutdownNow();
}
}
private boolean worker(ReentrantLock first, ReentrantLock second, CountDownLatch latch, Duration timeout) throws Exception {
first.lock();
try {
latch.countDown();
if (!latch.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("test setup failed");
boolean acquiredSecond = second.tryLock(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!acquiredSecond) return true;
try {
return false;
}
finally {
second.unlock();
}
}
finally {
first.unlock();
}
}
}
| Maßnahme | Warum sie hilft | Grenze |
|---|---|---|
| feste Lock-Reihenfolge | verhindert Kreise im Wait-for Graph | muss in allen Codepfaden gelten |
| kurze Transaktionen | reduziert Wartezeit und Konfliktfenster | braucht klare Use-Case-Schnitte |
| Lock Timeout | System bleibt reaktionsfähig | Fehler muss fachlich/technisch behandelt werden |
| Retry mit Backoff | transiente Konflikte können verschwinden | darf keine doppelten Nebenwirkungen erzeugen |
| gute Indizes | Datenbank sperrt weniger unnötige Zeilen | Query-Pläne müssen beobachtet werden |
Retry ist kein magischer Korrektheitsmechanismus. Ein Retry ist nur dann sicher, wenn der Use Case idempotent ist oder Nebenwirkungen sauber isoliert sind. Bei Optimistic Locking muss der neue Zustand geladen und die fachliche Regel neu bewertet werden.
Die Entscheidung nach einem Konflikt ist fachlich, nicht nur technisch.
Faustregel
Retry darf eine technische Kollision überbrücken. Retry darf keine fachliche Entscheidung erzwingen, die nach dem neuen Zustand nicht mehr gültig ist.
Dieses Kapitel-Lab bleibt JDK-only, aber die Mechanik entspricht
typischen JPA/Hibernate-Fragen. @Version ist das
Standardwerkzeug für Optimistic Locking. Pessimistic Locking wird über
Lock Modes oder Query-Hints angestoßen. Isolation kommt weiterhin aus
Datenbank und Transaktionskonfiguration.
JPA @Version als Optimistic-Locking-Werkzeug
@Entity
class InventoryItemEntity {
@Id String productId;
int quantity;
@Version
long version;
}
-- Hibernate-Update enthaelt typischerweise eine Versionsbedingung.
| Lock Mode | Gedanke | Einsatz |
|---|---|---|
| OPTIMISTIC | Konflikt beim Commit/Flush erkennen | Benutzer- und Aggregate-Updates |
| OPTIMISTIC_FORCE_INCREMENT | Version bewusst erhöhen | fachliche Reservierung/Anspruch sichtbar machen |
| PESSIMISTIC_WRITE | Schreibkonflikte durch Sperre verhindern | hochkonkurrierte Ressourcen |
| PESSIMISTIC_READ | Änderung durch andere erschweren | spezielle Lesekonsistenzfälle |
Concurrency-Fehler sind schwer, wenn Tests zufällig sind. Das Lab nutzt deshalb deterministische Simulationen: veraltete Snapshots werden kontrolliert erzeugt, Isolation wird über eine kleine Transaktionsklasse modelliert, Deadlock wird über Latches und Timeouts reproduzierbar gemacht.
Kapitel2TestRunner.java - Szenario-Tests
package com.example.lockdeepdive;
import com.example.lockdeepdive.deadlock.*;
import com.example.lockdeepdive.inventory.*;
import com.example.lockdeepdive.isolation.*;
import com.example.lockdeepdive.locking.*;
import com.example.lockdeepdive.shared.*;
import java.time.Duration;
public final class Kapitel2TestRunner {
public static void main(String[] args) {
lostUpdateIsVisibleWithoutVersionCheck();
optimisticLockDetectsStaleWrite();
optimisticServiceRechecksBusinessRuleAfterConflict();
pessimisticLockSerializesCriticalSection();
readCommittedAllowsNonRepeatableRead();
repeatableReadKeepsStableSnapshot();
deadlockIsBrokenByTimeout();
System.out.println("Kapitel2_TESTS_OK");
}
static void lostUpdateIsVisibleWithoutVersionCheck() {
ProductId sku = ProductId.of("SKU-1");
InventoryTable table = new InventoryTable();
table.insert(sku, 10);
NaiveReservationSession txA = new NaiveReservationSession(table, table.read(sku));
NaiveReservationSession txB = new NaiveReservationSession(table, table.read(sku));
assertTrue(txA.planReservation(7), "txA can reserve 7");
assertTrue(txB.planReservation(6), "txB can reserve 6 based on stale snapshot");
txA.commitWithoutVersionCheck();
txB.commitWithoutVersionCheck();
assertEquals(4, table.quantity(sku), "last writer wins although 13 items were promised from stock 10");
}
static void optimisticLockDetectsStaleWrite() {
ProductId sku = ProductId.of("SKU-2");
InventoryTable table = new InventoryTable();
table.insert(sku, 10);
StockSnapshot a = table.read(sku);
StockSnapshot b = table.read(sku);
table.writeIfVersionMatches(sku, a.version(), 3);
assertThrows(OptimisticLockException.class, () -> table.writeIfVersionMatches(sku, b.version(), 4),
"stale writer must be rejected");
}
static void optimisticServiceRechecksBusinessRuleAfterConflict() {
ProductId sku = ProductId.of("SKU-3");
InventoryTable table = new InventoryTable();
table.insert(sku, 10);
OptimisticInventoryService service = new OptimisticInventoryService(table, 3);
ReservationResult first = service.reserve(sku, 7);
ReservationResult second = service.reserve(sku, 6);
assertTrue(first.success(), "first reservation succeeds");
assertTrue(!second.success(), "second reservation is rejected after reread because only 3 remain");
}
static void pessimisticLockSerializesCriticalSection() {
ProductId sku = ProductId.of("SKU-4");
InventoryTable table = new InventoryTable();
table.insert(sku, 10);
PessimisticInventoryService service = new PessimisticInventoryService(table, new RowLockManager(), Duration.ofMillis(500));
ReservationResult first = service.reserve(sku, 6);
ReservationResult second = service.reserve(sku, 5);
assertTrue(first.success(), "first reservation succeeds");
assertTrue(!second.success(), "second reservation sees updated remaining stock and fails fachlich");
assertEquals(4, table.quantity(sku), "stock remains consistent");
}
static void readCommittedAllowsNonRepeatableRead() {
AccountId account = AccountId.of("A-1");
AccountTable table = new AccountTable();
table.insert(account, 100);
SimulatedTransaction tx = new SimulatedTransaction(table, IsolationLevel.READ_COMMITTED);
int first = tx.read(account).balance();
table.update(account, 150);
int second = tx.read(account).balance();
assertEquals(100, first, "first read");
assertEquals(150, second, "second read sees newer committed value");
}
static void repeatableReadKeepsStableSnapshot() {
AccountId account = AccountId.of("A-2");
AccountTable table = new AccountTable();
table.insert(account, 100);
SimulatedTransaction tx = new SimulatedTransaction(table, IsolationLevel.REPEATABLE_READ);
int first = tx.read(account).balance();
table.update(account, 150);
int second = tx.read(account).balance();
assertEquals(100, first, "first read");
assertEquals(100, second, "repeatable read keeps snapshot stable inside transaction");
}
static void deadlockIsBrokenByTimeout() {
DeadlockSimulationResult result = new DeadlockSimulator().simulateOppositeLockOrder(Duration.ofMillis(200));
assertTrue(result.deadlockWasBrokenByTimeout(), "timeout should break wait cycle");
}
static void assertTrue(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static void assertEquals(int expected, int actual, String message) {
if (expected != actual) throw new AssertionError(message + ": expected=" + expected + ", actual=" + actual);
}
static void assertThrows(Class<? extends Throwable> type, Runnable action, String message) {
try {
action.Kapitel();
}
catch (Throwable t) {
if (type.isInstance(t)) return;
throw new AssertionError(message + ": wrong exception " + t);
}
throw new AssertionError(message + ": expected exception " + type.getSimpleName());
}
}
Teststrategie
Concurrency-Tests sollen nicht beweisen, dass Threads immer gleich laufen. Sie sollen kritische Reihenfolgen gezielt erzwingen und dadurch das fachliche Risiko erklären.
Locking-Probleme werden oft erst im Betrieb sichtbar. Deshalb gehören Metriken, Logging und Datenbankbeobachtung zur Lösung. Wer nur Code schreibt, aber keine Konflikte misst, sieht die wichtigsten Signale zu spät.
Fehlende Indizes können Locking drastisch verschlechtern. Wenn eine Datenbank für eine Änderung viele Zeilen scannen muss, sperrt sie oft mehr und länger als fachlich erwartet. Deshalb gehört Query-Plan-Prüfung zu Locking-Design.
| Check | Frage |
|---|---|
| Transaktionsdauer | Wie lange bleibt die Transaktion offen? |
| Lock-Reihenfolge | Greifen alle Codepfade in derselben Reihenfolge zu? |
| Timeouts | Gibt es klare Obergrenzen? |
| Retry | Ist Retry idempotent und begrenzt? |
| Indizes | Findet die DB gezielt die betroffenen Zeilen? |
| User Experience | Kann die UI Konflikte verständlich erklären? |
Das Code-Lab ist bewusst ohne externe Datenbank gebaut. Dadurch kann man die Mechanik lesen, kompilieren und ausführen. Es ersetzt keine echte Datenbanktests, aber es macht die Ursache der Fehler sichtbar.
Projektstruktur
code/locking-isolation-deep-dive-lab/
├── src/main/java/com/example/lockdeepdive/
│ ├── inventory/ Lost Update, Optimistic Locking
│ ├── isolation/ Read Committed vs Repeatable Read
│ ├── locking/ RowLockManager, Pessimistic Locking
│ ├── deadlock/ deterministische Deadlock-Simulation
│ └── demo/ Demo Runner
├── src/test/java/ Kapitel2TestRunner
└── docs/ Design Patterns und ADRs
Befehle im Lab-Ordner
javac --release 21 -d target/classes $(find src/main/java src/test/java -name "*.java")
java -cp target/classes com.example.lockdeepdive.demo.Kapitel2Demo
java -cp target/classes com.example.lockdeepdive.Kapitel2TestRunner
Weiter
Kapitel vertieft Outbox, Idempotenz, Retry und Crash-Fälle. Dort wird aus Locking-Korrektheit eine robuste Integrationsstrategie.