Lär dig Postgres-transaktioner för flerstegsarbetsflöden: hur du grupperar uppdateringar säkert, undviker delvisa skrivningar, hanterar omförsök och håller data konsekventa.

De flesta verkliga funktioner är inte en enda databasuppdatering. De är en kort kedja: skapa en rad, uppdatera ett saldo, markera en status, skriva en revisionspost, kanske köa ett jobb. En delvis skrivning uppstår när bara några av dessa steg når databasen.
Det visar sig när något avbryter kedjan: ett serverfel, en timeout mellan din app och Postgres, en krasch efter steg 2 eller ett omförsök som kör om steg 1. Varje sats är i sig okej. Arbetsflödet bryts när det stannar halvvägs.
Du brukar kunna upptäcka det snabbt:
Ett konkret exempel: en planuppgradering uppdaterar kundens plan, lägger till en betalningspost och ökar tillgängliga krediter. Om appen kraschar efter att betalningen sparats men innan krediterna lagts till ser support "paid" i en tabell och "no credits" i en annan. Om klienten gör ett omförsök kan du till och med bokföra betalningen två gånger.
Målet är enkelt: behandla arbetsflödet som en enda strömbrytare. Antingen lyckas varje steg, eller inget av dem gör det, så att du aldrig sparar halvfärdigt arbete.
En transaktion är databasens sätt att säga: behandla dessa steg som en enhet av arbete. Antingen händer alla ändringar, eller inga alls. Det spelar roll varje gång ditt arbetsflöde behöver mer än en uppdatering, som att skapa en rad, uppdatera ett saldo och skriva en revisionspost.
Tänk på att flytta pengar mellan två konton. Du måste dra från Konto A och lägga till på Konto B. Om appen kraschar efter det första steget vill du inte att systemet "kommer ihåg" bara uttaget.
När du commitar säger du till Postgres: behåll allt jag gjorde i den här transaktionen. Alla ändringar blir permanenta och synliga för andra sessioner.
När du rullar tillbaka säger du till Postgres: glöm allt jag gjorde i den här transaktionen. Postgres ångrar ändringarna som om transaktionen aldrig hänt.
Inuti en transaktion garanterar Postgres att du inte exponerar halvfärdiga resultat för andra sessioner innan du committar. Om något misslyckas och du rullar tillbaka städar databasen upp skrivningarna från den transaktionen.
En transaktion fixar inte dålig arbetsflödesdesign. Om du drar fel belopp, använder fel användar-ID eller hoppar över en nödvändig kontroll så commitar Postgres en felaktig ändring troget. Transaktioner förhindrar inte automatiskt alla affärslogiska konflikter (som att sälja slut lager) om du inte kombinerar dem med rätt constraints, lås eller isoleringsnivå.
När du uppdaterar mer än en tabell (eller mer än en rad) för att slutföra en enda verklig handling är det ett kandidatfall för en transaktion. Poängen är densamma: antingen är allt gjort, eller inget.
En orderprocess är det klassiska fallet. Du kanske skapar en orderrad, reserverar lager, tar en betalning och sedan markerar ordern som betald. Om betalningen lyckas men statusuppdateringen misslyckas har du fångat pengar med en order som fortfarande ser obetald ut. Om orderraden skapas men lagret inte reserveras kan du sälja artiklar du faktiskt inte har.
User onboarding går tyst sönder på samma sätt. Att skapa användaren, infoga en profilrad, tilldela roller och att registrera att ett välkomstmejl ska skickas är en logisk handling. Utan gruppering kan du få en användare som kan logga in men saknar behörigheter, eller en profil som finns utan användare.
Backoffice-åtgärder behöver ofta strikt "papper + tillstånd"-beteende. Att godkänna en begäran, skriva en audit-post och uppdatera ett saldo bör lyckas ihop. Om saldot ändras men audit-loggen saknas förlorar du bevis på vem som ändrade vad och varför.
Bakgrundsjobb drar också nytta av detta, särskilt när du bearbetar ett arbetsobjekt med flera steg: ta hand om objektet så att två arbetare inte gör samma sak, utför affärsuppdateringen, skriv ett resultat för rapportering och omförsök, och markera sedan objektet som klart (eller misslyckat med anledning). Om dessa steg glider isär skapar omförsök och samtidighet oreda.
Flerstegsfunktioner bryts när du behandlar dem som en hög av oberoende uppdateringar. Innan du öppnar en databas, skriv arbetsflödet som en kort berättelse med en tydlig mållinje: vad räknas exakt som "klart" för användaren?
Börja med att lista stegen i klartext och definiera sedan ett enda framgångsvillkor. Till exempel: "Order är skapad, lager är reserverat och användaren ser ett orderbekräftelsenummer." Allt mindre än det är inte framgång, även om vissa tabeller uppdaterats.
Dela därefter upp vad som är databasarbete och vad som är externt arbete. Databassteg är de som du kan skydda med transaktioner. Externa anrop som kortbetalningar, skicka e-post eller anropa tredjeparts-API:er kan misslyckas långsamt och oförutsägbart, och du kan vanligtvis inte rulla tillbaka dem.
Ett enkelt planeringssätt: separera steg i (1) måste vara allt-eller-inget, (2) kan ske efter commit.
Inuti transaktionen, behåll bara de steg som måste vara konsekventa tillsammans:
Flytta sidoeffekter utanför. Till exempel: commit ordern först, och skicka sedan bekräftelsemejlet baserat på en outbox-rad.
För varje steg, skriv vad som ska hända om nästa steg misslyckas. "Rollback" kan betyda en databasrollback, eller en kompensationsåtgärd.
Exempel: om betalningen lyckas men lagerreservationen misslyckas, bestäm i förväg om du ska återbetala omedelbart eller markera ordern som "betalning fångad, väntar på lager" och hantera det asynkront.
En transaktion säger till Postgres: behandla dessa steg som en enhet. Antingen händer alla eller inga. Det är det enklaste sättet att förhindra delvisa skrivningar.
Använd en databasanslutning (en session) från början till slut. Om du sprider stegen över olika anslutningar kan Postgres inte garantera allt-eller-inget-resultatet.
Sekvensen är enkel: begin, kör nödvändiga läsningar och skrivningar, commit om allt lyckas, annars rollback och returnera ett tydligt fel.
Här är ett minimalt exempel i 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;
Transaktioner håller lås medan de körs. Ju längre du håller dem öppna, desto mer blockerar du annat arbete och desto större är risken för timeouts eller deadlocks. Gör det nödvändigaste inuti transaktionen och flytta långsamma uppgifter (skicka e-post, anropa betalningsleverantörer, generera PDF:er) utanför.
När något misslyckas, logga tillräckligt med kontext för att reproducera problemet utan att läcka känsliga uppgifter: arbetsflödesnamn, order_id eller user_id, nyckelparametrar (amount, currency) och Postgres-felkod. Undvik att logga fullständiga payloads, kortdata eller personuppgifter.
Samtidighet är bara två saker som händer samtidigt. Föreställ dig två kunder som försöker köpa den sista konsertbiljetten. Båda skärmarna visar "1 kvar", båda klickar på Betala, och nu måste din app avgöra vem som får den.
Utan skydd kan båda begärandena läsa samma gamla värde och båda skriva en uppdatering. Så får du negativt lager, duplicerade reservationer eller en betalning utan order.
Radlås är den enklaste skyddslinjen. Du låser den specifika raden du tänker ändra, gör dina kontroller och uppdaterar sedan. Andra transaktioner som rör samma rad måste vänta tills du committar eller rollbackar, vilket förhindrar dubbla uppdateringar.
Ett vanligt mönster: starta en transaktion, välj lager-raden med FOR UPDATE, verifiera att det finns lager, minska den och lägg sedan in ordern. Det håller dörren medan du slutför de kritiska stegen.
Isoleringsnivåer styr hur mycket konstighet du tillåter från samtidiga transaktioner. Avvägningen är vanligtvis säkerhet kontra hastighet:
Håll lås korta. Om en transaktion sitter öppen medan du anropar ett externt API eller väntar på användarinteraktion skapar du långa väntetider och timeouter. Föredra en tydlig felväg: sätt en lock timeout, fånga felet och returnera "försök igen" istället för att låta förfrågningar hänga.
Om du behöver göra arbete utanför databasen (som att debitera ett kort), dela upp arbetsflödet: reservera snabbt, committa, gör sedan det långsamma, och finalisera med en annan kort transaktion.
Omförsök är normala i Postgres-baserade appar. En förfrågan kan misslyckas även när din kod är korrekt: deadlocks, statement timeouts, korta nätverksavbrott eller en serialiseringsfel vid högre isoleringsnivåer. Om du bara kör om samma handler riskerar du att skapa en andra order, debitera två gånger eller infoga duplicerade "event"-rader.
Lösningen är idempotens: operationen ska vara säker att köra två gånger med samma input. Databasen bör kunna känna igen "detta är samma begäran" och svara konsekvent.
Ett praktiskt mönster är att fästa en idempotency-nyckel (ofta en klientgenererad request_id) på varje flerstegsarbetsflöde och spara den på huvudposten, sedan lägga till en unik constraint på den nyckeln.
Till exempel: i kassan generera request_id när användaren klickar Betala, och sätt därefter in ordern med det request_id. Om ett omförsök sker träffar andra försöket den unika constrainten och du returnerar den befintliga ordern istället för att skapa en ny.
Vad som oftast spelar roll:
Håll omförsök-loopen utanför transaktionen. Varje försök bör starta en ny transaktion och köra om hela enheten av arbete från början. Att försöka igen inne i en misslyckad transaktion hjälper inte eftersom Postgres markerar den som aborted.
Ett litet exempel: din app försöker skapa en order och reservera lager men får timeout precis efter COMMIT. Klienten gör om ett omförsök. Med en idempotency-nyckel returnerar andra förfrågan den redan skapade ordern och hoppar över en andra reservation istället för att fördubbla arbetet.
Transaktioner håller ett flerstegsarbetsflöde ihop, men de gör inte automatiskt datan korrekt. Ett starkt sätt att undvika följderna av delvisa skrivningar är att göra "felaktiga" tillstånd svåra eller omöjliga i databasen, även om en bugg smiter in i applikationskoden.
Börja med grundläggande säkerhetsräls. Foreign keys ser till att referenser är riktiga (en orderrad kan inte peka på en saknad order). NOT NULL stoppar halvfyllda rader. CHECK-constraints fångar värden som inte är rimliga (till exempel quantity > 0, total_cents >= 0). Dessa regler körs vid varje skrivning, oavsett vilken tjänst eller skript som rör databasen.
För längre arbetsflöden, modellera tillståndsändringar explicit. Istället för många boolean-flaggor, använd en statuskolumn (pending, paid, shipped, canceled) och tillåt bara giltiga övergångar. Du kan tvinga detta med constraints eller triggers så att databasen vägrar illegala hopp som shipped -> pending.
Unikhet är en annan form av korrekthet. Lägg till unika constraints där dubbletter skulle bryta ditt arbetsflöde: order_number, invoice_number eller en idempotency_key som används för omförsök. Då, om din app försöker igen med samma request, blockerar Postgres andra inserten och du kan säkert returnera "redan behandlad" istället för att skapa en andra order.
När du behöver spårbarhet, lagra det uttryckligen. En audit-tabell (eller historiktabell) som registrerar vem som ändrade vad och när förvandlar "mystiska uppdateringar" till fakta du kan söka i vid incidenter.
De flesta delvisa skrivningar orsakas inte av "dålig SQL." De kommer från designbeslut i arbetsflödet som gör det lätt att committa bara halva berättelsen.
accounts sedan orders, men en annan uppdaterar orders sedan accounts, ökar du risken för deadlocks under belastning.Ett konkret exempel: i kassan reserverar du lager, skapar en order och debiterar sedan ett kort. Om du debiterar kortet inne i samma transaktion kan du hålla ett lagerlås medan du väntar på nätverket. Om debiteringen lyckas men transaktionen senare rullar tillbaka, har du debiterat kunden utan en order.
Ett säkrare mönster är: håll transaktionen fokuserad på databasstatus (reservera lager, skapa order, markera betalningsförsök som pending), commit, anropa sedan det externa API:et och skriv tillbaka resultatet i en ny kort transaktion. Många team implementerar detta med en enkel pending-status och ett bakgrundsjobb.
När ett arbetsflöde har flera steg (insert, update, charge, send) är målet enkelt: antingen registreras allt, eller inget.
Håll varje nödvändig databasskrivning inom en transaktion. Om ett steg misslyckas, rulla tillbaka och lämna datan precis som den var.
Gör framgångsvillkoret explicit. Till exempel: "Order är skapad, lager är reserverat och betalningsstatus är registrerad." Allt annat är en felväg som måste avbryta transaktionen.
BEGIN ... COMMIT-block.ROLLBACK, och anroparen får ett tydligt felresultat.Anta att samma begäran kan göras om. Databasen bör hjälpa dig att upprätthålla en-gång-regler.
Gör minsta möjliga arbete inuti transaktionen och undvik att vänta på nätverksanrop medan lås hålls.
Om du inte ser var det går fel kommer du att gissa vidare.
En checkout har flera steg som bör flytta tillsammans: skapa ordern, reservera lager, registrera betalningsförsöket och sedan markera orderstatus.
Föreställ dig att en användare klickar Köp för 1 artikel.
Inuti en transaktion gör du bara databasändringar:
orders-rad med status pending_payment.inventory.available eller skapa en reservations-rad).payment_intents-rad med en klientlevererad idempotency_key (unik).outbox-rad som "order_created".Om någon sats misslyckas (slut i lager, constraint-fel, krasch) rullar Postgres tillbaka hela transaktionen. Du får inte en order utan reservation, eller en reservation utan order.
Betalningsleverantören är utanför din databas, så behandla det som ett separat steg.
Om leverantörsanropet misslyckas innan du committar, avbryt transaktionen och inget skrivs. Om leverantörsanropet misslyckas efter commit, kör en ny transaktion som markerar betalningsförsöket som failed, släpper reservationen och sätter orderstatus till canceled.
Låt klienten skicka en idempotency_key per checkout-försök. Tvinga det med ett unikt index på payment_intents(idempotency_key) (eller på orders om du föredrar). Vid omförsök tittar din kod upp de befintliga raderna och fortsätter istället för att skapa en ny order.
Skicka inte e-post inne i transaktionen. Skriv en outbox-post i samma transaktion och låt en bakgrundsprocess skicka e-posten efter commit. Då mailar du aldrig för en order som rullats tillbaka.
Välj ett arbetsflöde som rör mer än en tabell: signup + köa välkomstmejl, checkout + lager, faktura + ledger-post eller skapa projekt + standardinställningar.
Skriv stegen först, och definiera sedan de regler som alltid måste gälla (dina invariants). Exempel: "En order är antingen fullt betald och reserverad, eller inte betald och inte reserverad. Aldrig halvreserverad." Omvandla de reglerna till en allt-eller-inget-enhet.
En enkel plan:
Testa sedan de fula fallen med avsikt. Simulera en krasch efter steg 2, en timeout precis innan commit och en dubbelexekvering från UI:t. Målet är tråkiga utfall: inga föräldralösa rader, inga dubbla avgifter, inget som hänger i pending för evigt.
Om du prototypar snabbt hjälper det att skissa arbetsflödet i ett planeringsverktyg innan du genererar handlers och schema. Till exempel har Koder.ai en Planning Mode och stöd för snapshots och rollback, vilket kan vara användbart medan du itererar på transaktionsgränser och constraints.
Gör detta för ett arbetsflöde den här veckan. Det andra går mycket snabbare.