Transaktionen und Race Conditions

Track Konzepte · Nebenläufigkeit und Datenbanken · ca. 50 Min.

Worum es geht

Zwei Requests ändern gleichzeitig denselben Datensatz. Beide lesen den alten Wert, beide schreiben ihr Ergebnis, einer überschreibt den anderen. Das nennt man Lost Update (verlorene Änderung), ein typischer Fall einer Race Condition (das Ergebnis hängt von der zufälligen Reihenfolge der Schritte ab). Solche Fehler tauchen nur unter Last auf, sind kaum reproduzierbar und kosten in Zahlungs- und Bestandssystemen echtes Geld. Am Ende der Lektion kannst du erklären, was eine Transaktion (transaction) garantiert, wo sie nicht reicht, und wie du ein Lost Update mit Lock, bedingtem Update oder Versionsnummer verhinderst. Außerdem lernst du, was passiert, wenn dieselbe Operation durch einen Retry zweimal ankommt (Idempotenz, idempotency).

Du siehst hier keine echten Threads. Wir simulieren verschachtelte Schritte (interleaving) mit Generatoren: Jedes yield ist ein Moment, in dem ein anderer Prozess drankommen darf. So ist der Fehler jedes Mal gleich reproduzierbar.

Von JS/TS her gedacht

Du denkst vielleicht: “JavaScript ist single-threaded, da gibt es keine Race Conditions.” Falsch. Sobald zwischen Lesen und Schreiben ein await liegt (Datenbank, HTTP), kann ein anderer Request dazwischenkommen. Der Event Loop schaltet an jedem await um.

const konto = { stand: 100 };
const pause = () => new Promise(r => setTimeout(r, 0));

async function einzahlen(betrag: number) {
  const alt = konto.stand;      // lesen
  await pause();                // I/O, z. B. DB-Aufruf: hier wird umgeschaltet
  konto.stand = alt + betrag;   // schreiben
}

await Promise.all([einzahlen(50), einzahlen(30)]);
console.log(konto.stand);       // 130, nicht 180

Das Beispiel wurde mit Node ausgeführt und gibt 130 aus. Das Konzept ist sprachunabhängig: Immer wenn “lesen, rechnen, schreiben” nicht als Einheit abläuft, geht etwas verloren.

Konzept JS/TS Python (hier simuliert)
Umschaltpunkt await yield im Generator
Gleichzeitig laufende Einheiten Promises, Requests Threads, Prozesse, Coroutines, Requests
Gemeinsamer Zustand Objekt, DB-Zeile Dict, DB-Zeile
Schutz im selben Prozess Mutex per Promise-Kette threading.Lock
Schutz über Prozessgrenzen DB-Transaktion, Versionsfeld DB-Transaktion, Versionsfeld

Konzept

Schritt 1: Der Simulator

Der Simulator nimmt Prozesse (Generator-Funktionen) und lässt sie abwechselnd je einen Schritt machen, bis zum nächsten yield. Ein Generator ist in Python eine Funktion, die an yield pausiert und später weitermacht, ähnlich wie ein async-Function an await. yield from andere() heißt: Laufe die andere Generator-Funktion Schritt für Schritt ab, inklusive ihrer Pausen (du brauchst das in Übung 2). SimLock ist eine Sperre (Lock, auch Mutex): Wer sie hält, lässt andere warten.

Schritt 2: Code, der kaputtgeht

Zwei Einzahlungen (50 und 30) auf ein Konto mit 100. Erwartet wären 180.

Ergebnis: 130. Die Einzahlung von 50 ist verloren, ohne Fehlermeldung. Der Ablauf:

sequenceDiagram
    participant A as Prozess A (+50)
    participant K as Konto
    participant B as Prozess B (+30)
    A->>K: lesen: 100
    B->>K: lesen: 100
    A->>K: schreiben: 150
    B->>K: schreiben: 130
    Note over K: Die 50 von A sind überschrieben

Ein Lost Update ist ein Spezialfall von Check-then-act (prüfen, dann handeln): Zwischen Prüfung und Aktion ändert sich die Welt. Das gleiche Muster gibt es bei “Guthaben prüfen, dann abbuchen”.

Schritt 3: Was eine Transaktion garantiert

Eine Transaktion bündelt mehrere Schritte. Die vier ACID-Eigenschaften:

  • Atomicity (Atomarität): alles oder nichts.
  • Consistency: Regeln der Datenbank (z. B. stand >= 0) bleiben erfüllt.
  • Isolation: parallele Transaktionen stören sich nicht (wie stark, bestimmt der Isolation Level).
  • Durability: Was committed ist, überlebt einen Absturz.

Atomarität live mit sqlite3 (läuft auch im Browser). Eine Überweisung bucht ab und schreibt gut. Stürzt der Code dazwischen ab, wird alles zurückgerollt (Rollback):

Ausgabe: [(1, 100), (2, 0)]. Die Abbuchung von 60 wurde zurückgenommen. Das ist Atomicity.

Schritt 4: Wo die Transaktion nicht reicht

Eine Transaktion verhindert das Lost Update nicht automatisch. Wenn du Wert A liest, in Python rechnest und B zurückschreibst, sind Lesen und Schreiben zwar in einer Transaktion, aber die Isolation hängt vom Isolation Level und der Datenbank ab (bitte prüfen, was deine Datenbank als Standard einstellt). Bei schwächeren Levels sind Anomalien wie Non-repeatable Read, Phantom und Write Skew möglich (Begriffe aus der Quelle, Schicht 5 fortgeschritten). Sicher ist, die Entscheidung in die Datenbank zu verlegen, in einem einzigen Statement:

Ausgabe: True False und 20. Prüfung und Änderung sind ein atomarer Schritt. Zum Vergleich der kaputte Weg (beide lesen 100, beide dürfen 80 abheben): Der Stand ist danach 20, obwohl 160 abgehoben wurden.

Drei Wege gegen Lost Update, jeder mit anderem Preis:

Weg Idee Preis
Pessimistic Locking (Lock, SELECT ... FOR UPDATE) Erst sperren, dann lesen und schreiben andere warten, Deadlock-Gefahr
Atomares Update (stand = stand - ?) Rechnen in der Datenbank nur für einfache Änderungen
Optimistic Concurrency (Versionsnummer) Schreiben nur, wenn sich seit dem Lesen nichts geändert hat, sonst Retry Konflikte kosten Wiederholungen, keine globale Sperre

Falle

  1. Ein Lock im Anwendungscode schützt nur diesen einen Prozess. Läuft deine App auf zwei Servern oder in mehreren Worker-Prozessen, sitzt jeder hinter seinem eigenen Lock. Der Konflikt muss dort gelöst werden, wo die Daten liegen: in der Datenbank.
  2. “Ich nutze eine Transaktion, also bin ich sicher” stimmt nur mit passender Isolation oder Sperre.
  3. Retries ohne Idempotenz (siehe Übung 5): Wenn ein Timeout kommt, weißt du nicht, ob die Operation lief. Wiederholst du blind, wird doppelt gebucht.

Idempotenz: dieselbe Operation zweimal

In verteilten Systemen gilt oft at-least-once (mindestens einmal): Nachrichten und Requests können mehrfach ankommen, weil Timeouts zu Retries führen. Eine Operation ist idempotent, wenn mehrfaches Ausführen dasselbe Ergebnis hat wie einmaliges. “Setze Stand auf 70” ist idempotent, “ziehe 30 ab” nicht. Für nicht idempotente Operationen schickt der Client einen Idempotency Key (eine eindeutige ID pro Vorhaben). Der Server merkt sich die Keys und führt dieselbe ID nur einmal aus.

Übungen

Übung 1: Ergebnis vorhersagen (ca. 5 Min.)

Das Konto hat 100. Prozess A zahlt 50 ein, Prozess B zahlt 30 ein, reihum ausgeführt. Welchen Wert hat konto["stand"] am Ende? Trage die Zahl ein, ohne den Code auszuführen.

Beide Prozesse lesen, bevor einer schreibt. Wer schreibt zuletzt, und was hatte er gelesen?

konto = {"stand": 100}

def einzahlen(betrag):
    def prozess():
        alt = konto["stand"]
        yield
        konto["stand"] = alt + betrag
    return prozess

lauf([einzahlen(50), einzahlen(30)])
antwort = 130
antwort

Beide lesen 100. A schreibt 150, B schreibt danach 100 + 30 = 130. Die 50 von A sind verloren.

Übung 2: Lost Update reparieren mit Lock (ca. 10 Min.)

Repariere einzahlen, sodass nie eine Einzahlung verloren geht. Benutze die Sperre sperre: erst yield from sperre.acquire(), am Ende sperre.release(). Das yield in der Mitte bleibt (es steht für die Zeit, die die Datenbank braucht). Die Prüfung testet 3 Einzahlungen mit reihum und 40 zufälligen Reihenfolgen.

Die Sperre muss vor dem Lesen genommen werden und erst nach dem Schreiben wieder frei sein. Der ganze Block “lesen, rechnen, schreiben” ist der kritische Abschnitt (critical section).

def einzahlen(konto, sperre, betrag):
    def prozess():
        yield from sperre.acquire()
        alt = konto["stand"]
        yield
        konto["stand"] = alt + betrag
        sperre.release()
    return prozess

einzahlen

Übung 3: Multiple Choice mit Begründung (ca. 8 Min.)

Ein Webshop läuft auf zwei Server-Prozessen hinter einem Load Balancer. Im Code steht:

with threading.Lock():                    # Lock im Programm
    stand = db.lies_stand(konto_id)
    if stand >= betrag:
        db.setze_stand(konto_id, stand - betrag)

Zwei Überweisungen vom selben Konto kommen gleichzeitig an, jede bei einem anderen Server. Welche Aussage stimmt?

  • A Das ist sicher, der Lock sorgt dafür, dass immer nur eine Überweisung rechnet.
  • B Das ist nicht sicher: Jeder Server hat seinen eigenen Lock, der Konflikt muss in der Datenbank gelöst werden.
  • C Das ist nicht sicher, aber ein größerer Server mit mehr CPU und RAM löst das Problem.
  • D Das ist sicher, sobald die Datenbank ACID unterstützt, denn dann ist jede Abfolge von Lesen und Schreiben automatisch isoliert.

Wo lebt ein threading.Lock? Und wer sieht ihn auf dem zweiten Server?

antwort = "B"
antwort

B stimmt. Ein threading.Lock gilt nur innerhalb eines Prozesses. Der zweite Server kennt ihn nicht. Die einzige Stelle, an der beide Requests zusammentreffen, ist die Datenbank. Dort löst du den Konflikt, z. B. mit einem bedingten UPDATE ... WHERE stand >= betrag oder SELECT ... FOR UPDATE in einer Transaktion.

Übung 4: Optimistic Concurrency mit Versionsnummer (ca. 12 Min.)

Die Tabelle konto hat die Spalten id, stand, version. Schreibe speichern: Es setzt den neuen Stand nur dann, wenn die Version in der Datenbank noch der gelesenen Version entspricht, und erhöht dabei die Version um 1. Rückgabe True bei Erfolg, False bei Konflikt. Danach darf der Client neu lesen und es erneut versuchen (Retry). Du brauchst nur ein SQL-Statement.

Die Version gehört in die WHERE-Bedingung des UPDATE, nicht in ein vorheriges SELECT. Woran siehst du danach, ob eine Zeile geändert wurde? Schau dir an, was cur.rowcount liefert. Und vergiss das commit nicht.

def speichern(con, konto_id, neuer_stand, gelesene_version):
    cur = con.execute(
        "UPDATE konto SET stand = ?, version = version + 1 WHERE id = ? AND version = ?",
        (neuer_stand, konto_id, gelesene_version))
    con.commit()
    return cur.rowcount == 1
speichern

Übung 5: Retry ohne Doppelbuchung (Idempotency Key, ca. 10 Min.)

Ein Client schickt “zahle 30”. Es kommt ein Timeout, also schickt er denselben Request noch einmal, mit derselben key. Schreibe zahle(konto, verarbeitet, key, betrag):

  • Ist key neu: betrag vom Stand abziehen, den neuen Stand merken (verarbeitet[key] = neuer_stand) und zurückgeben.
  • Ist key schon bekannt: nichts buchen, dieselbe Antwort wie beim ersten Mal zurückgeben.
  • Zwei verschiedene Vorhaben mit gleichem Betrag, aber verschiedenen Keys, sind zwei echte Zahlungen.

Der Key entscheidet, nicht der Betrag. Prüfe zuerst if key in verarbeitet.

def zahle(konto, verarbeitet, key, betrag):
    if key in verarbeitet:
        return verarbeitet[key]
    konto["stand"] -= betrag
    verarbeitet[key] = konto["stand"]
    return konto["stand"]
zahle

In einer echten Datenbank liegt der Key in einer Tabelle mit Unique Constraint, und Buchung und Key-Eintrag passieren in derselben Transaktion. Sonst entsteht die nächste Race Condition.

Merksatz

Immer wenn “lesen, entscheiden, schreiben” nicht als eine unteilbare Einheit abläuft, kann eine Änderung verloren gehen. Löse den Konflikt dort, wo die Daten liegen (Datenbank), und mache Retries mit einem Idempotency Key ungefährlich.

Prüfstein

Zwei Clients ändern denselben Datensatz, und du willst keine globale Sperre. Wie verhinderst du ein Lost Update, was passiert beim Verlierer, und warum brauchst du bei Retries zusätzlich Idempotenz?


Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitte “3. Nebenläufigkeit”, “5. Datenbanken”, “7. Verteilte Systeme und Betrieb” (Messaging, at-least-once); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 3 (Fehlerbilder Lost Update und Check-then-act, Optimistic Concurrency, Idempotency Keys) und Schicht 5 (Isolation und Anomalien, Concurrency Control). Die Aussage zum Standard-Isolation-Level einzelner Datenbanken steht nicht in den Quellen (bitte prüfen).