Artikel Testning · Data och drift

Två anrop samtidigt och saldot blir fel: samtidighetsbuggar i AI-genererad kod

Be en modell om ett uttag, en platsbokning eller en räknare, och du får nästan alltid samma form: hämta värdet, kontrollera det i en if-sats, skriv tillbaka det nya värdet. Koden är läsbar, testerna är gröna och den fungerar varje gång du provar den själv. Sedan går den i drift, två anrop landar i samma millisekund, och saldot slutar stämma — utan ett enda felmeddelande i loggen.

Senast granskad: 19 aug 2026

Svartvit scen: till vänster en stor apliknande profil byggd av ett gitter av noder och tunna linjer. Mitt i bilden svävar en liten mörk urtavla med gröna markeringar, omgiven av vita cirkelstreck som ritar en snurrande rörelse. Från var sitt håll sträcker sig två kantiga figurer av samma nätverksmaterial mot tavlan i exakt samma ögonblick, båda med armen utsträckt och ingen av dem framme än.
Två som sträcker sig mot samma siffra i samma ögonblick — båda hinner läsa den innan någon av dem har ändrat den.

Kortfattat

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.

Ett test som inte kan misslyckas är inte ett test

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.

1
Atomär uppdatering — när ett tal ska räknas om. Låt 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.
2
Unikt index — när något bara får finnas en gång. En bokning per tid, ett konto per e-postadress, en betalning per order. Regeln hör hemma i schemat, inte i en if-sats: i PostgreSQL skapar ett unikt villkor automatiskt ett unikt B-tree-index på de angivna kolumnerna [2]. Med tio parallella trådar som alla först kollade om tiden var ledig och sedan skrev, hamnade tio identiska bokningar i tabellen utan index — och exakt en med, medan de nio övriga fick ett integritetsfel. Det felet är inte en olycka att logga bort utan svaret "tiden är tagen": fånga det och översätt det till ett vanligt användarmeddelande. En fallgrop att känna till: som standard räknas två null-värden inte som lika, så ett unikt villkor på en kolumn som tillåter null hindrar inte flera rader med null där [2].
3
Transaktion med radlås — när beslutet spänner över flera satser. Ska du läsa en rad, göra något i applikationen och sedan skriva till en annan tabell går det inte att pressa in i en enda sats. Då behöver läsningen låsa. MySQL:s dokumentation är rakt på sak om varför en vanlig läsning inte räcker: frågar du ut data och infogar eller uppdaterar relaterad data i samma transaktion ger en vanlig 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].
Isoleringsnivån är inte densamma i olika motorer

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.

Beror det skrivna värdet på ett värde som lästes tidigare i samma anrop? Ett 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.
Finns en if-sats som vaktar en skrivning? Formuleringar som "finns inte redan", "är ledig", "har tillräckligt" eller "är under gränsen" är kontroller vars sanning hinner bli inaktuell. Fråga var samma regel finns i schemat.
Är transaktionen verkligen öppen över både läsning och skrivning? Många ORM-uppsättningar kör med autocommit som standard, så två satser som ser ut att ligga i samma block kan i själva verket vara två skilda transaktioner. Kontrollera var transaktionen öppnas, inte var koden ser sammanhållen ut.
Kontrolleras antalet påverkade rader? En villkorad uppdatering utan kontroll av returvärdet ser säker ut men svarar ja till användaren även när noll rader ändrades.
Finns ett test som faktiskt kör två anrop samtidigt? Saknas det finns ingen kontroll som fångar en regression här — och just den här buggen kommer tillbaka varje gång någon skriver om funktionen utan att känna till skälet.

Källor

[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.