I primi miglioramenti di performance spesso derivano da una migliore progettazione dello schema: tabelle, chiavi e vincoli corretti prevengono query lente e riscritture costose in seguito.

Quando un'app sembra lenta, il primo istinto è spesso “sistemare la SQL”. Questo ha senso: una singola query è visibile, misurabile e facile da incolpare. Puoi eseguire EXPLAIN, aggiungere un indice, modificare un JOIN e a volte ottenere un miglioramento immediato.
Ma nelle prime fasi di vita di un prodotto, i problemi di velocità possono dipendere tanto dalla forma dei dati quanto dal testo specifico della query. Se lo schema ti costringe a combattere contro il database, l'ottimizzazione delle query diventa un gioco del gatto col topo.
La progettazione dello schema è come organizzi i tuoi dati: tabelle, colonne, relazioni e regole. Include decisioni come:
Una buona progettazione dello schema fa sì che il modo naturale di porre domande sia anche il modo veloce.
L'ottimizzazione delle query consiste nel migliorare come recuperi o aggiorni i dati: riscrivere query, aggiungere indici, ridurre lavoro inutile e evitare pattern che provocano scansioni enormi.
Questo articolo non vuol dire “schema buono, query cattive”. Parla dell'ordine delle operazioni: sistemare prima i fondamenti dello schema del database, poi ottimizzare le query che ne hanno davvero bisogno.
Imparerai perché le decisioni sullo schema dominano le prestazioni iniziali, come riconoscere quando lo schema è il vero collo di bottiglia e come farlo evolvere in sicurezza man mano che l'app cresce. È scritto per team prodotto, founder e sviluppatori che costruiscono app reali—non per specialisti di database.
Le prestazioni iniziali raramente dipendono da SQL geniale: dipendono da quanto dato il database è costretto a toccare.
Una query può essere tanto selettiva quanto lo permette il modello dei dati. Se memorizzi “stato”, “tipo” o “proprietario” in campi poco strutturati (o sparsi in tabelle incoerenti), il database spesso deve scansionare molte più righe per trovare le corrispondenze.
Un buon schema restringe naturalmente lo spazio di ricerca: colonne chiare, tipi coerenti e tabelle ben definite permettono alle query di filtrare prima e leggere meno pagine da disco o memoria.
Quando mancano chiavi primarie e chiavi esterne (o non sono applicate), le relazioni diventano supposizioni. Questo sposta lavoro nello strato delle query:
JOIN diventano più grandi perché manca un percorso di join indicizzato e affidabile.Senza vincoli, si accumulano dati sporchi—e le query si rallentano all'aumentare delle righe.
Gli indici sono più utili quando combaciano con percorsi di accesso prevedibili: join su chiavi esterne, filtri su colonne ben definite, ordinamenti su campi comuni. Se lo schema mette attributi critici nella tabella sbagliata, mescola significati in una colonna o dipende dal parsing di testo, gli indici non ti salveranno—continuerai a scansionare e trasformare troppi dati.
Con relazioni pulite, identificatori stabili e confini di tabella sensati, molte query quotidiane diventano “veloci per impostazione predefinita” perché toccano meno dati e usano predicati semplici e favorevoli agli indici. L'ottimizzazione delle query diventa allora un passo finale, non un fuoco da estinguere continuamente.
I prodotti in fase iniziale non hanno “requisiti stabili”—hanno esperimenti. Le feature vengono pubblicate, riscritte o cancellate. Un piccolo team gestisce roadmap, supporto e infrastruttura con poco tempo per rivedere decisioni passate.
Non è quasi mai il testo SQL a cambiare per primo. È il significato dei dati: nuovi stati, nuove relazioni, nuovi campi “oh dobbiamo anche tracciarlo…”, e workflow interi non immaginati al lancio. Questo churn è normale—ed è proprio per questo che le scelte di schema contano così tanto all'inizio.
Riscrivere una query è di solito reversibile e locale: puoi spedire un miglioramento, misurarlo e fare rollback se serve.
Riscrivere uno schema è diverso. Una volta che hai dati reali dei clienti, ogni cambiamento strutturale diventa un progetto:
Anche con buoni strumenti, le modifiche dello schema introducono costi di coordinamento: aggiornamenti del codice app, sequenza di deploy e validazione dei dati.
Quando il database è piccolo, uno schema malconcio può sembrare “ok”. Con milioni di righe, lo stesso design provoca scansioni più ampie, indici più pesanti e join più costosi—e ogni nuova feature si costruisce su quella base.
Quindi l'obiettivo iniziale non è la perfezione. È scegliere uno schema che possa assorbire il cambiamento senza imporre migrazioni rischiose ogni volta che il prodotto impara qualcosa di nuovo.
La maggior parte dei problemi di “query lenta” all'inizio non riguarda trucchi SQL ma ambiguità nel modello dati. Se lo schema rende poco chiaro cosa rappresenta una riga o come le righe si relazionano, ogni query diventa più costosa da scrivere, eseguire e mantenere.
Inizia nominando le poche cose senza cui il tuo prodotto non può funzionare: utenti, account, ordini, abbonamenti, eventi, fatture—ciò che è davvero centrale. Poi definisci le relazioni in modo esplicito: uno-a-molti, molti-a-molti (di solito con una tabella di join) e proprietà (chi “contiene” cosa).
Un controllo pratico: per ogni tabella dovresti poter completare la frase “Una riga in questa tabella rappresenta ___.” Se non ci riesci, la tabella probabilmente mescola concetti, costringendo poi a filtri e join complessi.
La coerenza evita join accidentali e comportamenti API confusi. Scegli convenzioni (snake_case vs camelCase, *_id, created_at/updated_at) e mantienile.
Decidi anche a chi appartiene un campo. Per esempio, “billing_address” appartiene a un ordine (istantanea al momento) o a un utente (predefinito corrente)? Entrambe le scelte possono essere valide—ma mescolarle senza chiarezza crea query lente e soggette a errori per “capire la verità”.
Usa tipi che evitano conversioni a runtime:
Quando i tipi sono sbagliati, i database non possono confrontare in modo efficiente, gli indici diventano meno utili e le query spesso richiedono casting.
Memorizzare lo stesso fatto in più punti (per esempio order_total e la somma di line_items) crea deriva. Se memorizzi un valore derivato, documentalo, definisci la sorgente della verità e assicurati che gli aggiornamenti siano coerenti (spesso tramite logica applicativa più vincoli).
Un database veloce è di solito un database prevedibile. Chiavi e vincoli rendono i dati prevedibili impedendo stati “impossibili”: relazioni mancanti, identità duplicate o valori che non significano ciò che l'app pensa. Questa pulizia incide direttamente sulle prestazioni perché il planner può fare assunzioni migliori.
Ogni tabella dovrebbe avere una chiave primaria (PK): una colonna (o piccolo insieme di colonne) che identifica univocamente una riga e non cambia mai. Non è solo teoria: permette join efficienti, caching sicuro e riferimenti ai record senza ambiguità.
Una PK stabile evita anche workaround costosi. Se una tabella non ha un vero identificatore, l'app inizia a “identificare” righe tramite email, nome, timestamp o un insieme di colonne—portando a indici più ampi, join lenti e casi limite quando quei valori cambiano.
Le foreign key (FK) impongono relazioni: un orders.user_id deve puntare a un users.id esistente. Senza FK, si infilano riferimenti invalidi (ordini per utenti cancellati, commenti per post mancanti), e ogni query deve filtrare difensivamente, usare left-join e gestire null.
Con le FK, il planner può ottimizzare i join con più sicurezza perché la relazione è esplicita e garantita. In più accumulerai meno righe orfane che gonfiano tabelle e indici.
I vincoli non sono burocrazia, sono guardrail:
users.email canonico.status IN ('pending','paid','canceled')).Dati più puliti significano query più semplici, meno condizioni di fallback e meno join “giusto per sicurezza”.
users.email e customers.email): identità conflittuali e indici duplicati.Se vuoi velocità all'inizio, rendi difficile memorizzare dati sbagliati. Il database ti ricompenserà con piani più semplici, indici più piccoli e meno sorprese di performance.
Normalizzare significa memorizzare ogni “fatto” in un solo posto per evitare duplicazioni. Quando lo stesso valore è copiato in molte tabelle, gli aggiornamenti diventano rischiosi—una copia cambia e l'altra no, e l'app mostra risposte contrastanti.
In pratica, normalizzare significa separare le entità in modo che gli aggiornamenti siano puliti e prevedibili. Per esempio, nome e prezzo di un prodotto stanno in products, non ripetuti dentro ogni riga d'ordine. Un nome di categoria sta in categories, referenziato tramite ID.
Questo riduce:
Normalizzare oltre misura porta a tante tabelle piccole che devono essere joinate continuamente per schermate comuni. Il database può restituire risultati corretti, ma le letture ordinarie diventano più lente e complesse perché ogni richiesta richiede molti join.
Un sintomo tipico: una pagina “semplice” (storico ordini) richiede 6–10 join e le prestazioni variano con traffico e cache.
Un bilanciamento sensato:
products, nomi di categoria in categories e relazioni tramite FK.Denormalizzare significa duplicare intenzionalmente un piccolo pezzo di dato per rendere una query frequente più economica (meno join, liste più veloci). La parola chiave è attenzione: ogni campo duplicato necessita di un piano per rimanere aggiornato.
Una configurazione normalizzata potrebbe essere:
products(id, name, price, category_id)categories(id, name)orders(id, customer_id, created_at)order_items(id, order_id, product_id, quantity, unit_price_at_purchase)Nota la sottile vittoria: order_items memorizza unit_price_at_purchase (una forma di denormalizzazione) perché serve l'accuratezza storica anche se il prezzo del prodotto cambia dopo. Questa duplicazione è intenzionale e stabile.
Se la schermata più comune è “ordini con riepilogo degli articoli”, potresti anche denormalizzare product_name in order_items per evitare il join su products per ogni lista—ma solo se sei pronto a mantenerlo sincronizzato (o ad accettare che sia un'istantanea al momento dell'acquisto).
Gli indici sono spesso trattati come un pulsante magico per la velocità, ma funzionano bene solo quando la struttura della tabella ha senso. Se continui a rinominare colonne, dividere tabelle o cambiare relazioni, il set di indici sarà in continuo cambiamento. Gli indici funzionano meglio quando le colonne e i modi in cui l'app filtra/ordina sono abbastanza stabili da non richiedere ricostruzioni settimanali.
Non serve prevedere tutto, ma serve una lista corta delle query che contano davvero:
Queste frasi si traducono direttamente in quali colonne meritano un indice. Se non riesci a dirle ad alta voce, di solito è un problema di chiarezza dello schema—non di indicizzazione.
Un indice composito copre più di una colonna. L'ordine delle colonne conta perché il database può usare l'indice da sinistra a destra.
Per esempio, se filtri spesso per customer_id e poi ordini per created_at, un indice su (customer_id, created_at) è tipicamente utile. L'inverso (created_at, customer_id) potrebbe non aiutare la stessa query.
Ogni indice ha un costo:
Uno schema pulito restringe gli indici “giusti” a un piccolo insieme che corrisponde ai reali pattern di accesso—senza pagare continuamente il peso su scritture e spazio.
Le app lente non sono sempre rallentate dalle letture. Molti problemi di performance iniziali emergono durante insert e update—signup, checkout, job in background—perché uno schema disordinato fa fare lavoro extra a ogni scrittura.
Alcune scelte di schema moltiplicano silenziosamente il costo di ogni modifica:
INSERT. Le foreign key con cascade sono corrette e utili, ma aggiungono lavoro al tempo di scrittura che cresce con i dati correlati.Se il carico è read-heavy (feed, pagine di ricerca), puoi permetterti più indici e qualche denormalizzazione. Se è write-heavy (ingestione eventi, telemetry, ordini ad alto volume), dai priorità a uno schema che mantenga le scritture semplici e prevedibili, poi aggiungi ottimizzazioni per le letture dove serve.
Un approccio pratico:
entity_id, created_at).Percorsi di scrittura puliti ti danno margine di manovra—e rendono l'ottimizzazione delle query molto più semplice in seguito.
Gli ORM rendono il lavoro col database più semplice: definisci modelli, chiami metodi e i dati compaiono. Il problema è che un ORM può anche nascondere SQL costose fino a quando non iniziano a pesare.
Due trappole comuni:
.include() o un serializer nidificato può trasformarsi in join ampi, righe duplicate o ordinamenti pesanti—soprattutto se le relazioni non sono chiaramente definite.Uno schema ben progettato riduce la probabilità che questi pattern emergano e li rende più facili da rilevare quando succedono.
Quando le tabelle hanno foreign key esplicite, vincoli di unicità e NOT NULL, l'ORM può generare query più sicure e il codice può contare su assunzioni coerenti.
Per esempio, imporre che orders.user_id esista (FK) e che users.email sia unico evita intere classi di edge case che altrimenti diventerebbero controlli a livello applicativo e lavoro di query aggiuntivo.
Il design delle API è a valle dello schema:
created_at + id).Tratta le decisioni di schema come ingegneria di prima classe:
Se costruisci velocemente con uno workflow chat-driven (ad esempio generando un'app React più un backend Go/PostgreSQL con Koder.ai), aiuta rendere la “revisione dello schema” parte della conversazione fin da subito. Puoi iterare rapidamente, ma vuoi comunque vincoli, chiavi e un piano di migrazione decisi—soprattutto prima che arrivi il traffico.
Alcuni problemi di performance non sono “SQL cattiva” ma il database che lotta contro la forma dei tuoi dati. Se vedi gli stessi problemi attraverso molti endpoint e report, spesso è un segnale di schema, non un'opportunità di tuning della query.
Filtri lenti sono un classico. Se condizioni semplici come “trova ordini per cliente” o “filtra per data di creazione” sono costantemente lente, il problema può essere chiavi mancanti, tipi non corrispondenti o colonne che non sono indicizzabili efficacemente.
Un altro campanello è il numero di join che esplode: una query che dovrebbe unire 2–3 tabelle finisce per concatenarne 6–10 per rispondere a una domanda base (spesso per via di lookup eccessivi, pattern polimorfici o design “tutto in una tabella”).
Controlla anche valori incoerenti in colonne usate come enum—soprattutto campi di stato (“active”, “ACTIVE”, “enabled”, “on”). L'incoerenza costringe query difensive (LOWER(), COALESCE(), catene di OR) che restano lente nonostante il tuning.
Inizia con controlli reali: conteggi di righe per tabella e cardinalità per colonne chiave (quanti valori distinti). Se una colonna “status” dovrebbe avere 4 valori ma ne trovi 40, lo schema sta già perdendo complessità.
Poi guarda i piani di query per gli endpoint lenti. Se vedi scansioni sequenziali ripetute su colonne di join o grandi set di risultati intermedi, schema e indicizzazione sono probabilmente la radice.
Infine, abilita e rivedi i log delle query lente. Quando molte query diverse sono lente allo stesso modo (stesse tabelle, stessi predicati), è di solito un problema strutturale da correggere a livello di modello.
Le scelte iniziali raramente sopravvivono al contatto con utenti reali. L'obiettivo non è “azzeccare tutto” ma cambiarlo senza rompere la produzione, perdere dati o bloccare il team per giorni.
Un workflow pratico che scala da un'app di una persona a team più grandi:
La maggior parte dei cambiamenti di schema non richiede rollout complessi. Preferisci la strategia “espandi e contrai”: scrivi codice che possa leggere sia il vecchio sia il nuovo formato, poi passa le scritture quando sei sicuro.
Usa feature flag o dual writes solo quando serve un cutover graduale (alto traffico, backfill lungo o più servizi coinvolti). Se fai dual write, aggiungi monitoraggio per rilevare deriva e definisci quale lato vince in caso di conflitto.
I rollback sicuri partono da migrazioni reversibili. Prova la strada del “tornare indietro”: eliminare una colonna nuova è facile; recuperare dati sovrascritti no.
Testa migrazioni su volumi realistici. Una migrazione che impiega 2 secondi su un portatile può bloccare tabelle per minuti in produzione. Usa conteggi di righe e indici simili alla produzione e misura i tempi.
Qui gli strumenti di piattaforma riducono il rischio: deployment affidabili più snapshot/rollback e la possibilità di esportare codice rendono più sicuro iterare su schema e logica app insieme. Se usi Koder.ai, sfrutta snapshot e la modalità di pianificazione quando stai per introdurre migrazioni che richiedono attenzione.
Tieni un breve diario dello schema: cosa è cambiato, perché e quali compromessi sono stati accettati. Collegalo a /docs o al README del repo. Includi note tipo “questa colonna è intenzionalmente denormalizzata” o “foreign key aggiunta dopo backfill il 2025-01-10” così chi modifica in futuro non ripeta errori passati.
L'ottimizzazione delle query conta—ma rende di più quando lo schema non ti ostacola. Se le tabelle mancano di chiavi chiare, le relazioni sono incoerenti o la regola “una riga per una cosa” è violata, puoi passare ore a ottimizzare query che verranno riscritte la settimana dopo.
Risolvi prima i blocchi dello schema. Parti da tutto ciò che rende difficile interrogare correttamente: chiavi primarie mancanti, foreign key incoerenti, colonne che mescolano più significati, fonti di verità duplicate o tipi non corretti (es. date come stringhe).
Stabilizza i pattern di accesso. Una volta che il modello dati rispecchia come si comporta l'app (e probabilmente per i prossimi sprint), il tuning delle query diventa durevole.
Ottimizza le query principali—non tutte. Usa log/APM per identificare le query più lente e frequenti. Un endpoint che riceve 10.000 richieste al giorno solitamente vale più di un report amministrativo raro.
I maggiori miglioramenti iniziali vengono da pochi interventi:
SELECT *, specialmente su tabelle wide).Il lavoro sulle performance non finisce mai, ma l'obiettivo è renderlo prevedibile. Con uno schema pulito ogni nuova feature aggiunge carico incrementale; con uno schema disordinato ogni feature aggiunge confusione composita.
SELECT * in un percorso caldo.