Datenbanktransaktionen und Isolationsstufen erklärt
„Pack es einfach in eine Transaktion" ist ein verbreiteter Rat, aber eine Transaktion allein garantiert nicht, dass sich zwei nebenläufige Operationen nicht in die Quere kommen – das hängt von der Isolationsstufe ab, und die Standardstufe der meisten Datenbanken ist schwächer, als man annimmt. Wer das falsch macht, landet bei einem Bug, der nur unter echter Produktionslast auftaucht, nie in einer Einzelnutzer-Entwicklungsumgebung.
Was eine Transaktion tatsächlich garantiert
Eine Transaktion gibt dir Atomarität (alle ihre Schreibvorgänge passieren, oder keiner) und Dauerhaftigkeit (einmal committet, übersteht sie einen Absturz). Diese beiden stehen selten infrage. Was sich stark unterscheidet, ist die Isolation: wie viel der laufenden Arbeit einer Transaktion für eine andere, gleichzeitig laufende Transaktion sichtbar ist.
Isolation wird als Spektrum ausgedrückt. Schwächere Isolation bedeutet mehr Nebenläufigkeit und besseren Durchsatz, aber mehr Wege, auf denen nebenläufige Transaktionen einander stören können. Stärkere Isolation bedeutet weniger Überraschungen, aber mehr Sperren und Konkurrenz.
Die drei klassischen Anomalien
Bevor die Isolationsstufen Sinn ergeben, braucht man die Probleme, nach denen sie benannt sind:
Dirty Read – Transaktion A liest eine Zeile, die Transaktion B geschrieben, aber noch nicht committet hat. Rollt B danach zurück, hat A Daten gelesen, die es nie wirklich gab.
Non-repeatable Read – Transaktion A liest eine Zeile zweimal und bekommt beide Male unterschiedliche Werte, weil B dazwischen eine Änderung an dieser Zeile committet hat.
Phantom Read – Transaktion A führt dieselbe Bereichsabfrage zweimal aus (z. B. WHERE amount > 100) und bekommt beim zweiten Mal eine andere Menge von Zeilen, weil B dazwischen eine passende Zeile eingefügt oder gelöscht hat.
Die Standard-Isolationsstufen
SQL definiert vier Stufen, die jeweils mehr des Obigen verhindern:
- Read Uncommitted – verhindert nichts. Dirty Reads sind möglich. PostgreSQL implementiert diese Stufe nicht eigenständig (sie verhält sich wie Read Committed); InnoDB von MySQL schon.
- Read Committed – verhindert Dirty Reads. Man sieht immer nur committete Daten, aber ein Wert kann sich zwischen zwei Lesevorgängen derselben Transaktion trotzdem ändern. Das ist der Standard in PostgreSQL, Oracle und SQL Server.
- Repeatable Read – verhindert Dirty Reads und Non-repeatable Reads. Hat die eigene Transaktion eine Zeile einmal gelesen, sieht sie für den Rest der Transaktion denselben Wert, auch wenn eine andere Transaktion eine Änderung daran committet. Das ist der Standard in InnoDB von MySQL. Phantom Reads sind laut SQL-Standard technisch weiterhin möglich, wobei die PostgreSQL-Implementierung (die Snapshot-Isolation verwendet) sie tatsächlich ebenfalls verhindert.
- Serializable – verhindert alle drei. Transaktionen verhalten sich, als liefen sie nacheinander in irgendeiner Reihenfolge, auch wenn sie tatsächlich nebenläufig liefen.
Wie Read Committed zubeißt
Zwei Transaktionen prüfen beide einen Kontostand, bevor sie abbuchen:
-- Transaction A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- reads 100
-- ...application logic decides it's safe to withdraw 80...
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
COMMIT;
-- Transaction B, running concurrently
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- also reads 100
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
COMMIT;
Lesen beide Transaktionen den Kontostand, bevor eine von beiden committet, glauben beide, es sei genug Geld da. Unter Read Committed blockiert das zweite UPDATE tatsächlich, bis die erste Transaktion committet (Sperre auf Zeilenebene beim Schreiben), und wird dann gegen den aktuellen Kontostand von 20 angewendet – aus dem reinen SQL entsteht also kein negativer Kontostand. Hat deine Anwendung den Kontostand aber in einer Abfrage gelesen und „ist 80 <= Kontostand" im Anwendungscode geprüft, bevor sie ein separates Update abgesetzt hat, liegt genau in dieser Prüfen-dann-Handeln-Folge die Race Condition, und Read Committed schützt sie in keiner Weise.
Die Lösung: explizites Sperren oder eine stärkere Isolationsstufe
Option 1: SELECT ... FOR UPDATE – die gelesene Zeile explizit sperren, sodass eine nebenläufige Transaktion, die dieselbe Zeile liest, blockiert, bis man committet.
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- application logic
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
COMMIT;
Option 2: Die Isolationsstufe anheben und bei Konflikt wiederholen.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1;
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
COMMIT;
Serializable sperrt nicht proaktiv – es erkennt Konflikte beim Commit und bricht eine der konfligierenden Transaktionen mit einem Serialisierungsfehler ab. Die Anwendung muss das abfangen und wiederholen:
async function withdrawSerializable(db, accountId, amount) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
await db.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
const { rows } = await db.query('SELECT balance FROM accounts WHERE id = $1', [accountId]);
if (rows[0].balance < amount) throw new Error('insufficient funds');
await db.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, accountId]);
await db.query('COMMIT');
return;
} catch (err) {
await db.query('ROLLBACK');
if (err.code !== '40001') throw err; // not a serialization failure — don't retry
}
}
throw new Error('could not complete withdrawal after retries');
}
Option 3: Eine einzelne atomare Anweisung, die das gesamte Lesen-dann-Schreiben-Race umgeht:
UPDATE accounts SET balance = balance - 80
WHERE id = 1 AND balance >= 80;
-- check affected row count; 0 means the guard failed
Das ist meist die beste Option, wenn sie passt – kein explizites Sperren, keine Retry-Logik, kein Tuning der Isolationsstufe. Sie ist nicht immer anwendbar (manchmal muss man wirklich lesen, nichttriviale Logik ausführen und dann schreiben), aber wenn ein einzelnes UPDATE ... WHERE die ganze Operation ausdrücken kann, ist es einfacher und schneller als jede Alternative.
Eine Stufe in der Praxis wählen
Greife nicht überall zu Serializable – es bringt Retry-Komplexität mit und senkt den Durchsatz unter Konkurrenz. Ein vernünftiger Standard:
- Read Committed (oder der Standard deiner Datenbank) für die meisten Operationen.
- Explizites Zeilensperren (
FOR UPDATE) für Lesen-dann-Schreiben-Folgen auf einer bestimmten Zeile, wie im Kontostand-Beispiel. - Serializable für Operationen, bei denen die Korrektheit wirklich davon abhängt, dass die Transaktion über mehrere Anweisungen hinweg einen konsistenten Snapshot sieht – Finanzbuchhaltung, Lagerbestände, alles, wo ein Phantom oder Non-repeatable Read ein falsches geschäftliches Ergebnis liefern würde.
- Eine einzelne atomare Anweisung, wann immer sich die Operation als eine ausdrücken lässt, unabhängig von der Isolationsstufe, denn sie beseitigt das Race-Fenster vollständig.
Die Isolationsstufe ist ein Kompromiss-Regler, kein „Sicherheit an"-Schalter – und der richtige Wert hängt davon ab, vor welchem konkreten Race man sich in einem bestimmten Stück Code fürchtet, nicht von einer pauschalen Einstellung für die ganze Anwendung.