Den dag halvdelen af ordren forsvandt ud af databasen
Det øjeblik jeg opdagede, at SQL alene ikke redder dine data
Jeg troede længe, at hvis bare min SQL var korrekt, så var jeg nogenlunde sikker. INSERT de rigtige steder, UPDATE med en god WHERE, husk et par indeks, og så kører bussen.
Den illusion døde den dag, jeg stod med en ordre i databasen uden tilhørende ordrelinjer. Kunden havde fået penge trukket, men der var intet at sende. Halvskrevne data. Ingen syntaksfejl, ingen exceptions. Bare et system, der havde gjort halvdelen af sit arbejde og var tilfreds med det.
Det var der, jeg for alvor forstod, hvad en database transaktion egentlig er: en aftale om, at enten sker det hele, eller også sker der ingenting.
Og ja, du kan bygge ret meget uden at tænke over transaktioner. Indtil du en dag skal rydde op manuelt, fordi noget fejlede midt i et forløb, og brugerne begynder at melde mærkelige fejl.
Transaktioner uden mystik – én bruger, én handling, én samlet aftale
Lad mig starte med den mentale model, jeg selv bruger:
En transaktion er: “Denne pakke af ændringer hører sammen. Database, enten tager du det hele, eller også glemmer du alt om det.”
I SQL ser det helt kedeligt ud:
BEGIN;
INSERT INTO orders (user_id, total_amount)
VALUES (42, 299.00)
RETURNING id;
INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (123, 10, 1, 299.00);
COMMIT; -- gem ændringerne permanent
Det vigtige er ikke syntaksen, men rammen:
Fra BEGIN til COMMIT siger du: alt det her hører sammen. Hvis noget mislykkes, kalder du ROLLBACK, og databasen ruller tilbage som om du aldrig havde rørt noget.
Uden transaktioner kan du sagtens ende med, at ordren bliver oprettet, men ordrelinjerne fejler. Eller at du trækker penge, men ikke registrerer betalingen som “succes”.
Et lille webapp-eksempel
Forestil dig en simpel webshop-backend i noget Node eller Python. En ny ordre skal gøre tre ting:
1) Oprette en ordre
2) Oprette ordrelinjer
3) Reservere lager
Hvis du gør det sådan her uden transaktion:
// pseudo
createOrder(user)
createOrderItems(orderId, items)
reserveStock(items)
og kald nummer 3 fejler, så står du nu med en ordre, som du ikke kan levere, men som ser helt fin ud i databasen.
Med en transaktion siger du til databasen: “Hvis trin 3 fejler, så fortryd trin 1 og 2 sammen med det.”
Tre situationer hvor du SKAL bruge en database transaktion
Der er masser af steder, hvor du kan leve uden transaktioner. Men der er også nogle mønstre, hvor jeg i dag næsten per refleks siger: “Det her bliver pakket ind i én.”
1. Penge, point, saldo og alt der ligner
Hvis du flytter værdi mellem to steder, så er der ingen vej uden om. Saldokorrektioner, point i et loyalitetssystem, interne credits, alt det der.
Eksempel: Overførsel mellem to konti.
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
COMMIT;
Fejler den anden UPDATE, vil du
Den slags bugs roder man ikke bare lige op i med et hurtigt script.
2. Flere tabeller der tilsammen beskriver én handling
Det var her, min halve ordre kom fra. Din bruger gør én ting, men du skriver til tre-fire tabeller for at gemme den ting.
Eksempler fra hverdagen:
Du opretter en bruger, en profil og et audit-log. Eller en booking, deltagerlinjer og en notifikation.
Hvis det er én brugerhandling, så bør det også være én transaktion. Ellers ender du før eller siden med “spøgelsesrækker”: ordrer uden linjer, bookinger uden deltagere osv.
3. Unikke regler der skal håndhæves under pres
Den tredje type situation er der, hvor to brugere kan ramme den samme begrænsning samtidig.
Eksempel: Der er kun 10 pladser tilbage på et kursus. To brugere klikker “Tilmeld” næsten samtidig.
Hvis du bare gør:
SELECT seats_left FROM events WHERE id = 1;
-- i app-kode: tjek om seats_left >= requested
INSERT INTO registrations ...
UPDATE events SET seats_left = seats_left - requested
WHERE id = 1;
uden transaktion og uden låsning, så kan begge nå igennem dit tjek, og du får for mange tilmeldte. Hej race condition database-bug.
Her vil du både have en transaktion og typisk enten:
– en unik constraint (mere om det om lidt)
– eller en form for låsning, så to opdateringer ikke kan ske “samtidig” på den samme række
Når du roligt kan lade være med at bruge transaktioner
Jeg ser også det modsatte problem: alt bliver smidt ind i én enorm transaktion “for en sikkerheds skyld”.
Det gør koden tungere, og det gør databasen unødigt stresset. Så hvornår giver det mening ikke at tænke transaktion?
Læsninger og simple opslag
Hvis du bare SELECTer data for at vise dem, så er transaktioner sjældent relevante. Du ændrer jo ikke noget.
Databasen sørger selv for, at dit SELECT ser et konsistent øjebliksbillede, afhængigt af isolation level. Du behøver typisk ikke selv at pakke det ind.
Engangs-importer og baggrunds-jobs
Nogle ting er simpelthen for store.
Hvis du migrerer en million rækker, vil du sjældent pakke det hele i én transaktion. Fortrydelsesknappen bliver for tung. Du kan ikke have en transaktion åben i flere minutter uden at begynde at blokere andet.
Her opdeler jeg typisk i batches:
for hver 500 rækker:
BEGIN
importer 500 rækker
COMMIT
Fejler én batch, så kan jeg køre den for sig selv igen. Men jeg risikerer ikke at skulle starte forfra på alt, hver gang noget går galt.
Idempotente operationer
Hvis du har bygget operationen sådan, at du trygt kan køre den igen og igen uden at ødelægge noget, så er kravet til transaktioner lidt lavere.
Et eksempel er et script, der en gang i timen opdaterer et felt baseret på andre felter. Hvis scriptet kun laver UPDATE ... SET field = udregnet_værdi og ikke indsætter nye rækker, kan du som regel leve uden transaktioner, fordi du kan genkøre det.
Det her er i øvrigt en strategi, der ofte er nemmere at leve med end at prøve at gøre alting perfekt med transaktionsmagi fra starten.
ACID forklaret med rigtige problemer, ikke med lærebogs-sætninger
Hvis du googler database transaktion, falder du hurtigt over ACID. Det lyder meget teoretisk, men i praksis er det bare fire typer hovedpine, du slipper for, hvis databasen gør sit arbejde.
Atomicity – farvel til halvskrevne data
Atomicity siger: “Alt eller intet.”
Min halve ordre var et brud på atomicity. Jeg startede på en logisk operation (opret en ordre), men endte i midten.
Med en transaktion får du garantien: hvis noget fejler inde i transaktionen, så bliver ingen af ændringerne gemt.
Consistency – dine regler gælder også kl. 03.17 om natten
Consistency betyder: efter en transaktion er databasen stadig i en tilstand, der overholder alle reglerne.
Hvis du f.eks. har en constraint om, at saldo ikke må være negativ, eller at reference-nøgler skal pege på noget, så må en gennemført transaktion ikke efterlade databasen i en tilstand, der bryder de regler.
Det er databasen, der håndhæver meget af det. Men det er dig, der skal sørge for at samle de rigtige ting i samme transaktion.
Isolation – det der sker parallelt må ikke ødelægge dit billede
Isolation handler om, hvordan samtidige transaktioner “ser” hinanden.
Tre typiske bugs:
– To kunder får samme sæde, fordi de begge ser det som ledigt
– Rabat bliver brugt to gange, fordi “brugt=false” læses samtidig af to transaktioner
– En rapport summerer tal baseret på en blanding af gamle og nye værdier
Der findes forskellige isolation levels. I f.eks. PostgreSQL: READ COMMITTED, REPEATABLE READ og SERIALIZABLE. De styrer, hvor “rent” et billede du får, mens der skrives omkring dig.
De fleste systemer lever fint på READ COMMITTED, men når du begynder at se mystiske race conditions, kan det give mening at overveje isolation mere bevidst.
Durability – hvis du siger ja, mener du det også efter et crash
Durability er den mindst spændende, indtil disken en dag dør.
Pointen er: når databasen har sagt COMMIT, så er data skrevet på en måde, så den kan komme op igen efter et crash uden at glemme, hvad den lovede.
Som applikationsudvikler kan du sjældent gøre så meget her, ud over at vælge en database, der tager det alvorligt, og sætte dens write-ahead log og backups fornuftigt op. Men det er rart at vide, at transaktionen ikke kun er et “måske”.
Unikke constraints og transaktioner – to værktøjer der bør bo sammen
Noget af det mest undervurderede er kombinationen af:
– Unikke constraints i databasen
– Transaktioner i koden
Eksempel: Du vil undgå dublet-brugere på samme email.
CREATE UNIQUE INDEX users_email_key ON users(email);
I stedet for at lave “SELECT … WHERE email = ?” og selv tjekke for eksistens hver gang, kan du lade databasen fejle, hvis nogen prøver at indsætte en mail, der allerede findes.
I koden kan du så gøre:
BEGIN;
INSERT INTO users (email, name)
VALUES ('test@example.com', 'Jonas');
-- måske andre ting her
COMMIT;
Hvis to requests prøver at oprette samme email næsten samtidig, vil én af dem vinde. Den anden får en fejl om unik constraint-brud.
Det vigtige er, at det sker inden i en transaktion. Så hvis du har flere trin (oprettelse af profil, audit-log osv.), følger de med i fortrydelsen.
På Coding Class prøver jeg generelt at skubbe brugere i retning af at lægge så meget domænelogik som muligt ned i databasen, netop via constraints. For så kan transaktioner rent faktisk beskytte noget.
Transaktioner i ORM – den skjulte magi og de klassiske fodfejl
Mange rammer ORM-verdenen (Prisma, Sequelize, TypeORM, Django ORM, Entity Framework osv.) før de rammer ren SQL.
ORM’er har næsten altid et eller andet transaktions-API. Og det bliver næsten altid misbrugt første gang, inklusiv af mig.
To typiske fejl jeg selv har lavet
Fejl 1: Tro, at “hver request er automatisk en transaktion”
Nogle frameworks gør det, andre gør ikke. Og selv når de gør, kan scope let blive galt. F.eks. at transaktionen kun dækker database-kald, men ikke eksterne API-kald og lignende.
Du bliver nødt til at læse dokumentationen og verificere i logs, hvornår der faktisk køres COMMIT og ROLLBACK.
Fejl 2: Glemme at alt indeni callbacken skal bruge samme connection
I flere ORMs skriver du noget i stil med:
db.transaction(async (trx) => {
await trx.user.create(...)
await trx.order.create(...)
})
Hvis du så inde i funktionen bare bruger den globale db i stedet for trx, ryger de queries uden for transaktionen.
Resultat: du tror, du har rullet alt tilbage, men halvdelen blev kørt udenfor. Det er en sikker vej til halvskrevne data.
Gode vaner med ORM-transaktioner
Jeg prøver at holde mig til nogle få simple regler:
– Alt der hører til en brugerhandling, pakkes i én transaktion i service-laget
– Inde i transaktionen bruger jeg konsekvent den transaktionsspecifikke client (ofte kaldet trx eller tx)
– Jeg kalder ikke eksterne systemer efter COMMIT, hvis deres svar skal hænge logisk sammen med data
Og så skriver jeg gerne en lille kommentar ved vigtige transaktioner i koden: “Denne transaktion sørger for X + Y + Z hænger sammen”. Det hjælper både mig selv om tre måneder og den næste, der læser koden.
Sådan spotter du halvskrevne data i dine egne logs
Hvis du allerede har et system i drift, er sandsynligheden ret stor for, at du allerede har halvskrevne data et sted. Du har bare ikke set dem endnu.
Der er nogle mønstre, jeg leder efter, når jeg mistænker transaktionsproblemer.
Den klassiske: “INSERT success, men efterfølgende exception”
Tjek dine logs for stack traces hvor:
– der lige inden fejlen er logget en “created order X” eller “inserted user Y”
– men fejlbeskeden handler om noget, der skete efter det
Hvis du ikke ruller noget tilbage, når den fejl sker, står du sandsynligvis med halvskrevet domænehandling.
Mismatch mellem tabeller der burde følges ad
Et hurtigt SQL-tjek kan være afslørende. Eksempel med ordrer:
SELECT o.id
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
GROUP BY o.id
HAVING COUNT(i.id) = 0;
Får du noget tilbage her, har du ordrer uden linjer. Det kan være helt legitimt, hvis du f.eks. tillader tomme udkast. Men hvis det ikke er meningen, har du en transaktionslæk.
Samme trick kan bruges for brugere uden profiler, bookinger uden deltagere osv.
Uforklarlige “hvordan kan det ske?”-supporttickets
Når en bruger skriver: “Jeg fik en kvittering, men ordren er der ikke” eller “jeg mistede point, men ser ingen transaktion”, så er transaktionsfejl en god kandidat.
Her er det guld værd at have korrelerede logs (request-id, user-id) og kunne se rækkefølgen:
– request modtog
– noget blev skrevet i databasen
– der opstod en fejl
– der kom ikke nogen ROLLBACK, fordi du ikke havde en transaktion
Den slags gennemgang har lært mig mere om mit eget system end mange timers generel teori.
En lille huskeregel til dine egne transaktionsgrænser
Jeg slutter med den regel, jeg selv bruger, når jeg sidder ved spisebordet med kaffe, laptop og et halvfærdigt sideprojekt.
Når jeg koder en ny funktion, spørger jeg mig selv:
“Hvis jeg trækker strømstikket her midt i forløbet, vil jeg så være ok med den tilstand, databasen står tilbage i?”
Hvis svaret er nej, pakker jeg det ind i en transaktion.
Hvis svaret er ja, eller jeg kan gøre funktionen idempotent og retry-venlig, lader jeg ofte være. Så slipper jeg for unødigt lange låse og komplekse grænser.
Næste gang du kigger på dit eget projekt, kunne du prøve at tage én funktion ad gangen og stille det spørgsmål. Jeg er ret sikker på, at der dukker mindst én overraskelse op, hvor svaret er: “Ok, det her skal faktisk være alt-eller-intet.”
Og så bliver det spændende at se, om du kan fange de halvskrevne data, inden dine brugere gør det for dig.









1 kommentar