Webentwicklung

Datenbanktransaktionen für kleine Webanwendungen — Mehrstufige Schreibvorgänge als Einheit

Datenbanktransaktionen für kleine Webanwendungen — Mehrstufige Schreibvorgänge als Einheit

Eine kleine Webanwendung kann zwei Datenbankanweisungen hunderte Male erfolgreich ausfuehren und trotzdem eine ernsthafte Luecke im Design haben. Was geschieht, wenn die erste Anweisung erfolgreich ist, die zweite aber fehlschlaegt? Eine Bestellung koennte ohne ihre Positionen existieren, der Bestand koennte ohne Bestellung sinken oder ein Zahlungsdatensatz koennte ohne den zugehoerigen Buchungseintrag gespeichert werden. Jede einzelne Abfrage kann gueltig sein, obwohl das Gesamtergebnis es nicht ist.

Eine Datenbanktransaktion behandelt genau dieses Problem, indem sie zusammengehoerige Arbeit abgrenzt. Innerhalb dieser Grenze fordert die Anwendung die Datenbank auf, entweder alle Aenderungen gemeinsam dauerhaft zu machen oder alle abzubrechen. Das klingt einfach, doch Transaktionen sinnvoll einzusetzen erfordert mehr, als beliebiges SQL mit beginTransaction() und commit() zu umschliessen.

Die hilfreiche Frage lautet nicht: "Wie viele Abfragen?"

Eine Transaktion wird oft als Buendel von Anweisungen beschrieben, doch deren Anzahl ist nur ein Hinweis. Die hilfreichere Frage lautet: Welche Aenderungen repraesentieren eine einzige geschaeftliche Entscheidung?

Das Erstellen einer Bestellung und ihrer Positionen kann eine solche Entscheidung sein. Bestand zu reservieren und diese Reservierung zu erfassen, kann ebenfalls eine sein. Ein Profil zu aktualisieren und ein optionales Analytics-Ereignis zu schreiben, ist vermutlich keine, denn der Verlust des Analytics-Ereignisses sollte die Profilaktualisierung nicht zwingend ablehnen.

Das PostgreSQL-Tutorial zu Transaktionen beschreibt das grundlegende Modell: Mehrere Schritte werden zu einer Alles-oder-nichts-Operation, und ihre Zwischenzustaende werden nicht als abgeschlossene Arbeit sichtbar. Obwohl die Beispiele PostgreSQL verwenden, gilt dieses Grundmodell auch fuer Transaktionen in anderen relationalen Datenbanken. Details unterscheiden sich weiterhin zwischen Anbietern, weshalb die Dokumentation von Datenbank und Client wichtig bleibt.

Diese Grenze ist eine Designentscheidung. Eine Transaktion kann nicht bestimmen, welche Operationen zusammengehoeren. Das muss zuerst die Anwendung festlegen.

Autocommit verbirgt die Luecke zwischen Anweisungen

PDO-Verbindungen arbeiten normalerweise im Autocommit-Modus, wie das PHP-Handbuch erklaert. Auch MariaDB dokumentiert Autocommit als standardmaessig aktiviert. Praktisch bedeutet das: Eine erfolgreiche Anweisung wird dauerhaft, ohne auf die naechste Anweisung der Anwendung zu warten.

Dieses Verhalten ist fuer einen einzelnen, unabhaengigen Schreibvorgang sinnvoll. Riskant wird es, wenn zwei oder mehr Schreibvorgaenge entweder vollstaendig oder gar nicht stattfinden muessen. Betrachten wir diese Abfolge ohne explizite Transaktion:

  1. Den verfuegbaren Bestand verringern.
  2. Die Bestellung erstellen.
  3. Die Bestellposition erstellen.

Wenn Schritt zwei oder drei fehlschlaegt, macht Autocommit Schritt eins nicht rueckgaengig. Der Anwendungscode kann eine ausgleichende Aktualisierung versuchen, doch damit entsteht eine weitere Operation, die ebenfalls fehlschlagen kann, und ein weiterer Nebenlaeufigkeitsfall, der bedacht werden muss.

Eine explizite Transaktion veraendert die Abfolge. beginTransaction() oeffnet die Grenze, commit() bestaetigt sie und rollBack() verwirft ihre noch nicht bestaetigten Datenbankaenderungen. MariaDB beschreibt die entsprechenden SQL-Operationen in der Dokumentation zu START TRANSACTION.

Ein kleines PDO-Muster

Das folgende Beispiel dient der Veranschaulichung und stammt nicht aus einem Produktivsystem. Es setzt voraus, dass $pdo eine bestehende PDO-Verbindung ist, die beteiligten MariaDB-Tabellen eine transaktionsfaehige Storage Engine verwenden und IDs sowie Menge bereits an der Anwendungsgrenze validiert wurden.

<?php
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$orderId = $validatedOrderId;
$productId = $validatedProductId;
$quantity = $validatedQuantity;

try {
    $pdo->beginTransaction();

    $reserve = $pdo->prepare(
        'UPDATE products
         SET stock = stock - :quantity
         WHERE id = :product_id AND stock >= :quantity'
    );
    $reserve->execute([
        'quantity' => $quantity,
        'product_id' => $productId,
    ]);

    if ($reserve->rowCount() !== 1) {
        throw new RuntimeException('Product unavailable or stock insufficient');
    }

    $createOrder = $pdo->prepare(
        'INSERT INTO orders (id, status) VALUES (:id, :status)'
    );
    $createOrder->execute([
        'id' => $orderId,
        'status' => 'pending',
    ]);

    $createItem = $pdo->prepare(
        'INSERT INTO order_items (order_id, product_id, quantity)
         VALUES (:order_id, :product_id, :quantity)'
    );
    $createItem->execute([
        'order_id' => $orderId,
        'product_id' => $productId,
        'quantity' => $quantity,
    ]);

    $pdo->commit();
} catch (Throwable $error) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    throw $error;
}

Hier wirken zwei unterschiedliche Schutzmechanismen. Die Transaktion verhindert, dass nur ein Teil der Datenbankschreibvorgaenge dauerhaft wird. Die Bedingung stock >= :quantity schuetzt eine Geschaeftsinvariante direkt in der Bestandsaktualisierung. Wird keine Zeile aktualisiert, lehnt der Code den Vorgang ab und fuehrt einen Rollback aus.

Diese beiden Ideen zu trennen ist wichtig. Eine Transaktion kann eine falsche Berechnung atomar bestaetigen. Atomaritaet bedeutet, dass die ausgewaehlten Operationen zusammenbleiben; sie beweist nicht, dass die ausgewaehlte Regel korrekt ist.

Nebenlaeufigkeit braucht weiterhin bewusste Regeln

Transaktionen werden manchmal so behandelt, als machten sie jede Lesen-dann-Schreiben-Abfolge automatisch sicher. Das ist zu pauschal. Angenommen, eine Anwendung liest zuerst einen Bestandswert, entscheidet in PHP, dass er ausreicht, und sendet spaeter eine bedingungslose Aktualisierung. Eine andere Transaktion kann dasselbe Produkt zwischen diesen Operationen veraendern. Das genaue Ergebnis haengt von Anweisungen, Indizes, Sperren und Isolationsstufe ab.

Die bedingte Aktualisierung oben vermeidet einen eng begrenzten Wettlauf, indem sie Pruefung und Verringerung in einer Datenbankanweisung zusammenfasst. Andere Ablaeufe koennen einen sperrenden Lesezugriff wie SELECT ... FOR UPDATE, einen Unique Constraint, optimistische Versionspruefung oder ein anderes Datenmodell benoetigen. Das passende Werkzeug haengt von der zu schuetzenden Invariante ab.

MariaDB unterstuetzt mehrere Isolationsstufen und dokumentiert REPEATABLE READ in der Referenz zu SET TRANSACTION als InnoDB-Standard. Eine staerker klingende Isolationsstufe ist nicht automatisch die bessere globale Einstellung: Sie veraendert Sichtbarkeit und Sperrverhalten und kann nuetzliche Parallelitaet verringern. Es ist sicherer, zuerst die Anomalie zu bestimmen, die ein Ablauf verhindern muss, bevor die Stufe geaendert wird.

Was eine Datenbanktransaktion nicht zurueckrollen kann

Die Datenbankgrenze endet an der Datenbank. Wenn Anwendungscode vor dem Commit eine E-Mail sendet, eine Zahlungs-API aufruft, eine Nachricht veroeffentlicht oder in ein anderes unabhaengiges System schreibt, kann ein spaeterer Datenbank-Rollback diesen externen Seiteneffekt nicht zurueckrufen.

Bei einem einfachen Ablauf kann die Anwendung eine unkritische Aktion erst nach einem erfolgreichen Commit ausfuehren. Fuer eine Zustellung, die Abstuerze und Wiederholungen ueberstehen muss, kann ein dauerhaftes Outbox-Muster die beabsichtigte Nachricht in derselben Datenbanktransaktion speichern und sie von einem separaten Worker zustellen lassen. Trotzdem muessen Regeln fuer Wiederholungen und Duplikate entworfen werden; das Wort "Transaktion" macht einen Netzwerkaufruf nicht zu Exactly-once.

Transaktionen ersetzen auch keine Constraints. Ein Foreign Key, Unique Constraint oder geeigneter CHECK Constraint kann Daten schuetzen, wenn Schreibvorgaenge ueber einen anderen Codepfad eintreffen. Anwendungsvalidierung hilft Benutzern, waehrend Datenbank-Constraints Invarianten naeher an den gespeicherten Daten verteidigen. Diese Schichten behandeln verwandte, aber unterschiedliche Probleme.

Vier Fallstricke, die eine Pruefung verdienen

1. Der Speicherpfad muss Transaktionen wirklich unterstuetzen

PDO kann Transaktionsmethoden bereitstellen, waehrend eine zugrunde liegende Laufzeitbedingung oder Tabellen-Engine das erwartete Rollback-Verhalten verhindert. Das PHP-Handbuch warnt ausdruecklich davor, dass PDO die Faehigkeit auf Treiberebene prueft. Bei MariaDB-Anwendungen sollte fuer jede an der Arbeitseinheit beteiligte Tabelle eine passende transaktionsfaehige Engine verifiziert werden, ueblicherweise InnoDB.

2. Schemaaenderungen koennen unbemerkt committen

Normale Schreibvorgaenge einer Anfrage sollten nicht mit Anweisungen einer Schemamigration vermischt werden, in der Annahme, ein Rollback decke beides ab. MariaDB dokumentiert viele Anweisungen, insbesondere DDL wie CREATE TABLE, ALTER TABLE und DROP TABLE, die einen impliziten Commit verursachen. Die Referenz zu impliziten Commits weist darauf hin, dass der Commit bei aufgefuehrten Anweisungen vor der Ausfuehrung erfolgt. Daher kann selbst eine fehlschlagende Anweisung die vorherige Transaktion bereits beendet haben.

3. Lange Transaktionen haben betriebliche Kosten

Eine Transaktion sollte normalerweise die fuer ihre Invariante notwendige Datenbankarbeit enthalten, nicht langsame HTTP-Anfragen, Benutzerinteraktion, Datei-Uploads oder unabhaengige Berechnungen. Eine offene Transaktion kann Sperren halten und andere Arbeit warten lassen. Kuerzer ist keine magische Garantie, doch eine enge Grenze ist leichter nachzuvollziehen und reduziert meist Konflikte.

4. Die Zustaendigkeit muss klar sein

Ein untergeordneter Helper, der still eine Transaktion beginnt oder bestaetigt, kann mit einer bereits vom Aufrufer verwalteten Transaktion kollidieren. PDO bietet zudem keine portablen, transparent verschachtelten Transaktionen. Es sollte festgelegt werden, welche Anwendungsschicht die Grenze besitzt. Wenn ein partieller Rollback wirklich erforderlich ist, muss das Savepoint-Verhalten der Datenbank gezielt untersucht werden, statt einen weiteren Aufruf von beginTransaction() als Verschachtelung zu behandeln.

Checkliste fuer einen Schreibpfad

  • Die Invariante in einem Satz benennen.
  • Jede Datenbankaenderung auflisten, die zu ihrem Erhalt erforderlich ist.
  • Optionales Logging oder Analytics ausserhalb der kritischen Grenze halten, sofern es nicht wirklich erforderlich ist.
  • PDO-Treiber und Storage Engines aller beteiligten Tabellen pruefen.
  • Fehler von Anweisungen als Exceptions oder gepruefte Rueckgabewerte sichtbar machen.
  • Nur bei noch aktiver Transaktion zurueckrollen und den urspruenglichen Fehler danach bewusst weiterwerfen oder behandeln.
  • Nebenlaeufige Anfragen beruecksichtigen, nicht nur den Erfolgsweg einer einzelnen Anfrage.
  • Netzwerkaufrufe und andere nicht umkehrbare Seiteneffekte aus der offenen Transaktion heraushalten.
  • Den Fehler nach jeder kritischen Anweisung in einer wegwerfbaren Umgebung testen.

Fazit

Eine Transaktion ist am nuetzlichsten, wenn sie ein klares Versprechen ausdrueckt: Diese Datenbankaenderungen gehoeren zusammen. Sie schliesst die durch Autocommit entstehende Luecke partieller Schreibvorgaenge, waehlt aber nicht die richtige Geschaeftsregel, koordiniert nicht jedes externe System und beseitigt keine Entscheidungen zur Nebenlaeufigkeit.

Fuer eine kleine PHP-Anwendung ist ein bescheidener Ausgangspunkt praktisch: die Invariante definieren, die Transaktion unmittelbar vor der zusammengehoerigen Datenbankarbeit oeffnen, kritische Bedingungen moeglichst nah am Schreibvorgang durchsetzen, erst nach Erfolg aller erforderlichen Anweisungen committen und bei Fehlern zurueckrollen. Danach bleibt zu untersuchen, was ausserhalb dieser Grenze liegt. In dieser letzten Frage zeigt sich oft das naechste wirkliche Designproblem.

References