Leer Postgres-transacties voor meerstaps-workflows: hoe je updates veilig groepeert, gedeeltelijke schrijfacties voorkomt, herhalingen afhandelt en data consistent houdt.

De meeste echte features zijn niet één enkele database-update. Het is een korte keten: een rij invoegen, een balans bijwerken, een status zetten, een auditrecord schrijven en misschien een job in de wachtrij zetten. Een gedeeltelijke schrijfactie ontstaat wanneer slechts enkele van die stappen in de database terechtkomen.
Dit zie je wanneer iets de keten onderbreekt: een serverfout, een timeout tussen jouw app en Postgres, een crash na stap 2 of een retry die stap 1 opnieuw uitvoert. Elke statement is op zichzelf prima. De workflow breekt als hij halverwege stopt.
Meestal zie je het snel:
Een concreet voorbeeld: een planupgrade werkt het plan van de klant bij, voegt een betaalrecord toe en verhoogt beschikbare tegoeden. Als de app crasht nadat de betaling is opgeslagen maar voordat de tegoeden zijn toegevoegd, ziet support in de ene tabel "betaald" en in de andere "geen tegoed". Als de client opnieuw probeert, kun je zelfs de betaling twee keer registreren.
Het doel is simpel: behandel de workflow als één enkele schakelaar. Of alle stappen slagen, of geen enkele, zodat je nooit half-af werk opslaat.
Een transactie is de manier van de database om te zeggen: behandel deze stappen als één eenheid werk. Of alle wijzigingen gebeuren, of geen enkele. Dit is belangrijk zodra je workflow meer dan één update nodig heeft, zoals een rij aanmaken, een balans bijwerken en een auditrecord schrijven.
Denk aan geld overboeken tussen twee rekeningen. Je moet van Rekening A afschrijven en bij Rekening B bijschrijven. Als de app crasht na de eerste stap, wil je niet dat het systeem zich alleen de afschrijving herinnert.
Als je commit, vertel je Postgres: bewaar alles wat ik in deze transactie deed. Alle wijzigingen worden permanent en zichtbaar voor andere sessies.
Als je rollback, vertel je Postgres: vergeet alles wat ik in deze transactie deed. Postgres maakt de wijzigingen ongedaan alsof de transactie nooit heeft plaatsgevonden.
Binnen een transactie garandeert Postgres dat je geen half-af resultaten aan andere sessies blootstelt voordat je commit. Als er iets faalt en je rolbackt, ruimt de database de schrijfacties van die transactie op.
Een transactie repareert geen slecht ontworpen workflow. Als je het verkeerde bedrag aftrekt, de verkeerde gebruikers-id gebruikt of een noodzakelijke check overslaat, zal Postgres het foutieve resultaat trouw committen. Transacties voorkomen ook niet automatisch elk zakelijk conflict (zoals overselling van voorraad) tenzij je ze combineert met de juiste constraints, locks of isolatieniveaus.
Wanneer je meer dan één tabel (of meer dan één rij) update om één reële handeling af te ronden, is het een kandidaat voor een transactie. Het idee blijft: of alles is af, of niets.
Een orderflow is het klassieke geval. Je maakt mogelijk een orderrij aan, reserveert voorraad, neemt een betaling af en markeert de order als betaald. Als de betaling slaagt maar de statusupdate faalt, heb je geld vastgelegd met een order die er nog onbetaald uitziet. Als de orderrij is aangemaakt maar voorraad niet is gereserveerd, kun je items verkopen die je niet echt hebt.
Het aanmaken van een gebruiker breekt op dezelfde manier stilletjes. Een gebruiker aanmaken, een profiel invoegen, rollen toewijzen en vastleggen dat een welkomsmail moet worden gestuurd is één logische actie. Zonder groepering kun je eindigen met een gebruiker die kan inloggen maar geen permissies heeft, of een profiel zonder gebruiker.
Back-office acties hebben vaak strikte "paper trail + state change"-eisen. Een verzoek goedkeuren, een auditregel schrijven en een balans bijwerken moeten samen slagen. Als de balans verandert maar de auditlog ontbreekt, verlies je bewijs van wie wat en waarom heeft aangepast.
Background jobs profiteren ook, vooral als je een work item met meerdere stappen verwerkt: claim het item zodat twee workers het niet doen, voer de business update uit, registreer een resultaat voor rapportage en retries, en markeer het item als voltooid (of mislukt met een reden). Als die stappen uit elkaar drijven, ontstaan er problemen met retries en gelijktijdigheid.
Meerstaps-features breken als je ze behandelt als een stapel onafhankelijke updates. Voordat je een databaseclient opent, schrijf de workflow als een kort verhaal met één duidelijk eindpunt: wat telt precies als "klaar" voor de gebruiker?
Begin met het opsommen van de stappen in gewone taal en definieer vervolgens één succesvoorwaarde. Bijvoorbeeld: "Order is aangemaakt, voorraad is gereserveerd en de gebruiker ziet een orderbevestigingsnummer." Alles wat daaronder valt is geen succes, ook al zijn sommige tabellen bijgewerkt.
Teken daarna een scherpe lijn tussen databasewerk en extern werk. Databasestappen zijn stappen die je kunt beschermen met transacties. Externe calls zoals kaartbetalingen, e-mails verzenden of third-party API's kunnen traag en onvoorspelbaar falen en zijn meestal niet terug te draaien.
Een eenvoudige planningsaanpak: verdeel stappen in (1) moet alles-of-niets zijn, (2) kan na commit gebeuren.
Houd binnen de transactie alleen de stappen die samen consistent moeten blijven:
Verplaats bijwerkingen naar buiten. Commit bijvoorbeeld eerst de order en stuur daarna de bevestiging op basis van een outbox-record.
Voor elke stap beschrijf je wat er moet gebeuren als de volgende stap faalt. "Rollback" kan een database-rollback betekenen, of een compenserende actie.
Voorbeeld: als de betaling slaagt maar voorraadreservering faalt, beslis van tevoren of je meteen terugbetaalt, of deorder als "betaling vastgelegd, wacht op voorraad" markeert en het asynchroon afhandelt.
Een transactie vertelt Postgres: behandel deze stappen als één eenheid. Of ze allemaal gebeuren, of geen enkele. Dat is de eenvoudigste manier om gedeeltelijke schrijfacties te voorkomen.
Gebruik één databaseverbinding (één sessie) van begin tot eind. Als je stappen over verschillende verbindingen verspreidt, kan Postgres geen all-of-nothing-resultaat garanderen.
De volgorde is simpel: begin, voer de benodigde reads en writes uit, commit als alles slaagt, anders rollback en geef een duidelijke fout terug.
Hier is een minimaal voorbeeld 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;
Transacties houden locks terwijl ze draaien. Hoe langer je ze openhoudt, hoe meer je ander werk blokkeert en hoe groter de kans op timeouts of deadlocks. Doe het essentiële werk binnen de transactie en verplaats trage taken (e-mails verzenden, payment providers aanroepen, PDF's genereren) naar buiten.
Als er iets faalt, log dan genoeg context om het probleem te reproduceren zonder gevoelige data te lekken: workflownaam, order_id of user_id, sleutelparameters (bedrag, valuta) en de Postgres-foutcode. Vermijd het loggen van volledige payloads, kaartgegevens of persoonlijke informatie.
Concurrentie is gewoon twee dingen die tegelijkertijd gebeuren. Stel je twee klanten voor die het laatste concertticket willen. Beide schermen tonen "1 over," beiden klikken op Betalen en nu moet je app beslissen wie het krijgt.
Zonder bescherming kunnen beide requests dezelfde oude waarde lezen en beide een update schrijven. Zo krijg je negatieve voorraad, gedupliceerde reserveringen of een betaling zonder order.
Rijlocks zijn de eenvoudigste vangrail. Je lockt de specifieke rij die je wilt veranderen, doet je checks en werkt hem dan bij. Andere transacties die die rij aanraken moeten wachten tot jij commit of rollbackt, waardoor dubbele updates worden voorkomen.
Een veelvoorkomend patroon: start een transactie, selecteer de voorraadrij met FOR UPDATE, controleer dat er voorraad is, decrement het aantal en insert daarna de order. Dat houdt als het ware de deur dicht terwijl je de kritieke stappen afrondt.
Isolatieniveaus bepalen hoeveel vreemde overlap je toestaat tussen gelijktijdige transacties. De trade-off is meestal veiligheid versus snelheid:
Houd locks kort. Als een transactie open blijft terwijl je een externe API aanroept of op een gebruiker wacht, ontstaan er lange wachttijden en timeouts. Geef de voorkeur aan een duidelijk failure-pad: stel een lock timeout in, vang de fout en geef "probeer opnieuw" terug in plaats van requests te laten hangen.
Als je werk buiten de database moet doen (zoals een kaart belasten), split de workflow: reserveer snel, commit, doe dan het trage deel en finaliseer met een andere korte transactie.
Retries zijn normaal in apps met Postgres. Een request kan falen zelfs als je code correct is: deadlocks, statement timeouts, korte netwerkuitval of een serializatiefout bij hogere isolatie. Als je dezelfde handler gewoon opnieuw uitvoert, loop je het risico een tweede order te maken, twee keer te belasten of dubbele "event"-rijen in te voegen.
De remedie is idempotentie: de operatie moet veilig twee keer uitgevoerd kunnen worden met dezelfde input. De database moet kunnen herkennen "dit is hetzelfde verzoek" en consistent antwoorden.
Een praktisch patroon is een idempotency key (vaak een door de client gegenereerde request_id) aan elk meerstaps-workflow koppelen en die op de hoofdrecord opslaan, en vervolgens een unique constraint op die key zetten.
Bijvoorbeeld: bij checkout genereer je request_id wanneer de gebruiker op Betalen klikt en insert je de order met die request_id. Als er een retry plaatsvindt, treedt de unieke constraint in werking en retourneer je de bestaande order in plaats van een nieuwe te maken.
Wat meestal telt:
Houd de retry-loop buiten de transactie. Elke poging moet een nieuwe transactie starten en de hele eenheid werk opnieuw vanaf het begin uitvoeren. Retries binnen een gefaalde transactie helpen niet omdat Postgres deze als aborted markeert.
Een klein voorbeeld: je app probeert een order te maken en voorraad te reserveren, maar het time-out net na COMMIT. De client retryt. Met een idempotency key retourneert het tweede verzoek de al-aangemaakte order en sla je een tweede reservering over in plaats van het werk te verdubbelen.
Transacties houden een meerstaps-workflow bij elkaar, maar maken de data niet automatisch correct. Een sterke manier om fallout van gedeeltelijke schrijfacties te vermijden is om "verkeerde" staten moeilijk of onmogelijk te maken in de database, zelfs als er een bug in de applicatie sluipt.
Begin met basisveiligheidsrails. Foreign keys zorgen dat referenties echt zijn (een orderregel kan niet naar een ontbrekende order verwijzen). NOT NULL voorkomt halfvolle rijen. CHECK-constraints vangen waarden die geen zin hebben (bijvoorbeeld quantity > 0, total_cents >= 0). Deze regels draaien bij elke write, ongeacht welke service of script de database aanraakt.
Voor langere workflows modelleer state changes expliciet. Gebruik in plaats van veel booleans één statuskolom (pending, betaald, verzonden, geannuleerd) en sta alleen geldige transities toe. Je kunt dit afdwingen met constraints of triggers zodat de database illegale sprongen zoals verzonden -> pending weigert.
Uniekheid is een andere vorm van correctheid. Voeg unique constraints toe waar duplicaten je workflow zouden breken: order_number, invoice_number of een idempotency_key voor retries. Dan, als je app hetzelfde verzoek opnieuw probeert, blokkeert Postgres de tweede insert en kun je veilig "al verwerkt" retourneren in plaats van een tweede order te maken.
Als je traceerbaarheid nodig hebt, sla die dan expliciet op. Een audit-tabel (of history-tabel) die vastlegt wie wat wanneer wijzigde, verandert "mystery updates" in feiten die je tijdens incidenten kunt opvragen.
De meeste gedeeltelijke schrijfacties worden niet veroorzaakt door "slechte SQL." Ze komen voort uit workflow-beslissingen die het makkelijk maken om maar de helft van het verhaal te committen.
accounts dan orders bijwerkt, maar een ander orders dan accounts, vergroot je de kans op deadlocks onder load.Een concreet voorbeeld: in checkout reserveer je voorraad, maak je een order aan en laad je vervolgens een kaart. Als je de kaart binnen dezelfde transactie belast, houd je mogelijk een voorraadlock vast terwijl je op het netwerk wacht. Als de charge slaagt maar je transactie later rollt back, heb je de klant wel belast zonder order.
Een veiliger patroon is: concentreer de transactie op database-state (reserveer voorraad, maak order, registreer betaling als "pending"), commit, roep daarna de externe API aan en schrijf het resultaat terug in een nieuwe korte transactie. Veel teams implementeren dit met een eenvoudige pending-status en een background job.
Als een workflow meerdere stappen heeft (insert, update, charge, send), is het doel simpel: of alles wordt vastgelegd, of niets.
Houd elke vereiste database-write binnen één transactie. Als één stap faalt, roll back en laat de data precies staan zoals het was.
Maak de succesvoorwaarde expliciet. Bijvoorbeeld: "Order is aangemaakt, voorraad is gereserveerd en betaalstatus is vastgelegd." Alles daaronder is een faalpad dat de transactie moet afbreken.
BEGIN ... COMMIT-blok.ROLLBACK, en de aanroeper krijgt een duidelijke foutmelding.Ga ervan uit dat hetzelfde verzoek opnieuw kan worden geprobeerd. De database moet je helpen only-once-regels af te dwingen.
Doe het minimale werk binnen de transactie en vermijd wachten op netwerkcalls terwijl je locks vasthoudt.
Als je niet kunt zien waar het breekt, blijf je gissen.
Een checkout heeft meerdere stappen die samen moeten bewegen: order aanmaken, voorraad reserveren, de betaalpoging registreren en daarna de orderstatus zetten.
Stel je voor dat een gebruiker op Koop klikt voor 1 item.
Doe binnen één transactie alleen databasewijzigingen:
orders-rij met status pending_payment.inventory.available of maak een reservations-rij).payment_intents-rij met een door de client verstrekte idempotency_key (uniek).outbox-rij zoals "order_created".Als een statement faalt (niet genoeg voorraad, constraint-fout, crash), rolt Postgres de hele transactie terug. Je eindigt niet met een order zonder reservering of een reservering zonder order.
De payment provider zit buiten je database, behandel het dus als een aparte stap.
Als de provider-call faalt voordat je commit, breek je de transactie af en wordt er niets weggeschreven. Als de provider-call faalt nadat je commit, start je een nieuwe transactie die de betaalpoging als mislukt markeert, de reservering vrijgeeft en de orderstatus op geannuleerd zet.
Laat de client een idempotency_key per checkoutpoging meesturen. Handhaaf dat met een unieke index op payment_intents(idempotency_key) (of op orders als je dat verkiest). Bij retry kijkt je code de bestaande rijen op en gaat verder in plaats van een nieuwe order te inserten.
Stuur geen e-mails binnen de transactie. Schrijf een outbox-record in dezelfde transactie en laat een background worker de e-mail na commit sturen. Zo mail je nooit voor een order die is teruggerold.
Kies één workflow die meer dan één tabel raakt: signup + enqueue welkomsmail, checkout + voorraad, factuur + grootboekpost, of project aanmaken + default instellingen.
Schrijf eerst de stappen, maak dan de regels die altijd waar moeten zijn (jouw invarianten). Voorbeeld: "Een order is óf volledig betaald en gereserveerd, óf niet betaald en niet gereserveerd. Nooit half-gereserveerd." Zet die regels om in een alles-of-niets-eenheid.
Een eenvoudig plan:
Test daarna bewust de pijnlijke gevallen. Simuleer een crash na stap 2, een timeout vlak voor commit en een dubbele submit vanuit de UI. Het doel is saaie uitkomsten: geen verweesde rijen, geen dubbele charges, niets dat eeuwig in pending blijft.
Als je snel prototypeert, helpt het om de workflow eerst in een planningstool te schetsen voordat je handlers en schema genereert. Bijvoorbeeld, Koder.ai (koder.ai) heeft een Planning Mode en ondersteunt snapshots en rollback, wat handig kan zijn terwijl je transaction boundaries en constraints bijstelt.
Doe dit voor één workflow deze week. De tweede gaat veel sneller.