Le race condition nelle app CRUD possono causare ordini duplicati e totali errati. Scopri i punti di collisione più comuni e come risolverli con vincoli, blocchi e protezioni nell'interfaccia utente.

Una race condition si verifica quando due (o più) richieste aggiornano gli stessi dati quasi nello stesso momento e il risultato finale dipende dal timing. Ogni richiesta sembra corretta da sola. Insieme, producono un risultato sbagliato.
Un esempio semplice: due persone cliccano Salva sullo stesso record cliente nello spazio di un secondo. Una aggiorna l'email, l'altra il numero di telefono. Se entrambe le richieste inviano l'intero record, la seconda scrittura può sovrascrivere la prima e una modifica scompare senza errore.
Lo si vede più spesso in app veloci perché gli utenti possono generare più azioni al minuto. Si amplifica anche nei momenti di picco: vendite lampo, chiusura di fine mese, una grande campagna email o ogni volta che un backlog di richieste colpisce le stesse righe.
Gli utenti raramente segnalano "una race condition." Segnalano sintomi: ordini o commenti duplicati, aggiornamenti mancanti ("l'ho salvato ma è tornato indietro"), totali strani (magazzino negativo, contatori che diminuiscono) o stati che si ribaltano inaspettatamente (approvato, poi di nuovo in attesa).
I ritentativi peggiorano la situazione. Gli utenti fanno doppio clic, ricaricano dopo una risposta lenta, inviano da due schede o hanno reti instabili che spingono browser e app a reinviare. Se il server tratta ogni richiesta come una nuova scrittura, puoi ottenere due create, due pagamenti o due cambi di stato che erano intesi per essere unici.
Molte app CRUD sembrano semplici: leggi una riga, cambia un campo, salva. Il problema è che la tua app non controlla il timing. Database, rete, retry, lavori in background e comportamento degli utenti si sovrappongono.
Un trigger comune è che due persone modificano lo stesso record. Entrambi caricano gli stessi valori 'correnti', entrambi fanno modifiche valide e l'ultimo salvataggio sovrascrive silenziosamente il primo. Nessuno ha fatto nulla di sbagliato, ma un aggiornamento va perso.
Succede anche con una sola persona. Un doppio clic sul pulsante Salva, toccare avanti/indietro o una connessione lenta che spinge a premere Invio di nuovo può mandare la stessa scrittura due volte. Se l'endpoint non è idempotente, puoi creare duplicati, addebitare due volte o far avanzare uno stato di due passi.
L'uso moderno aumenta le sovrapposizioni. Più schede o dispositivi con lo stesso account possono inviare aggiornamenti conflittuali. Job in background (email, fatturazione, sincronizzazione, pulizia) possono toccare le stesse righe delle richieste web. Retry automatici sul client, sul bilanciatore di carico o sul job runner possono ripetere una richiesta già riuscita.
Se stai rilasciando funzionalità velocemente, lo stesso record viene spesso aggiornato da più punti rispetto a quelli che ci si ricorda. Se usi un builder guidato da chat come Koder.ai, l'app può crescere ancora più in fretta, quindi vale la pena trattare la concorrenza come comportamento normale, non come caso limite.
Le race condition raramente appaiono nelle demo "crea un record". Appaiono dove due richieste toccano la stessa fonte di verità quasi nello stesso momento. Conoscere i punti caldi abituali ti aiuta a progettare scritture sicure fin dall'inizio.
Qualsiasi cosa che sembri "basta aggiungere 1" può rompersi sotto carico: like, conteggi di visualizzazioni, totali, numeri di fattura, numeri dei ticket. Il pattern rischioso è leggere il valore, aggiungere e poi scriverlo. Due richieste possono leggere lo stesso valore iniziale e sovrascriversi a vicenda.
Workflow come Bozza -> Inviato -> Approvato -> Pagato sembrano lineari, ma le collisioni sono comuni. I problemi iniziano quando due azioni sono possibili contemporaneamente (approvare e modificare, annullare e pagare). Senza protezioni, puoi ottenere un record che salta passi, torna indietro o mostra stati diversi in tabelle diverse.
Tratta i cambiamenti di stato come un contratto: permetti solo il passo successivo valido e rifiuta tutto il resto.
Posti rimanenti, conteggi di stock, slot appuntamento e campi "capacità residua" creano il classico problema di oversell. Due acquirenti completano il checkout nello stesso momento, entrambi vedono disponibilità e entrambi riescono. Se il database non è il giudice finale, finirai per vendere più di quanto hai.
Alcune regole sono assolute: un'email per account, un abbonamento attivo per utente, un carrello aperto per utente. Queste spesso falliscono quando prima verifichi ("esiste già?") e poi inserisci. In concorrenza, entrambe le richieste possono superare il controllo.
Se generi flussi CRUD rapidamente (per esempio, parlando con Koder.ai), annota presto questi punti sensibili e sostienili con vincoli e scritture sicure, non solo controlli UI.
Molte race condition iniziano con qualcosa di noioso: la stessa azione viene inviata due volte. Gli utenti fanno doppio clic. La rete è lenta quindi cliccano ancora. Un telefono registra due tocchi. A volte non è voluto: la pagina ricarica dopo un POST e il browser offre di reinviare.
Quando succede, il backend può eseguire due create o update in parallelo. Se entrambe riescono, ottieni duplicati, totali sbagliati o un cambiamento di stato eseguito due volte (per esempio, approva e ancora approva). Sembra casuale perché dipende dal timing.
L'approccio più sicuro è la difesa in profondità. Correggi la UI, ma assumi che la UI fallirà.
Modifiche pratiche che puoi applicare alla maggior parte dei flussi di scrittura:
Esempio: un utente tocca "Paga fattura" due volte sul mobile. La UI dovrebbe bloccare il secondo tap. Il server dovrebbe anche rifiutare la seconda richiesta quando vede la stessa idempotency key, restituendo il risultato di successo originale invece di addebitare due volte.
I campi di stato sembrano semplici finché due cose non provano a cambiarli contemporaneamente. Un utente clicca Approva mentre un job automatico segna lo stesso record come Scaduto, o due membri del team lavorano sullo stesso elemento in schede diverse. Entrambi gli aggiornamenti possono riuscire, ma lo stato finale dipende dal timing, non dalle tue regole.
Tratta lo stato come una piccola macchina a stati. Mantieni una tabella breve di mosse consentite (per esempio: Bozza -> Inviato -> Approvato, e Inviato -> Respinto). Poi ogni scrittura controlla: "Questa mossa è consentita dallo stato corrente?" Se no, rifiutala invece di sovrascrivere silenziosamente.
Il locking ottimistico ti aiuta a intercettare aggiornamenti obsoleti senza bloccare gli altri utenti. Aggiungi un numero di versione (o updated_at) e richiedi che corrisponda quando salvi. Se qualcun altro ha cambiato la riga dopo averla caricata, il tuo update non modifica righe e puoi mostrare un messaggio chiaro tipo "Questo elemento è cambiato, aggiorna e riprova."
Un pattern semplice per gli aggiornamenti di stato è:
Inoltre, centralizza i cambi di stato in un solo posto. Se gli aggiornamenti sono sparsi tra schermi, job in background e webhook, ti perderai una regola. Mettili dietro una singola funzione o endpoint che applica gli stessi controlli di transizione ogni volta.
Il bug più comune sui contatori sembra innocuo: l'app legge un valore, aggiunge 1 e poi lo riscrive. Sotto carico, due richieste possono leggere lo stesso numero e scrivere entrambe lo stesso nuovo numero, quindi un incremento va perso. È facile non accorgersene perché "di solito funziona" nei test.
Se un valore viene solo incrementato o decrementato, lascia che sia il database a farlo in una sola istruzione. Così il database applica i cambiamenti in modo sicuro anche con molte richieste contemporanee.
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
La stessa idea vale per inventario, conteggi visualizzazioni, contatori di retry e tutto ciò che può essere espresso come "nuovo = vecchio + delta".
I totali spesso vanno storti quando memorizzi un numero derivato (order_total, account_balance, project_hours) e lo aggiorni da più posti. Se puoi calcolare il totale dalle righe sorgente (righe dell'ordine, voci di registro), eviti tutta una classe di bug di drift.
Quando devi memorizzare un totale per velocità, trattalo come una scrittura critica. Mantieni gli aggiornamenti delle righe sorgente e del totale memorizzato nella stessa transazione. Assicurati che un solo writer possa aggiornare lo stesso totale alla volta (locking, aggiornamenti guardati o un percorso con un solo proprietario). Aggiungi vincoli che impediscano valori impossibili (per esempio, inventario negativo). Poi riconcilia occasionalmente con un job che ricalcola e segnala le discrepanze.
Un esempio concreto: due utenti aggiungono articoli allo stesso carrello contemporaneamente. Se ogni richiesta legge cart_total, aggiunge il prezzo dell'articolo e riscrive, un'aggiunta può sparire. Se aggiorni gli articoli del carrello e il totale insieme in una transazione, il totale resta corretto anche sotto clic paralleli pesanti.
Se vuoi meno race condition, inizia dal database. Il codice dell'app può ritentare, andare in timeout o eseguire due volte. Un vincolo di database è la porta finale che resta corretta anche quando due richieste arrivano insieme.
I vincoli di unicità fermano i duplicati che "non dovrebbero mai succedere" ma succedono: indirizzi email, numeri d'ordine, ID fattura o la regola "un abbonamento attivo per utente". Quando due registrazioni arrivano insieme, il database accetta una riga e rifiuta l'altra.
Le foreign key impediscono riferimenti rotti. Senza di loro, una richiesta può cancellare un record padre mentre un'altra crea un figlio che punta al nulla, lasciando righe orfane difficili da pulire.
I check constraint mantengono i valori in un range sicuro e impongono semplici regole di stato. Per esempio, quantity >= 0, rating tra 1 e 5 o status limitato a un set consentito.
Tratta i fallimenti di vincolo come esiti attesi, non come "errori del server". Intercetta violazioni di unicità, foreign key e check, restituisci un messaggio chiaro tipo "Quell'email è già in uso" e registra dettagli per il debug senza esporre internals.
Esempio: due persone cliccano "Crea ordine" due volte durante lag. Con un vincolo unico su (user_id, cart_id) non ottieni due ordini. Ottieni un ordine e un rifiuto pulito e spiegabile.
Alcune scritture non sono una singola istruzione. Leggi una riga, controlli una regola, aggiorni uno stato e forse inserisci un log di audit. Se due richieste fanno lo stesso contemporaneamente, entrambe possono superare il controllo e scrivere. Questo è il pattern classico che fallisce.
Raggruppa la scrittura multi-step in una transazione in modo che tutti i passaggi riescano insieme o nessuno. Più importante, la transazione ti dà un posto per controllare chi può cambiare gli stessi dati allo stesso tempo.
Quando solo un attore può modificare un record alla volta, usa un lock a livello di riga. Per esempio: lock sulla riga dell'ordine, conferma che è ancora in stato "pending", poi falla diventare "approved" e scrivi l'entry di audit. La seconda richiesta aspetterà, poi ricontrollerà lo stato e si fermerà.
Scegli in base a quanto spesso accadono collisioni:
Tieni breve il tempo di lock. Fai il minimo lavoro possibile mentre lo tieni: niente chiamate API esterne, nessun lavoro su file lento, nessun loop pesante. Se costruisci flussi in uno strumento come Koder.ai, tieni la transazione solo per i passi sul database, poi fai il resto dopo il commit.
Scegli un flusso che può perdere denaro o fiducia quando collide. Uno comune è: crea un ordine, riserva stock, poi imposta lo stato ordine su confermato.
Scrivi i passaggi esatti che il tuo codice esegue oggi, in ordine. Sii specifico su cosa viene letto, cosa viene scritto e cosa significa "successo". Le collisioni si nascondono nel vuoto tra una lettura e una scrittura successiva.
Un percorso di rinforzo che funziona nella maggior parte degli stack:
Aggiungi un test che dimostri la correzione. Esegui due richieste contemporanee contro lo stesso prodotto e quantità. Asserisci che esattamente un ordine venga confermato e l'altro fallisca in modo controllato (niente stock negativo, niente righe di prenotazione duplicate).
Se generi app velocemente (incluso con piattaforme come Koder.ai), questa checklist vale ancora per i pochi percorsi di scrittura che contano veramente.
Uno dei maggiori responsabili è fidarsi della UI. I pulsanti disabilitati e i controlli client-side aiutano, ma gli utenti possono fare doppio clic, ricaricare, aprire due schede o riprodurre una richiesta da una connessione instabile. Se il server non è idempotente, i duplicati passano.
Un altro bug silenzioso: intercetti un errore di database (per esempio una violazione di unicità) ma continui il flusso comunque. Questo spesso diventa "create fallito, ma abbiamo comunque mandato l'email" o "pagamento fallito, ma abbiamo comunque segnato l'ordine come pagato." Una volta che gli effetti collaterali succedono, è difficile tornare indietro.
Le transazioni lunghe sono anche una trappola. Se tieni aperta una transazione mentre chiami email, pagamenti o API di terze parti, tieni i lock più a lungo del necessario. Questo aumenta attese, timeout e la probabilità che le richieste si blocchino a vicenda.
Mischiare job in background e azioni utente senza una singola fonte di verità crea stato split-brain. Un job ritenta e aggiorna una riga mentre un utente la sta modificando, e ora entrambi pensano di essere stati l'ultimo scrittore.
Alcune "correzioni" che in realtà non risolvono il problema:
Se costruisci con uno strumento chat-to-app come Koder.ai, valgono le stesse regole: chiedi vincoli server-side e confini transazionali chiari, non solo controlli UI più carini.
Le race condition spesso emergono solo sotto traffico reale. Un passaggio pre-release può catturare i punti di collisione più comuni senza riscrivere tutto.
Inizia dal database. Se qualcosa deve essere unico (email, numeri fattura, un abbonamento attivo per utente), rendilo un vincolo unico reale, non una regola di "controlliamo prima" a livello di app. Poi assicurati che il codice si aspetti che il vincolo a volte fallisca e restituisca una risposta chiara e sicura.
Poi guarda lo stato. Ogni cambiamento di stato (Bozza -> Inviato -> Approvato) dovrebbe essere validato contro un insieme esplicito di transizioni consentite. Se due richieste provano a muovere lo stesso record, la seconda dovrebbe essere rifiutata o diventare una no-op, non creare uno stato intermedio.
Una checklist pratica pre-release:
Se costruisci flussi in Koder.ai, considera questi criteri di accettazione: l'app generata dovrebbe fallire in modo sicuro sotto ripetizioni e concorrenza, non solo passare il percorso felice.
Due membri del personale aprono la stessa richiesta di acquisto. Entrambi cliccano Approva entro pochi secondi. Entrambe le richieste arrivano al server.
Quello che può andare storto è confuso: la richiesta viene "approvata" due volte, partono due notifiche e qualsiasi totale legato alle approvazioni (budget usato, conteggio approvazioni giornaliere) può aumentare di 2. Entrambi gli aggiornamenti sono validi da soli, ma collidono.
Ecco un piano di correzione che funziona bene con un database in stile PostgreSQL.
Aggiungi una regola che garantisca che possa esistere una sola riga di approvazione per una richiesta. Per esempio, memorizza le approvazioni in una tabella separata ed impone un vincolo UNIQUE su request_id. Ora la seconda insert fallisce anche se il codice dell'app ha un bug.
Quando approvi, fai tutta la transizione in una transazione:
Se il secondo membro arriva in ritardo, vedrà 0 righe aggiornate o un errore di vincolo unico. In entrambi i casi, vince una sola modifica.
Dopo la correzione, il primo membro vede Approved e la normale conferma. Il secondo vede un messaggio amichevole tipo: "Questa richiesta è già stata approvata da un altro utente. Aggiorna per vedere lo stato più recente." Niente spinning, niente notifiche duplicate, niente fallimenti silenziosi.
Se stai generando un flusso CRUD in una piattaforma come Koder.ai (backend in Go con PostgreSQL), puoi incorporare questi controlli nell'azione di approve una volta e riutilizzare il pattern per altre azioni "solo un vincitore".
Le race condition sono più facili da correggere quando le tratti come una routine ripetibile, non come una caccia al bug una tantum. Concentrati sui pochi percorsi di scrittura che contano davvero e rendili noiosamente corretti prima di lucidare il resto.
Inizia nominando i tuoi principali punti di collisione. In molte app CRUD sono lo stesso trio: contatori (like, inventario, saldi), cambi di stato (Bozza -> Inviato -> Approvato) e doppi invii (doppio clic, retry, reti lente).
Una routine che regge:
Se costruisci su Koder.ai, Planning Mode è un posto pratico per mappare ogni flusso di scrittura come passi e regole prima di generare cambiamenti in Go e PostgreSQL. Snapshot e rollback sono utili quando distribuisci nuovi vincoli o comportamenti di lock e vuoi un modo rapido per tornare indietro se incontri un edge case.
Col tempo diventa un'abitudine: ogni nuova funzionalità di scrittura ottiene un vincolo, un piano transazionale e un test di concorrenza. Così le race condition nelle app CRUD smettono di essere sorprese.