Sie haben die Bestellung gespeichert, aber die Bestellpositionen sind nie in der Datenbank angekommen. Oder vielleicht ist der Lagerbestand gesunken, das Payment-Gateway hat einen Timeout verursacht, und nun hat der Kunde eine Abbuchung auf seiner Karte, aber keinen Bestellungseintrag. Diese Szenarien klingen wie Randfälle, bis sie es nicht mehr sind. In einer hochfrequentierten Anwendung werden sie zu täglichen Kopfschmerzen.
Die Ursache ist fast immer dieselbe: eine Sequenz von Datenbank-Schreibvorgängen, die als separate, isolierte Ereignisse behandelt wurden. Wenn einer fehlschlägt, bleiben die anderen zurück. Die Lösung ist eine Datenbanktransaktion, und in Laravel ist das Werkzeug DB::transaction().
Was eine Transaktion tatsächlich verspricht
Eine Datenbanktransaktion bindet mehrere Operationen zu einer einzigen Arbeitseinheit zusammen. Die Datenbank-Engine garantiert, dass alles innerhalb dieser Einheit entweder dauerhaft bestätigt (commit) oder vollständig rückgängig gemacht (rollback) wird. Es gibt keinen Mittelweg.
Denken Sie an eine Banküberweisung. Das System muss ein Konto belasten und ein anderes gutschreiben. Wenn die Gutschrift nach der Belastung fehlschlägt, verschwindet das Geld nicht einfach im Nichts. Die Bank macht die Belastung rückgängig. Diese Rückgängigmachung ist der Rollback. Wenn beide Schritte erfolgreich sind, wird die Überweisung bestätigt (committed), was bedeutet, dass die neuen Salden dauerhaft gespeichert werden.
Dieses Alles-oder-Nichts-Verhalten sorgt dafür, dass Ihre Daten konsistent bleiben. Ohne sie hinterlassen Teilfehler halb geschriebene Datensätze in Ihren Tabellen, und diese Datensätze verrotten in Ihrer Datenbank, weil kein automatisierter Prozess weiß, wie er sie sicher bereinigen kann.
Wie Laravel die Verbindung herstellt
In reinem SQL würden Sie BEGIN, COMMIT und ROLLBACK selbst schreiben und daran denken, jeden möglichen Fehler abzufangen, damit Sie niemals eine Transaktion offen lassen. Laravel kapselt diesen Boilerplate-Code in einer einzigen Methode.
Sie übergeben eine Closure an DB::transaction(). Laravel startet die Transaktion, führt Ihren Code aus, und wenn die Closure ohne das Werfen einer Exception endet, wird sie automatisch bestätigt (commit). Wenn etwas fehlschlägt, fängt Laravel die Exception ab, macht alles rückgängig (rollback) und wirft den Fehler erneut, damit Ihre Protokollierung und Fehlerbehandlung weiterhin wie erwartet funktionieren.
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
$order = Order::create([/* ... */]);
foreach ($cart->items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
]);
Product::find($item->product_id)
->decrement('stock', $item->quantity);
}
Payment::create([
'order_id' => $order->id,
'amount' => $cart->total,
'status' => 'completed',
]);
});
Wenn das Einfügen der Zahlung fehlschlägt, weil ein Fremdschlüssel fehlt oder die Datenbankverbindung abbricht, werden die Bestellung, die Bestellpositionen und die Bestandsänderungen alle rückgängig gemacht. Sie bleiben nicht auf einem Bestand sitzen, der ohne Grund verschwunden ist, und Sie haben keine Bestellung, die eine Lieferung verlangt, die nie bezahlt wurde.
Wo Ihnen das hilft
Einige Workflows hängen absolut von der transaktionalen Sicherheit ab. Das Beispiel Checkout ist das offensichtlichste, aber das Muster taucht überall auf.
Benutzerregistrierung. Das Erstellen einer Zeile für den Benutzer, dann eine Zeile für das Profil, dann die Standardeinstellungen. Wenn das Einfügen des Profils aufgrund eines Validierungs-Edge-Cases fehlschlägt, wird ein Benutzer ohne Profil zu einem Geisterkonto. Jede Seite, die davon ausgeht, dass jeder Benutzer ein Profil hat, wird abstürzen oder eine fehlerhafte Benutzeroberfläche anzeigen.
Massenimporte. Ein CSV-Upload, der fünfzig Datensätze einfügt, sollte nicht bei fünfundzwanzig stehen bleiben, nur weil die sechsundzwanzigste Zeile ein fehlerhaftes Datum hatte. Das Einbetten des Batches in eine Transaktion ermöglicht es, dass der gesamte Import als eine saubere Einheit fehlschlägt. Der Administrator korrigiert die Datei und versucht es erneut, anstatt Stunden damit zu verbringen, herauszufinden, welche Zeilen teilweise durchgeschlüpft sind.
Lagerbestand und Buchhaltung. Wann immer eine Tabelle eine physische Ressource verfolgt und eine andere Geld oder Guthaben, müssen sich beide gemeinsam bewegen. Die Trennung lädt zu Audits ein, die nicht übereinstimmen, und zu Berichten, die lügen.
Wann Sie manuell eingreifen müssen
Der Closure-Ansatz deckt die meisten Fälle ab, aber manchmal benötigen Sie mehr Kontrolle. Komplexe bedingte Logik innerhalb einer Service-Klasse oder die Notwendigkeit, zur Laufzeit zu entscheiden, ob ein Commit durchgeführt werden soll, können eine einzelne Closure unhandlich machen. In diesen Momenten können Sie die Transaktion selbst verwalten:
DB::beginTransaction();
try {
// Run your operations
$order = Order::create([/* ... */]);
// ... more work ...
if ($someBusinessRulePasses) {
DB::commit();
} else {
DB::rollBack();
}
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
Beachten Sie die Reihenfolge innerhalb des catch-Blocks: zuerst den Rollback durchführen, dann den Fehler werfen. Wenn Sie werfen, bevor Sie den Rollback durchführen, bleibt die Transaktion auf der Verbindung offen. Das kann Zeilen sperren, andere Abfragen in einen Deadlock führen oder Ihren Connection-Pool erschöpfen. Manuelle Transaktionen sind leistungsstark, aber sie übertragen die Verantwortung für die Bereinigung auf Sie.
Vermeiden Sie Seiteneffekte innerhalb der Transaktion
Dies ist die Regel, die Teams in der Produktion teuer zu stehen kommt. Eine Transaktion kann nur Datenbankarbeiten rückgängig machen. Sie kann keine E-Mail zurückholen, keine Datei aus dem Cloud-Speicher löschen und keine Zahlung über eine Zahlungs-API erstatten.
Wenn Sie einen Mail::send()-Aufruf innerhalb der Transaktions-Closure platzieren und die Datenbank zwei Zeilen später einen Rollback durchführt, landet diese E-Mail trotzdem im Posteingang des Kunden. Der Empfänger hat nun eine Rechnung für eine Bestellung, die in Ihrem System nicht existiert. Dasselbe gilt für Slack-Benachrichtigungen, Datei-Uploads zu S3 oder Webhook-Dispatches.
Die korrekte Abfolge ist:
- Schließen Sie die Transaktion ab und erfassen Sie alle benötigten IDs oder Ergebnisse.
- Erst danach lösen Sie externe Seiteneffekte aus.
Zum Beispiel: Stellen Sie die Bestätigungs-E-Mail in eine Queue, nachdem der Commit erfolgt ist, nicht innerhalb der Transaktion:
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
Es gibt einen zweiten Grund, externe Aufrufe außerhalb der Transaktion zu halten: die Zeit. Eine Transaktion hält Sperren (Locks) und beansprucht eine Datenbankverbindung. Drei Sekunden auf eine Antwort der Stripe-API zu warten, während man sich innerhalb einer Transaktion befindet, bedeutet drei Sekunden unnötige Sperrzeit. Halten Sie die Transaktion kurz und schnell.
Warum dieser Bug verborgen bleibt
Auf einer Entwicklungsmaschine mit nur einem Benutzer und einer lokalen Datenbank gelingen zusammenhängende Inserts fast immer. Das Netzwerk ist stabil, die Festplatte wird nie voll und es gibt keine konkurrierende Last. Der Code sieht korrekt aus, weil er normalerweise funktioniert.
In der Produktion sieht das anders aus. Zwei Kunden geben Bestellungen im exakt selben Millisekundenbereich auf. Ein Queue-Worker startet während eines Deployments mitten in einem Job neu. Ein Zahlungsanbieter läuft für dreißig Sekunden in einen Timeout. Ohne Transaktionen führen diese Ereignisse zu Waisen-Datensätzen (Orphan Records) und fehlerhaften Summen, die nur schwer zurückzuverfolgen sind. Das Schlimmste ist, dass Standard-Feature-Tests sie selten finden, da die Fehlerursache zeitabhängig ist und mit der Infrastruktur zusammenhängt, nicht mit einfachen Logikfehlern.
Die Lösung ist nicht exotisch. Sie ist mechanisch. Wenn Sie zwei oder mehr zusammenhängende Schreibvorgänge sehen, kapseln Sie diese in eine Transaktion. Mit der Zeit sollte dies so automatisch ablaufen wie die Validierung einer Anfrage.
Machen Sie es zu einem Reflex
DB::transaction() erhöht die Komplexität nicht. Es entfernt die versteckte Komplexität, die entsteht, wenn man versucht, unvollständige Daten im Nachhinein zu bereinigen. Wenn eine Gruppe von Datenbankoperationen zusammengehört, behandeln Sie sie von Anfang an auch so. Ihre Anwendung bleibt auch unter Last zuverlässig, Ihre Fehlerprotokolle bleiben sauber und Ihre Datenbank wird nicht zu einem Friedhof für halbfertige Datensätze.
