sequenceDiagram
participant A as Tx Dr1
participant D as Datenbank, Snapshot
participant B as Tx Dr2
A->>D: lies alle im Dienst, Ergebnis 2
B->>D: lies alle im Dienst, Ergebnis 2
A->>D: schreibe Dr1 frei, commit
B->>D: schreibe Dr2 frei, commit
Note over D: 0 im Dienst, Regel verletzt
Isolation Levels, Anomalien und MVCC: Write Skew selbst erzeugen
Track Konzepte · Datenbanken und Nebenläufigkeit · ca. 50 Min.
Worum es geht
In Lektion 01 hast du das Lost Update gesehen: zwei Transaktionen überschreiben dieselbe Zeile. Es gibt gefährlichere Fehler, bei denen jede Transaktion für sich korrekt ist und trotzdem eine Regel bricht. Der bekannteste heißt Write Skew (schiefes Schreiben): Zwei Transaktionen lesen dieselben Daten, prüfen dieselbe Regel, schreiben aber verschiedene Zeilen. Kein Schreibkonflikt, keine Fehlermeldung, die Regel ist trotzdem kaputt. Welche Anomalien (anomalies) ein System zulässt, bestimmt der Isolation Level. Heutige Datenbanken lösen das meist mit MVCC (Multi-Version Concurrency Control): Statt Zeilen zu überschreiben, legen sie neue Versionen an, und jede Transaktion sieht die Version, die zu ihrem Snapshot passt.
Am Ende von Teil 1 kannst du vier Anomalien an einem reproduzierbaren Szenario vorhersagen, die Sichtbarkeit von MVCC-Versionen ausrechnen und Write Skew selbst erzeugen. Die Simulation baut auf dem Generator-Simulator aus Lektion 01 auf. Wie du Write Skew verhinderst und was Deadlocks sind, folgt in Teil 2.
Von JS/TS her gedacht
Write Skew entsteht auch ohne Datenbank, überall wo “prüfen, dann schreiben” über einen await hinweg läuft. Regel: Von zwei Ärzten muss mindestens einer im Bereitschaftsdienst bleiben. Beide melden sich gleichzeitig ab:
const aerzte: Record<string, boolean> = { anna: true, ben: true };
const pause = () => new Promise(r => setTimeout(r, 0));
async function abmelden(name: string) {
const imDienst = Object.values(aerzte).filter(Boolean).length; // 1. prüfen
if (imDienst >= 2) {
await pause(); // I/O: der andere kommt dran
aerzte[name] = false; // 2. schreiben
}
}
await Promise.all([abmelden("anna"), abmelden("ben")]);
console.log(Object.values(aerzte).filter(Boolean).length); // 0Das Beispiel wurde mit Node ausgeführt (als JavaScript, die Typen sind nur Annotationen) und gibt 0 aus. Es ist kein Lost Update: Anna schreibt anna, Ben schreibt ben, keiner überschreibt den anderen. Verletzt ist eine Regel über beide Zeilen.
| Begriff | JS/TS | Datenbank (hier simuliert) |
|---|---|---|
| Umschaltpunkt | await |
yield im Generator |
| Alte Version behalten | Objekt kopieren (structuredClone) |
MVCC: Versionsliste pro Zeile |
| Eigene Sicht auf die Daten | Kopie zum Startzeitpunkt | Snapshot der Transaktion |
| Konflikt beim Speichern | Versionsfeld im Request (ETag) | First committer wins, Serialisierungsfehler |
Konzept
Schritt 1: Der Werkzeugkasten
Die Zelle enthält eine kleine Datenbank (MiniDB) mit Transaktionen (Tx) und den Simulator aus Lektion 01. Du musst sie nicht auswendig kennen, lies die Kommentare. Neu gegenüber Lektion 01: SimLock.acquire gibt nach dem Nehmen der Sperre kurz ab (das Nehmen dauert), und die Sperre merkt sich ihren halter.
Zur Erinnerung, wie lauf arbeitet: Jeder Prozess ist ein Generator, jedes yield ist ein Umschaltpunkt. Ohne seed ruft lauf reihum bei jedem Prozess einmal next auf (Runde 1: alle bis zu ihrem ersten yield, Runde 2: alle bis zum zweiten, und so weiter). Mit seed wählt es die Reihenfolge zufällig, aber wiederholbar. commit bei serializable bricht ab, wenn sich nach dem Start der Transaktion etwas geändert hat, das sie gelesen hat (gelesen und scans merken sich das). Mehr musst du von Tx nicht wissen.
Die vier Isolation Levels in MiniDB, vom schwächsten zum stärksten:
| Level | Was die Transaktion beim Lesen sieht |
|---|---|
read_uncommitted |
auch fremde, noch nicht committete Änderungen |
read_committed |
nur Committetes, aber bei jedem Lesen den neuesten Stand |
snapshot |
nur Committetes, und zwar den Stand beim Start der Transaktion |
serializable |
wie snapshot, plus eine Prüfung beim Commit, die Write Skew abfängt |
Schritt 2: MVCC, jede Zeile hat Versionen
Schreiben überschreibt nichts, es hängt eine neue Version mit dem Commit-Zeitpunkt an. Eine Transaktion liest die neueste Version, die zu ihrem Snapshot passt. Deshalb blockieren Leser bei MVCC keine Schreiber und umgekehrt.
Ausgabe: [(0, 100), (1, 200)], dann 100, dann 200. Alte Versionen werden in echten Systemen später aufgeräumt (Stichwort Vacuum, bitte prüfen, wie deine Datenbank es nennt).
Schritt 3: Non-repeatable Read und Phantom
Vier klassische Anomalien:
- Dirty Read: Du liest eine Änderung, die noch nicht committet ist (und vielleicht zurückgerollt wird).
- Non-repeatable Read: Du liest dieselbe Zeile zweimal und bekommst verschiedene Werte, weil dazwischen jemand committet hat.
- Phantom: Du fragst “alle Zeilen, die X erfüllen” zweimal und beim zweiten Mal ist eine neue Zeile dabei.
- Write Skew: Siehe unten.
Non-repeatable Read, einmal mit read_committed und einmal mit snapshot. Prozess A liest zweimal den Lagerbestand, Prozess B setzt dazwischen einen neuen Wert und committet:
Ausgabe: [10, 7] und [10, 10]. Beim ersten Level sieht A denselben Wert zweimal verschieden (Non-repeatable Read), beim zweiten bleibt sein Snapshot stabil.
Ein Phantom ist dasselbe für eine Menge: A zählt die Ärzte im Dienstplan, B fügt einen hinzu.
Ausgabe: [1, 2] [1, 1]. Merke: Ein Snapshot schützt vor Non-repeatable Read und Phantom beim Lesen. Das heißt nicht, dass er vor allem schützt. Den Dirty Read zeigt dir hier noch kein Beispiel: read_uncommitted sieht fremde, noch offene Änderungen, auch wenn der andere sie später zurückrollt. Übung 1 lässt dich genau das vorhersagen.
Schritt 4: Write Skew, die Anomalie, die Snapshot nicht sieht
Zurück zu den Ärzten. Jede Transaktion liest die Menge “wer ist im Dienst”, prüft die Regel und schreibt ihre eigene Zeile:
Ausgabe: 0 und beide Ärzte im_dienst: False. Der Ablauf:
Warum merkt snapshot nichts? Es prüft beim Commit nur Schreib-Schreib-Konflikte auf derselben Zeile (first committer wins). Dr1 und Dr2 schreiben verschiedene Zeilen. Der Konflikt liegt zwischen dem, was gelesen, und dem, was geschrieben wurde. Mit read_committed passiert dasselbe. Der Prüfstein der Quelle fragt genau danach: Was ist Write Skew, wie reproduzierst du ihn, und welche Maßnahmen verhindern ihn? Das Reproduzieren machst du in Übung 2, die Maßnahmen folgen in Teil 2.
Schritt 5: Zwei parallele Überweisungen vom selben Konto
Der Prüfstein der Grundlagen: Was garantiert eine Transaktion, und was passiert bei zwei parallelen Überweisungen vom selben Konto? Eine Transaktion garantiert ACID (siehe Lektion 01), aber die Isolation hängt vom Level ab. Beide Überweisungen lesen 100 und buchen 80 ab:
Ausgabe: (20, ['ok', 'ok']) und (20, ['ok', 'abgewiesen']). Mit read_committed wurden 160 ausgezahlt, aber nur 80 abgezogen (beide schreiben 100 - 80): ein Lost Update. Mit snapshot verliert der zweite Committer (first committer wins) und muss wiederholen. Das ist der Unterschied zwischen “Transaktion benutzt” und “Transaktion schützt”.
Falle
- Snapshot ist nicht Serializable. In vielen Datenbanken heißt eine Stufe “Repeatable Read” und ist tatsächlich Snapshot Isolation, die Write Skew zulässt (bitte prüfen, wie deine Datenbank ihre Level umsetzt). Wer “Transaktion mit hohem Level” sagt und nie geprüft hat, was der Level verhindert, hat Write Skew in Produktion.
- “Ich benutze eine Transaktion” heißt nicht “ich bin geschützt”. Bei
read_committedverlieren zwei parallele Überweisungen vom selben Konto einen Abzug, obwohl beide in einer Transaktion laufen.
Übungen
Übung 1: Anomalien vorhersagen (ca. 8 Min.)
Die Funktion szenario ist wie lager_szenario, nur kann B wählen, ob er committet oder zurückrollt. Der Ablauf reihum, Runde für Runde: In Runde 1 beginnt A, B beginnt und schreibt x = 200. In Runde 2 liest A (a1), B pausiert. In Runde 3 pausiert A, B committet oder rollt zurück. In Runde 4 liest A (a2). Trage für jedes der drei Szenarien das Paar (a1, a2) ein, ohne den Code auszuführen.
Frage dich bei jedem Szenario zwei Dinge: Was darf A in Runde 2 sehen, obwohl B noch nicht fertig ist? Und was sieht A in Runde 4, nachdem B fertig ist? Bei snapshot zählt der Stand beim Start von A.
antwort = (
(200, 100), # read_uncommitted, B macht rollback
(100, 200), # read_committed, B macht commit
(100, 100), # snapshot, B macht commit
)
antwortSzenario 1 ist ein Dirty Read: A sah die 200 von B, die nie committet wurden. Szenario 2 ist ein Non-repeatable Read: Derselbe Lesebefehl liefert beim zweiten Mal etwas anderes. Szenario 3: Der Snapshot von A bleibt stabil.
Übung 2: Write Skew selbst erzeugen (ca. 12 Min.)
Ein Ehepaar hat zwei Konten, giro und spar. Die Bank erlaubt, dass ein einzelnes Konto ins Minus rutscht, aber die Summe beider Konten darf nie unter 0 fallen. Jede Abhebung ist eine eigene Transaktion mit Level snapshot.
Schreibe abheben(db, konto, betrag), einen Prozess (Generator) nach diesem Muster:
- Beide Konten lesen (
tx.lies("giro"),tx.lies("spar")). - Nur wenn die Summe nach der Abhebung noch
>= 0ist:yield(das Prüfen der Bank dauert), dannbetragvom eigenen Konto abziehen und committen. - Sonst nichts tun.
Danach zeigt die Prüfung, was bei zwei gleichzeitigen Abhebungen mit Start 60 und 60 passiert.
Die Regel betrifft die Summe beider Konten, geschrieben wird aber nur ein Konto. Wo muss der Umschaltpunkt sitzen, damit der andere Prozess zwischen Prüfung und Schreiben drankommt? Und bei einem Prozess allein muss die Regel trotzdem greifen.
def abheben(db, konto, betrag):
def prozess():
tx = db.begin("snapshot")
summe = tx.lies("giro") + tx.lies("spar")
if summe - betrag >= 0:
yield
tx.schreibe(konto, tx.lies(konto) - betrag)
tx.commit()
return prozess
abhebenBeide sehen Summe 120, beide dürfen 100 abheben. Giro landet bei -40, Spar bei -40, die Summe bei -80. Es gibt keinen Schreibkonflikt, weil verschiedene Konten geschrieben werden. Dasselbe Muster wie bei den Ärzten.
Übung 3: Welche Version sieht wer? (ca. 7 Min.)
Ein Preis wird zweimal geändert, und vier Transaktionen lesen ihn zu verschiedenen Zeitpunkten. Der Ablauf (Zeile für Zeile, die Uhr der Datenbank zählt die Commits):
db = MiniDB({"preis": 50})
ta = db.begin("snapshot") # Uhr 0
tb = db.begin("read_committed") # Uhr 0
tc = db.begin("snapshot")
tc.schreibe("preis", 60)
tc.commit() # Uhr 1
td = db.begin("snapshot") # Snapshot bei Uhr 1
te = db.begin("snapshot") # Snapshot bei Uhr 1
td.schreibe("preis", 70)
td.commit() # Uhr 2Trage ein, was jetzt jeweils lies("preis") liefert: ta, tb, te und eine neue snapshot-Transaktion (in dieser Reihenfolge). Der fünfte Wert ist die Anzahl der Versionen, die db.versionen["preis"] jetzt enthält.
Frage bei jeder Transaktion: Wann wurde ihr Snapshot gezogen, oder liest sie bei jedem Lesen den aktuellen Stand? Und: Was hängt ein Commit an, und was nicht?
antwort = (50, 70, 60, 70, 3)
antwortta hat seinen Snapshot bei Uhr 0 und sieht 50. tb ist read_committed und sieht bei jedem Lesen den neuesten Stand, also 70. te hat den Snapshot bei Uhr 1 (nach dem Commit von tc, vor dem von td) und sieht 60. Die neue Transaktion startet bei Uhr 2 und sieht 70. Es gibt drei Versionen (50, 60, 70), denn jeder Commit hängt eine an.
Merksatz
Snapshot Isolation verhindert Dirty Read, Non-repeatable Read und Phantom beim Lesen, aber nicht Write Skew: Wer eine Regel über mehrere Zeilen prüft und nur eine davon schreibt, bricht sie, ohne dass die Datenbank es merkt.
Prüfstein
Was garantiert eine Transaktion, und was passiert bei zwei parallelen Überweisungen vom selben Konto? Was ist Write Skew und wie reproduzierst du ihn?
Weiter mit Teil 2: Maßnahmen gegen Write Skew und Deadlocks.
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “5. Datenbanken” (Transaktionen: ACID, Isolation Levels, MVCC; Prüfstein zu parallelen Überweisungen); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 5 “Isolation und Anomalien” (Dirty Read, Non-repeatable Read, Phantom, Write Skew; Snapshot Isolation) und “Concurrency Control” (MVCC).
Über die Quelle hinaus (allgemeines Fachwissen, MiniDB ist ein vereinfachtes Lehrmodell, kein Abbild einer echten Datenbank): die genaue Bedeutung der vier Anomalien, die Zuordnung Level zu Anomalie, First-committer-wins, “Repeatable Read” als Snapshot Isolation in manchen Datenbanken (bitte prüfen), Vacuum (bitte prüfen).