Erfahren Sie, wie Sie Postgres-Transaktionen für mehrstufige Workflows nutzen: Updates sicher gruppieren, partielle Schreibvorgänge verhindern, Wiederholungen handhaben und Daten konsistent halten.

Die meisten realen Features bestehen nicht aus einem einzigen Datenbank-Update. Es ist eine kurze Kette: eine Zeile einfügen, ein Guthaben aktualisieren, einen Status setzen, einen Audit-Eintrag schreiben, vielleicht einen Job in die Queue stellen. Ein partieller Schreibvorgang tritt auf, wenn nur einige dieser Schritte in die Datenbank gelangen.
Das zeigt sich, wenn etwas die Kette unterbricht: ein Serverfehler, ein Timeout zwischen Ihrer App und Postgres, ein Absturz nach Schritt 2 oder ein Retry, der Schritt 1 erneut ausführt. Jede einzelne Anweisung ist für sich in Ordnung. Der Workflow bricht, wenn er mitten im Ablauf stehen bleibt.
Man erkennt es meist schnell:
Ein konkretes Beispiel: Ein Plan-Upgrade aktualisiert den Plan des Kunden, fügt eine Zahlungszeile hinzu und erhöht verfügbare Credits. Wenn die App nach dem Speichern der Zahlung abstürzt, bevor die Credits hinzugefügt werden, sieht der Support in einer Tabelle „bezahlt“ und in einer anderen „keine Credits“. Wenn der Client erneut sendet, könnten Sie die Zahlung sogar doppelt verbuchen.
Das Ziel ist einfach: Behandeln Sie den Workflow wie einen einzigen Schalter. Entweder gelingt jeder Schritt, oder keiner, damit Sie nie halbfertige Arbeit speichern.
Eine Transaktion ist die Art, wie die Datenbank sagt: Behandle diese Schritte als eine Einheit. Entweder alle Änderungen passieren, oder keine. Das ist wichtig, wann immer Ihr Workflow mehr als ein Update braucht, z. B. eine Zeile anlegen, ein Guthaben aktualisieren und einen Audit-Eintrag schreiben.
Stellen Sie sich vor, Sie überweisen Geld zwischen zwei Konten. Sie müssen bei Konto A abziehen und bei Konto B hinzufügen. Wenn die App nach dem ersten Schritt abstürzt, möchten Sie nicht, dass das System sich nur an die Abbuchung „erinnert".
Wenn Sie committen, sagen Sie zu Postgres: behalte alles, was ich in dieser Transaktion gemacht habe. Alle Änderungen werden dauerhaft und für andere Sessions sichtbar.
Wenn Sie rollbacken, sagen Sie zu Postgres: vergiss alles, was ich in dieser Transaktion gemacht habe. Postgres macht die Änderungen rückgängig, als wäre die Transaktion nie passiert.
Innerhalb einer Transaktion garantiert Postgres, dass Sie anderen Sessions keine halb fertigen Ergebnisse zeigen, bevor Sie committen. Wenn etwas fehlschlägt und Sie rollbacken, räumt die Datenbank die Schreibvorgänge dieser Transaktion auf.
Eine Transaktion behebt keine schlechten Workflow-Entscheidungen. Wenn Sie den falschen Betrag abziehen, die falsche Benutzer-ID verwenden oder eine notwendige Prüfung auslassen, wird Postgres das falsche Ergebnis dennoch committen. Transaktionen verhindern auch nicht automatisch jeden geschäftslogischen Konflikt (wie Überverkauf), es sei denn, Sie kombinieren sie mit passenden Constraints, Locks oder dem richtigen Isolationslevel.
Immer wenn Sie mehr als eine Tabelle (oder mehr als eine Zeile) aktualisieren, um eine einzelne reale Aktion abzuschließen, haben Sie einen Kandidaten für eine Transaktion. Der Punkt bleibt: Entweder ist alles fertig, oder nichts.
Ein Bestellablauf ist der klassische Fall. Sie könnten eine Bestellzeile anlegen, Lagerbestand reservieren, eine Zahlung durchführen und dann den Auftrag als bezahlt markieren. Wenn die Zahlung gelingt, die Statusaktualisierung aber fehlschlägt, haben Sie Geld erfasst, aber die Bestellung sieht weiterhin als unbezahlt aus. Wenn die Bestellzeile erstellt wird, der Bestand aber nicht reserviert wird, können Sie Artikel verkaufen, die Sie gar nicht haben.
Auch das User-Onboarding bricht leise auf dieselbe Weise. Einen Benutzer erstellen, ein Profil einfügen, Rollen zuweisen und aufzeichnen, dass eine Willkommensmail versendet werden soll, ist eine logische Aktion. Ohne Gruppierung können Sie einen Benutzer haben, der sich anmelden kann, aber keine Berechtigungen hat, oder ein Profil, das ohne Benutzer existiert.
Back-Office-Aktionen brauchen oft striktes „Papiertrail + Zustandsänderung“-Verhalten. Eine Anforderung genehmigen, einen Audit-Eintrag schreiben und ein Guthaben aktualisieren sollten zusammen gelingen. Wenn das Guthaben geändert wird, aber der Audit-Log fehlt, verlieren Sie die Nachvollziehbarkeit, wer was warum geändert hat.
Hintergrundjobs profitieren ebenfalls, besonders wenn Sie ein Arbeitselement mit mehreren Schritten verarbeiten: das Element beanspruchen, damit nicht zwei Worker es gleichzeitig tun, das Business-Update anwenden, ein Ergebnis für Reporting und Retries speichern und das Element dann als erledigt markieren (oder mit Grund auf Fehler setzen). Wenn diese Schritte auseinanderlaufen, erzeugen Retries und Konkurrenz Chaos.
Mehrstufige Features brechen, wenn Sie sie wie einen Haufen unabhängiger Updates behandeln. Bevor Sie einen Datenbank-Client öffnen, schreiben Sie den Workflow als Kurzgeschichte mit einem klaren Ziel: Was genau zählt als „fertig" für den Nutzer?
Beginnen Sie damit, die Schritte in einfacher Sprache aufzulisten und dann eine einzige Erfolgsvoraussetzung zu definieren. Zum Beispiel: „Bestellung ist erstellt, Lagerbestand ist reserviert und der Nutzer sieht eine Bestellbestätigungsnummer.“ Alles andere ist kein Erfolg, selbst wenn einige Tabellen aktualisiert wurden.
Zeichnen Sie als Nächstes eine klare Linie zwischen Datenbankarbeit und externer Arbeit. Datenbankschritte sind die, die Sie mit Transaktionen schützen können. Externe Aufrufe wie Kartenabbuchungen, E-Mails versenden oder Drittanbieter-APIs anzusprechen, können langsam und unvorhersehbar fehlschlagen und lassen sich meist nicht zurückrollen.
Eine einfache Planungsmethode: Teile die Schritte in (1) muss alles-oder-nichts sein und (2) kann nach dem Commit passieren.
Behalten Sie innerhalb der Transaktion nur die Schritte, die konsistent zusammenbleiben müssen:
Verschieben Sie Seiteneffekte nach außen. Zum Beispiel: Committen Sie die Bestellung zuerst und senden Sie die Bestätigungs-E-Mail basierend auf einem Outbox-Eintrag.
Für jeden Schritt schreiben Sie auf, was passieren soll, wenn der nächste Schritt fehlschlägt. „Rollback“ kann eine Datenbank-Rollback bedeuten oder eine kompensierende Aktion.
Beispiel: Wenn die Zahlung gelingt, aber die Lagerreservierung fehlschlägt, entscheiden Sie im Voraus, ob Sie sofort zurückerstatten oder die Bestellung als „Zahlung erfasst, wartet auf Lager“ markieren und asynchron behandeln.
Eine Transaktion sagt Postgres: Behandle diese Schritte als eine Einheit. Entweder passieren alle, oder keine. Das ist der einfachste Weg, partielle Schreibvorgänge zu verhindern.
Nutzen Sie eine Datenbankverbindung (eine Session) von Anfang bis Ende. Wenn Sie Schritte über mehrere Verbindungen verteilen, kann Postgres kein Alles-oder-Nichts-Ergebnis garantieren.
Die Reihenfolge ist einfach: BEGIN, führen Sie die erforderlichen Reads und Writes aus, COMMIT wenn alles gelingt, andernfalls ROLLBACK und geben Sie einen klaren Fehler zurück.
Hier ein minimales Beispiel in SQL:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
Transaktionen halten Locks, solange sie laufen. Je länger Sie sie offenhalten, desto mehr blockieren Sie andere Arbeiten und desto wahrscheinlicher sind Timeouts oder Deadlocks. Tun Sie das Nötigste in der Transaktion und verlagern Sie langsame Aufgaben (E-Mails senden, Zahlungsanbieter anrufen, PDFs erzeugen) nach außen.
Wenn etwas fehlschlägt, protokollieren Sie genügend Kontext, um das Problem reproduzieren zu können, ohne sensible Daten zu leaken: Workflow-Name, order_id oder user_id, Schlüsselparameter (Betrag, Währung) und den Postgres-Fehlercode. Vermeiden Sie das Loggen kompletter Payloads, Kartendaten oder persönlicher Details.
Konkurrenz bedeutet einfach: zwei Dinge passieren zur gleichen Zeit. Stellen Sie sich zwei Kunden vor, die das letzte Konzertticket kaufen. Beide sehen „1 übrig“, beide klicken auf Bezahlen, und jetzt muss Ihre App entscheiden, wer es bekommt.
Ohne Schutz können beide Anfragen denselben alten Wert lesen und beide ein Update schreiben. So entstehen negativer Lagerbestand, doppelte Reservierungen oder eine Zahlung ohne Bestellung.
Row-Locks sind das einfachste Schutzmittel. Sie sperren die konkrete Zeile, die Sie ändern wollen, führen Ihre Prüfungen durch und aktualisieren sie dann. Andere Transaktionen, die dieselbe Zeile anfassen, müssen warten, bis Sie committen oder rollbacken, was doppelte Updates verhindert.
Ein gängiges Muster: Starte eine Transaktion, wähle die Inventarzeile mit FOR UPDATE, verifiziere, dass Bestand vorhanden ist, dekrementiere ihn und füge dann die Bestellung ein. Das „hält die Tür“ zu, während Sie die kritischen Schritte beenden.
Isolationsstufen steuern, wie viele seltsame Überschneidungen von gleichzeitigen Transaktionen Sie zulassen. Der Kompromiss ist meist Sicherheit vs. Geschwindigkeit:
Halten Sie Locks kurz. Wenn eine Transaktion offen bleibt, während Sie eine externe API anrufen oder auf eine Benutzeraktion warten, entstehen lange Wartezeiten und Timeouts. Bevorzugen Sie einen klaren Fehlerpfad: setzen Sie ein Lock-Timeout, fangen Sie den Fehler ab und geben Sie „bitte erneut versuchen“ zurück, statt die Anfragen hängen zu lassen.
Wenn Sie Arbeit außerhalb der Datenbank erledigen müssen (z. B. eine Karte belasten), teilen Sie den Workflow: schnell reservieren, committen, dann den langsamen Teil ausführen und mit einer weiteren kurzen Transaktion finalisieren.
Retries sind normal in Postgres-basierten Apps. Eine Anfrage kann fehlschlagen, auch wenn Ihr Code korrekt ist: Deadlocks, Statement-Timeouts, kurze Netzwerkabbrüche oder eine Serialisierungsfehler bei höheren Isolationsstufen. Wenn Sie denselben Handler einfach erneut ausführen, riskieren Sie eine zweite Bestellung, doppelte Abbuchungen oder doppelte Event-Zeilen.
Die Lösung ist Idempotenz: die Operation sollte sicher sein, zweimal mit denselben Eingaben ausgeführt zu werden. Die Datenbank sollte erkennen können „das ist dieselbe Anfrage" und konsistent antworten.
Ein praktisches Muster ist, jedem mehrstufigen Workflow einen Idempotency-Key (oft eine client-generierte request_id) beizufügen, ihn auf dem Haupt-Record zu speichern und einen Unique-Constraint auf diesen Key zu setzen.
Beispiel: Beim Checkout erzeugen Sie eine request_id, wenn der Nutzer auf Bezahlen klickt, und fügen die Bestellung mit dieser request_id ein. Wenn ein Retry passiert, schlägt der zweite Versuch am Unique-Constraint fehl und Sie geben die bestehende Bestellung zurück, statt eine neue zu erzeugen.
Was üblicherweise wichtig ist:
Halten Sie die Retry-Schleife außerhalb der Transaktion. Jeder Versuch sollte eine neue Transaktion starten und die gesamte Einheit von oben neu ausführen. Retries innerhalb einer fehlgeschlagenen Transaktion helfen nicht, weil Postgres sie als aborted markiert.
Ein kleines Beispiel: Ihre App versucht, eine Bestellung zu erzeugen und Inventar zu reservieren, aber es timed out direkt nach COMMIT. Der Client wiederholt. Mit einem Idempotency-Key liefert die zweite Anfrage die bereits erstellte Bestellung zurück und überspringt eine zweite Reservierung, anstatt die Arbeit zu verdoppeln.
Transaktionen halten einen mehrstufigen Workflow zusammen, aber sie machen die Daten nicht automatisch richtig. Eine starke Strategie gegen partielle Schreibfehler ist, „falsche" Zustände in der Datenbank schwer oder unmöglich zu machen, selbst wenn ein Bug in den Anwendungscode rutscht.
Beginnen Sie mit grundlegenden Sicherheitsgurten. Foreign Keys stellen sicher, dass Referenzen real sind (eine Bestellposition kann nicht auf eine fehlende Bestellung zeigen). NOT NULL verhindert halbgefüllte Zeilen. CHECK-Constraints fangen Werte ab, die keinen Sinn ergeben (z. B. quantity > 0, total_cents >= 0). Diese Regeln laufen bei jedem Write, egal welcher Service oder welches Script die Daten ändert.
Für längere Workflows modellieren Sie Zustandsänderungen explizit. Statt vieler Bool-Flags nutzen Sie eine einzelne Statusspalte (pending, paid, shipped, canceled) und erlauben nur gültige Übergänge. Sie können das mit Constraints oder Triggern erzwingen, sodass die Datenbank illegale Sprünge wie shipped -> pending ablehnt.
Eindeutigkeit ist eine weitere Form von Korrektheit. Fügen Sie Unique-Constraints hinzu, wo Duplikate Ihren Workflow brechen würden: order_number, invoice_number oder ein idempotency_key, der für Retries genutzt wird. Dann blockiert Postgres einen zweiten Insert und Sie können sicher „already processed“ zurückgeben, statt eine zweite Bestellung zu erzeugen.
Wenn Sie Nachvollziehbarkeit brauchen, speichern Sie sie explizit. Eine Audit- oder History-Tabelle, die aufzeichnet, wer was wann geändert hat, macht „mysteriöse Updates" zu Fakten, die Sie bei Incidents abfragen können.
Die meisten partiellen Schreibvorgänge werden nicht durch "schlechtes SQL" verursacht. Sie entstehen durch Workflow-Entscheidungen, die es leicht machen, nur die halbe Geschichte zu committen.
accounts dann orders updated, aber eine andere orders dann accounts, erhöhen Sie die Chance auf Deadlocks unter Last.Ein konkretes Beispiel: Beim Checkout reservieren Sie Inventar, erstellen eine Bestellung und belasten dann eine Karte. Wenn Sie die Karte innerhalb derselben Transaktion belasten, halten Sie möglicherweise einen Inventar-Lock, während Sie auf das Netzwerk warten. Gelingt die Belastung, rollbackt Ihre Transaktion später jedoch, dann haben Sie den Kunden belastet, aber keine Bestellung.
Ein sichereres Muster ist: Fokussieren Sie die Transaktion auf den Datenbankzustand (Inventar reservieren, Bestellung anlegen, Zahlung als pending markieren), committen Sie, rufen Sie dann die externe API auf und schreiben Sie das Ergebnis in einer neuen kurzen Transaktion zurück. Viele Teams implementieren das mit einem einfachen pending-Status und einem Hintergrundjob.
Wenn ein Workflow mehrere Schritte hat (insert, update, charge, send), ist das Ziel einfach: Entweder wird alles aufgezeichnet oder gar nichts.
Behalten Sie alle erforderlichen Datenbankwrites in einer Transaktion. Wenn ein Schritt fehlschlägt, rollbacken Sie und lassen die Daten genau so, wie sie waren.
Machen Sie die Erfolgsvoraussetzung explizit. Zum Beispiel: „Bestellung ist erstellt, Bestand ist reserviert und Zahlungsstatus ist vermerkt." Alles andere ist ein Fehlerpfad, der die Transaktion abbrechen muss.
BEGIN ... COMMIT Blocks.ROLLBACK, und der Aufrufer erhält ein klares Fehlergebnis.Gehen Sie davon aus, dass dieselbe Anfrage wiederholt werden kann. Die Datenbank sollte Ihnen helfen, Only-Once-Regeln durchzusetzen.
Machen Sie nur das Minimum innerhalb der Transaktion und vermeiden Sie Netzwerkaufrufe, während Locks gehalten werden.
Wenn Sie nicht sehen können, wo es bricht, werden Sie weiter raten.
Ein Checkout hat mehrere Schritte, die zusammen funktionieren sollten: Bestellung anlegen, Lager reservieren, Zahlungsversuch aufzeichnen und dann den Bestellstatus setzen.
Stellen Sie sich vor, ein Nutzer klickt auf Kaufen für 1 Artikel.
Innerhalb einer Transaktion machen Sie nur Datenbankänderungen:
orders-Zeile mit dem Status pending_payment ein.inventory.available dekrementieren oder eine reservations-Zeile erstellen).payment_intents-Zeile mit einem client-seitigen idempotency_key (unique) ein.outbox-Zeile wie „order_created" ein.Wenn eine Anweisung fehlschlägt (kein Bestand, Constraint-Fehler, Absturz), rollt Postgres die ganze Transaktion zurück. Sie haben dann keine Bestellung ohne Reservierung oder eine Reservierung ohne Bestellung.
Der Payment-Provider liegt außerhalb Ihrer Datenbank, behandeln Sie ihn also als separaten Schritt.
Scheitert der Provider-Aufruf, bevor Sie committen, brechen Sie die Transaktion ab und es wird nichts geschrieben. Scheitert der Provider-Aufruf, nachdem Sie committed haben, starten Sie eine neue Transaktion, die den Zahlungsversuch als fehlgeschlagen markiert, die Reservierung freigibt und den Bestellstatus auf canceled setzt.
Lassen Sie den Client pro Checkout-Versuch einen idempotency_key senden. Erzwingen Sie ihn mit einem Unique-Index auf payment_intents(idempotency_key) (oder auf orders, wenn Sie das bevorzugen). Bei einem Retry sucht Ihr Code die bestehenden Zeilen und macht weiter, statt eine neue Bestellung einzufügen.
Senden Sie keine E-Mails innerhalb der Transaktion. Schreiben Sie einen Outbox-Eintrag in derselben Transaktion und lassen Sie einen Hintergrundarbeiter die E-Mail nach dem Commit senden. So versenden Sie nie eine E-Mail für eine Bestellung, die zurückgerollt wurde.
Wählen Sie einen Workflow, der mehr als eine Tabelle berührt: Signup + Willkommens-E-Mail enqueuen, Checkout + Inventar, Rechnung + Ledger-Eintrag oder Projekt anlegen + Standard-Einstellungen.
Schreiben Sie zuerst die Schritte, dann die Regeln, die immer gelten müssen (Ihre Invarianten). Beispiel: „Eine Bestellung ist entweder vollständig bezahlt und reserviert, oder nicht bezahlt und nicht reserviert. Nie halb reserviert." Machen Sie diese Regeln zu einer Alles-oder-Nichts-Einheit.
Ein einfacher Plan:
Testen Sie dann absichtlich die hässlichen Fälle. Simulieren Sie einen Absturz nach Schritt 2, ein Timeout direkt vor dem Commit und ein Doppelsenden aus der UI. Das Ziel sind langweilige Ergebnisse: keine verwaisten Zeilen, keine doppelten Belastungen, nichts hängt ewig in pending.
Wenn Sie schnell prototypen, hilft es, den Workflow zunächst in einem Planungs-Tool zu skizzieren, bevor Sie Handler und Schema generieren. Zum Beispiel hat Koder.ai (koder.ai) einen Planning Mode und unterstützt Snapshots und Rollback, was beim Iterieren der Transaktionsgrenzen und Constraints nützlich sein kann.
Machen Sie diese eine Woche lang für einen Workflow. Der zweite geht dann viel schneller.