Optimistisches vs. pessimistisches Locking in Datenbanken
Zwei Anfragen lesen dieselbe Zeile, beide berechnen aus dem Gelesenen einen neuen Wert, und beide schreiben ihn zurück – der zweite Schreibvorgang überschreibt den ersten stillschweigend, als wäre er nie passiert. Das ist ein Lost Update, und es ist der Grund, warum nebenläufiger Datenbankzugriff irgendeine Form von Sperren braucht. Die beiden Standardstrategien, optimistisch und pessimistisch, setzen gegensätzlich darauf, wie oft Konflikte tatsächlich auftreten.
Das Lost Update konkret
Angenommen, du verringerst beim Checkout den Lagerbestand.
-- request A reads stock = 5
SELECT stock FROM products WHERE id = 42;
-- request B reads stock = 5, at nearly the same time
SELECT stock FROM products WHERE id = 42;
-- both compute 5 - 1 = 4 and write it back
UPDATE products SET stock = 4 WHERE id = 42;
UPDATE products SET stock = 4 WHERE id = 42;
Zwei Einheiten verkauft, aber der Bestand ist nur um eins gesunken. Keine Abfrage ist fehlgeschlagen, keine Transaktion zurückgerollt – die Daten sind einfach falsch. Sperren gibt es, damit eine dieser beiden Anfragen auf die andere warten muss oder an ihr scheitert.
Pessimistisches Locking: Konflikte werden passieren
Pessimistisches Locking holt eine Sperre auf die Zeile, bevor man sie anfasst, und hält sie bis zum Ende der Transaktion, wodurch jede andere Transaktion blockiert wird, die dieselbe Sperre will.
BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
-- stock is now locked until this transaction commits or rolls back
UPDATE products SET stock = stock - 1 WHERE id = 42;
COMMIT;
FOR UPDATE (Postgres, MySQL/InnoDB und die meisten anderen relationalen Engines unterstützen dies oder ein Äquivalent) sagt der Datenbank: Jede andere Transaktion, die diese Zeile mit FOR UPDATE lesen oder aktualisieren will, muss warten, bis diese hier fertig ist. Anfrage B oben reiht sich einfach hinter Anfrage A ein, statt mit ihr zu rennen.
Das ist leicht nachzuvollziehen – ein Lost Update ist ausgeschlossen, Punkt –, hat aber seinen Preis: Jede gesperrte Zeile ist ein Konfliktpunkt. Hält dein Checkout diese Sperre über einen langsamen Zahlungs-API-Aufruf hinweg, warten alle anderen, die dasselbe Produkt kaufen wollen, ebenfalls darauf.
Optimistisches Locking: Konflikte sind selten
Optimistisches Locking sperrt vorab nichts. Stattdessen versieht es jede Zeile mit einer Versionsmarkierung und prüft beim Schreiben, dass sich die Version nicht geändert hat.
-- add a version column
ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 0;
-- read the row along with its current version
SELECT stock, version FROM products WHERE id = 42;
-- suppose stock = 5, version = 7
-- write back conditioned on the version being unchanged
UPDATE products
SET stock = 4, version = 8
WHERE id = 42 AND version = 7;
Prüfe die Anzahl betroffener Zeilen des UPDATE. Ist sie 1, war dein Schreibvorgang erfolgreich und niemand hat die Zeile dazwischen angefasst. Ist sie 0, hat jemand anderes die Zeile zwischen deinem Lesen und deinem Schreiben aktualisiert (und ihre Version erhöht) – deine Daten sind veraltet, und die Anwendung muss entscheiden, was zu tun ist: neu lesen und wiederholen oder dem Nutzer einen Konflikt anzeigen.
async function decrementStock(productId) {
const { stock, version } = await db.one(
'SELECT stock, version FROM products WHERE id = $1',
[productId],
);
const result = await db.result(
'UPDATE products SET stock = $1, version = $2 WHERE id = $3 AND version = $4',
[stock - 1, version + 1, productId, version],
);
if (result.rowCount === 0) {
throw new ConflictError('Product was modified concurrently, retry');
}
}
Nie wird eine Sperre über Anwendungslogik, Netzwerkaufrufe oder Bedenkzeit des Nutzers hinweg gehalten. Konkurrenz wird erst im Moment des Schreibens sichtbar und nur, wenn tatsächlich ein Konflikt aufgetreten ist.
Zwischen beiden wählen
- Pessimistisch, wenn Konflikte häufig und billig zu serialisieren sind – wenige heiß umkämpfte Zeilen (der Bestand eines einzelnen beliebten Produkts, ein gemeinsamer Zähler, ein Sitzplatz in einem Buchungssystem), bei denen man Anfragen lieber warten lässt, als sie wiederholen zu lassen.
- Optimistisch, wenn Konflikte selten sind und die Arbeit zwischen Lesen und Schreiben aufwendig oder langlaufend ist – ein Dokument bearbeiten, ein Nutzerprofil aktualisieren, alles, wo ein Mensch zwischen Lesen und Schreiben ein Formular ausfüllt. Eine Datenbanksperre für die Dauer von „der Nutzer überlegt" zu halten ist nicht tragbar.
Eine nützliche Faustregel: Wenn du um einen Netzwerkaufruf, eine UI-Interaktion oder irgendetwas, das keine knappe Datenbanktransaktion ist, herum sperrst, willst du mit ziemlicher Sicherheit stattdessen optimistisches Locking.
Den Konflikt gut behandeln
Ein Update mit 0 Zeilen beim optimistischen Locking ist kein Fehlerzustand, den man verschluckt – es ist Information. Drei gängige Wege, damit umzugehen:
- Wiederholen: die Zeile neu lesen, die Änderung erneut anwenden, den Schreibvorgang noch einmal versuchen. Funktioniert gut für einfache numerische Updates wie die Bestandsverringerung oben, da das erneute Lesen einen frischen, korrekten Ausgangspunkt liefert.
- Zusammenführen: Ist das Update komplexer (ein Dokument mit mehreren Feldern bearbeiten), neu lesen, die nicht konfligierenden Teile zusammenführen und dann wiederholen.
- Anzeigen: Bei nutzersichtbaren Änderungen dem Nutzer sagen „das wurde von jemand anderem geändert, seit du es geladen hast" und ihn entscheiden lassen, statt seine Arbeit stillschweigend zu überschreiben oder stillschweigend zu verwerfen.
Was man nie tun sollte, ist, den Fall mit null betroffenen Zeilen abzufangen und einfach weiterzumachen – das ist wieder das Lost Update, nur mit zusätzlichen Schritten.
Fazit
Pessimistisches Locking verhindert Konflikte, indem nebenläufige Zugriffe warten müssen, bis sie dran sind; optimistisches Locking erkennt Konflikte im Nachhinein und überlässt der Anwendung die Reaktion. Keines ist universell besser – es kommt darauf an, wie oft man Kollisionen erwartet und wie teuer es ist, eine Anfrage warten zu lassen statt zu wiederholen.