Donnerstag, 16:30. Ein Kunde meldet sich beim Support: sein Kontostand zeigt -300€. Unmöglich, die App prüft vor jeder Abbuchung ob genug Guthaben da ist. Der Code ist simpel: lese Balance, prüfe ob Balance >= Betrag, buche ab. Kein Bug.
Trotzdem: -300€.
Was passiert ist: zwei Requests kamen gleichzeitig rein. Beide haben den Kontostand gelesen: 500€. Beide haben geprüft: 500 >= 400? Ja. Beide haben abgebucht. 500 - 400 - 400 = -300.
Kein Bug im Code. Der Bug sitzt eine Ebene tiefer: in der Datenbank-Konfiguration, die du nie angefasst hast.
Starte beide Transaktionen und schau dir an, was unter READ COMMITTED passiert — dann schalte auf SERIALIZABLE um:
Unter READ COMMITTED lesen beide Transaktionen denselben Wert. Beide prüfen. Beide buchen ab. -300€. Unter SERIALIZABLE wird Transaction B blockiert bis A fertig ist. B liest dann 100€, die Prüfung schlägt fehl, Balance bleibt korrekt.
Der Default, den niemand hinterfragt
READ COMMITTED ist der Default in PostgreSQL. Der beliebteste Isolation Level, in der beliebtesten Open-Source-Datenbank, für die meisten Anwendungen. Und er erlaubt genau das, was du gerade gesehen hast.
MySQL (InnoDB) hat als Default REPEATABLE READ, einen Level höher. Trotzdem: Lost Updates sind auch dort möglich wenn du nicht explizit lockst.
READ COMMITTED garantiert dir genau eine Sache: du liest keine Daten, die eine andere Transaktion noch nicht committed hat. Das ist alles. Zwischen deinem SELECT und deinem UPDATE kann eine andere Transaktion denselben Wert lesen, ändern und committen. Dein UPDATE überschreibt dann blind.
Vier Anomalien
Es gibt vier Arten, wie dir concurrent Transactions in die Quere kommen. Klick dich durch die Levels und schau, welche Anomalien wo möglich sind:
Dirty Read. Du liest Daten die noch nicht committed sind. Wenn die andere Transaktion ein Rollback macht, hast du Daten gelesen die nie existiert haben. Passiert nur bei READ UNCOMMITTED. Nutzt in der Praxis fast niemand.
Non-Repeatable Read. Du liest denselben Row zweimal und bekommst unterschiedliche Werte, weil eine andere Transaktion den Wert dazwischen geändert und committed hat.
Phantom Read. Du machst zweimal denselben SELECT und bekommst beim zweiten Mal mehr Rows. Eine andere Transaktion hat in der Zwischenzeit Rows eingefügt.
Lost Update. Das ist unser Balance-Problem. Zwei Transaktionen lesen denselben Wert, beide modifizieren basierend auf dem gelesenen Wert, der zweite Commit überschreibt den ersten.
Die Lücke schließen
Zwei Wege, den Lost Update zu verhindern. Erstens: SERIALIZABLE.
async function withdraw(accountId: string, amount: number) {
await db.transaction(async (tx) => {
// Unter SERIALIZABLE: blockiert wenn eine andere
// Transaktion denselben Row liest
const [{ balance }] = await tx.query(
'SELECT balance FROM accounts WHERE id = $1',
[accountId]
);
if (balance < amount) {
throw new Error('Insufficient funds');
}
await tx.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, accountId]
);
}, { isolationLevel: 'serializable' });
}
Funktioniert. Aber SERIALIZABLE ist teuer: die Datenbank muss potenzielle Konflikte tracken, und bei hoher Concurrency wirst du viele Serialization Failures sehen die du retrien musst.
Der pragmatischere Weg: SELECT FOR UPDATE.
async function withdraw(accountId: string, amount: number) {
await db.transaction(async (tx) => {
// FOR UPDATE: lockt den Row, andere Transaktionen
// warten bis wir committen
const [{ balance }] = await tx.query(
'SELECT balance FROM accounts WHERE id = $1 FOR UPDATE',
[accountId]
);
if (balance < amount) {
throw new Error('Insufficient funds');
}
await tx.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, accountId]
);
});
}
Selber Effekt, aber gezielter. Du lockst nur den Row den du brauchst, nicht die ganze Tabelle. Funktioniert auch unter READ COMMITTED. Kein Overhead für Rows die du nicht anfasst.
Es gibt auch Optimistic Locking: ein version-Feld im Row. Beim Update prüfst du WHERE version = $expectedVersion. Wenn jemand dazwischengefunkt hat, ist die Version anders und dein Update matcht 0 Rows. Retry. Gut wenn Konflikte selten sind.
Wann welches Level
Ich sag nicht: stell alles auf SERIALIZABLE. Dafür ist der Throughput-Hit zu groß. Stattdessen:
READ COMMITTED für die meisten Reads: wenn du Daten anzeigst, Reports baust, Logs schreibst. Concurrency ist hier kein Problem, weil du nichts basierend auf dem gelesenen Wert änderst.
SELECT FOR UPDATE für Read-Modify-Write Cycles: Kontostand prüfen und ändern, Inventar reservieren, alles wo du erst liest, dann entscheidest, dann schreibst. Das ist der häufigste Fall.
SERIALIZABLE wenn du komplexe Invarianten über mehrere Rows oder Tabellen sicherstellen musst — wenn SELECT FOR UPDATE nicht reicht, weil du nicht weißt welche Rows du locken musst. Selten, aber wenn du es brauchst, brauchst du es.
Ehrlich: die meisten Balance-Bugs kommen nicht von exotischen Race Conditions. Sie kamen davon, dass jemand SELECT balance gemacht hat, in der Applikation geprüft hat, und dann UPDATE balance = balance - amount ausgeführt hat — ohne Lock, ohne Transaction. Das ist kein Datenbank-Problem. Das ist ein “ich habe nie über Concurrency nachgedacht”-Problem.