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);      // 0

Das 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:

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

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

  1. 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.
  2. “Ich benutze eine Transaktion” heißt nicht “ich bin geschützt”. Bei read_committed verlieren 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
)
antwort

Szenario 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:

  1. Beide Konten lesen (tx.lies("giro"), tx.lies("spar")).
  2. Nur wenn die Summe nach der Abhebung noch >= 0 ist: yield (das Prüfen der Bank dauert), dann betrag vom eigenen Konto abziehen und committen.
  3. 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
abheben

Beide 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 2

Trage 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)
antwort

ta 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).