Felet heter läs–modifiera–skriv: koden läser ett värde, fattar ett beslut om det och skriver tillbaka ett resultat som beräknats ur den lästa kopian. Mellan läsningen och skrivningen finns ett tidsfönster där någon annan hinner göra exakt samma sak, och den sista skrivningen skriver över den första. Det syns aldrig i ett enanvändartest, för där finns bara en aktör. Åtgärden är att flytta beslutet in i databasen: en atomär uppdatering som räknar i UPDATE-satsen i stället för i applikationen, ett unikt index som gör dubbletten fysiskt omöjlig, eller en transaktion med radlås som håller raden medan beslutet fattas. Ytterligare en if-sats i applikationskoden är inte ett av alternativen — den flyttar bara fönstret några mikrosekunder.
Mönstret modellen skriver, och varför det ser rätt ut
Nästan all AI-genererad kod för den här sortens uppgift följer samma tre steg. Ett uttag ur ett saldo blir:
saldo = db.query("SELECT saldo FROM konto WHERE id = %s", konto_id)
if saldo >= belopp:
db.execute("UPDATE konto SET saldo = %s WHERE id = %s",
saldo - belopp, konto_id)
Koden är korrekt läst uppifrån och ned. Problemet är att den beskriver ett förlopp med en enda aktör, medan produktionen har många. OWASP beskriver den här klassen som time-of-check to time-of-use: två samtidiga aktörer passerar båda samma kontroll innan någon av dem hunnit slutföra sin åtgärd, och båda agerar sedan på ett tillstånd som redan hunnit ändras [1]. Kontrollen if saldo >= belopp är sann för båda, skrivningen sker för båda, och eftersom värdet saldo - belopp räknades ut ur var sin egen redan inaktuella kopia landar bara den sista på raden.
Det gör felet dubbelt lömskt. Inget undantag kastas, inget villkor bryts, ingen rad markeras som trasig. Databasen gjorde precis vad den blev ombedd att göra: den skrev talet som applikationen räknade fram. Bara summan är fel, och det upptäcks oftast först vid en avstämning dagar senare, eller av en kund som ser en dubbelbokad tid.
Den vanligaste rättningen är också den enda som garanterat inte hjälper: att lägga till ännu en kontroll strax före skrivningen. Två läsningar i följd i samma applikationskod ger två inaktuella kopior i stället för en. Fönstret krymper från millisekunder till mikrosekunder, men det försvinner inte, och det slås ut helt så fort trafiken ökar.
Det här är inte samma sak som idempotens
De två problemen blandas ofta ihop eftersom symtomet — en dubblett — ser likadant ut. Skillnaden ligger i vem som anropar.
Idempotens handlar om samma logiska anrop som kommer fram två gånger: klienten gjorde ett omförsök efter en tidsgräns, eller en webhook levererades om. Skyddet är en nyckel per operation som mottagaren kan känna igen och svara likadant på. Den genomgången äger API-integrationer med AI, och den upprepas inte här.
Samtidighet handlar om två olika anrop mot samma delade tillstånd i samma ögonblick. Två användare, två giltiga förfrågningar, två skilda idempotensnycklar. Ett idempotensskydd hjälper inte alls här: nycklarna är olika, mottagaren har aldrig sett någon av dem förut, och båda anropen ska besvaras — bara inte båda med ett ja. Det är en distinktion värd att göra uttryckligen i en kodgranskning, eftersom en modell gärna föreslår en idempotensnyckel som svar på en fråga om kapplöpning.
Reproducera felet med ett avsiktligt parallellt test
Ett fel som bara uppträder ibland är svårt att prata om. Ett fel som du kan framkalla på begäran är en buggrapport. Nyckeln är att inte förlita dig på tur: låt trådarna vänta på varandra vid en gemensam spärr och släppas samtidigt, i stället för att strö in väntetider och hoppas på en krock.
Testet nedan använder bara Pythons standardbibliotek, så det går att köra som det står utan installation. Databasen är SQLite eftersom den följer med språket. Mönstret och slutsatsen är desamma mot PostgreSQL eller MySQL, där du byter sqlite3.connect mot din drivrutin och låter varje tråd ta en egen anslutning.
import sqlite3, threading, tempfile, os
DB = os.path.join(tempfile.mkdtemp(), "konto.db")
def bygg():
c = sqlite3.connect(DB)
c.execute("CREATE TABLE konto (id INTEGER PRIMARY KEY, saldo INTEGER NOT NULL)")
c.execute("INSERT INTO konto VALUES (1, 100)")
c.commit(); c.close()
def uttag_naivt(): # las, kontrollera, skriv tillbaka
c = sqlite3.connect(DB, timeout=10)
saldo = c.execute("SELECT saldo FROM konto WHERE id = 1").fetchone()[0]
if saldo >= 10:
c.execute("UPDATE konto SET saldo = ? WHERE id = 1", (saldo - 10,))
c.commit(); c.close()
def kor(uttag, n=10):
grind = threading.Barrier(n) # alla slapps i samma ogonblick
def arbetare():
grind.wait()
uttag()
tradar = [threading.Thread(target=arbetare) for _ in range(n)]
for t in tradar: t.start()
for t in tradar: t.join()
c = sqlite3.connect(DB)
print(c.execute("SELECT saldo FROM konto WHERE id = 1").fetchone()[0])
bygg(); kor(uttag_naivt)
Tio parallella uttag på 10 kr från ett saldo på 100 kr ska lämna 0 kr kvar. På Python 3.14.2 med SQLite 3.50.4 under Windows 11 gav sex körningar i följd 80 eller 90 kr kvar — alltså registrerades bara ett eller två av tio uttag, och åtta till nio belopp försvann ur bokföringen utan att ett enda undantag kastades. Samma test med en extra kontrolläsning inlagd strax före skrivningen gav samma spann, 80 eller 90 kr, i fem körningar: den extra if-satsen ändrade ingenting mätbart.
Kör alltid det parallella testet mot den ofixade koden först och se det falla. Ett samtidighetstest som skrivs efter åtgärden passerar ofta av fel skäl — för få trådar, en spärr som inte används, eller en testdatabas som råkar serialisera allt — och då bevakar det ingenting. Notera också att utfallet är ett spann, inte ett exakt tal: rapportera intervallet över flera körningar i stället för en enda siffra, och ange runtime- och databasversion, eftersom både trådschemaläggning och låsbeteende skiljer sig mellan miljöer.
Det här är samma genre av fel som beskrivs i Tidszonstesta AI-genererad kod: en felklass som passerar en normal testsvit därför att testet aldrig återskapar det villkor som utlöser felet. Där är villkoret en klocka, här är det två aktörer.
Tre åtgärder som håller — välj efter vad som ska skyddas
Alla tre flyttar beslutet från applikationen till databasen. Vilken du väljer beror på om du skyddar ett räknat värde, en unik plats eller ett förlopp som spänner över flera satser.
UPDATE-satsen både räkna och villkora, så att den lästa kopian aldrig lämnar databasen: UPDATE konto SET saldo = saldo - 10 WHERE id = 1 AND saldo >= 10. I samma test som ovan gav den varianten 0 kr kvar i samtliga sex körningar. Poängen är inte bara formeln utan returvärdet: satsen påverkar noll rader när villkoret inte håller, och den siffran är ditt affärssvar. Kontrollera antalet påverkade rader och avvisa uttaget när det är noll — struntar du i det har du bytt en tyst felräkning mot ett tyst misslyckande.SELECT inte tillräckligt skydd, eftersom andra transaktioner kan uppdatera eller radera precis de rader du nyss läste [3]. Lösningen är en låsande läsning, SELECT ... FOR UPDATE, som läser den senast tillgängliga datan och sätter exklusiva lås på varje rad den läser [3].Ett antagande som kostar tid vid ett byte av databasmotor: PostgreSQL kör som standard på Read Committed [4], medan InnoDB i MySQL kör på Repeatable Read [5]. Att höja nivån är ett giltigt alternativ till radlås i PostgreSQL, men det kommer med ett krav: dokumentationen är uttrycklig med att applikationer på den nivån måste vara beredda att göra om transaktioner vid serialiseringsfel, och att en transaktion som får felet ska avbrytas och köras om från början [4]. Har den AI-genererade koden ingen omförsöksslinga runt transaktionen är en höjd isoleringsnivå inte en färdig lösning utan en ny felväg. Avvägningen i övrigt: lås och omförsök kostar genomströmning, och lås som tas i olika ordning på olika ställen i koden ger dödlägen i stället för felräkningar.
Migreringen som lägger på det unika indexet är för övrigt sin egen risk, särskilt om tabellen redan innehåller dubbletter som måste städas först. Det förloppet, och den låsning en schemaändring själv orsakar, hör till Databasmigreringar med AI.
Fem frågor att ställa till diffen
Det går att hitta mönstret i en kodgranskning utan att köra något. Leta efter avståndet mellan en läsning och den skrivning som beror på den.
UPDATE som sätter ett tal applikationen räknat fram är misstänkt. Ett UPDATE som räknar ur kolumnen själv är det inte.
[1] OWASP, Race Conditions — definition av kapplöpningsfel och time-of-check to time-of-use.
[2] PostgreSQL-dokumentationen, Constraints — ett unikt villkor skapar automatiskt ett unikt B-tree-index, och två null-värden räknas som standard inte som lika.
[3] MySQL 8.4 Reference Manual, Locking Reads — en vanlig SELECT ger inte tillräckligt skydd i en läs-och-skriv-transaktion, medan SELECT ... FOR UPDATE läser senast tillgängliga data och sätter exklusiva radlås.
[4] PostgreSQL-dokumentationen, Transaction Isolation — Read Committed är standardnivå, och på Repeatable Read måste applikationen vara beredd att göra om transaktioner vid serialiseringsfel.
[5] MySQL 8.4 Reference Manual, Transaction Isolation Levels — Repeatable Read är InnoDB:s standardnivå.
Samtliga källor lästa 19 augusti 2026. Mätvärdena i texten kommer från testkörningarna som beskrivs i avsnittet om parallella tester, körda på Python 3.14.2 med SQLite 3.50.4 under Windows 11. De är exempel på storleksordning i den miljön, inte allmängiltiga tal.