Chiavi Esterne: Smetti di chiederti se sono veloci
Chiediti cosa stai davvero ottimizzando.
L’ottimizzazione di database più costosa che abbia mai visto è iniziata con qualcuno che ha rimosso tutte le chiavi esterne (Foreign Keys).
Non perché avessero misurato un collo di bottiglia. Non perché le scritture fossero effettivamente lente. Perché avevano letto da qualche parte che “le chiavi esterne non scalano”. Sei mesi dopo, si sono ritrovati con 2 miliardi di record orfani, un sistema di fatturazione che addebitava costi a utenti cancellati e analisi statistiche sbagliate del 40%.
Quando hanno provato a reinserire i vincoli? Il database si è bloccato cercando di validare dati già corrotti.
C’è questa idea pervasiva nello sviluppo web che le chiavi esterne siano intrinsecamente lente, che siano rotelle da togliere una volta passati a sistemi “veri”. Ma questo manca completamente il punto di cosa sia un vincolo. Non stai scegliendo tra veloce e lento. Stai scegliendo tra diverse modalità di fallimento.
Pensala in questo modo: i vetri di sicurezza, le cinture e gli airbag aggiungono peso alla tua auto. Assolutamente rendono il tuo veicolo più lento e meno efficiente dal punto di vista del carburante. Ma non li strappi via per ottimizzare lo scatto da 0 a 100, perché stai ottimizzando per qualcos’altro del tutto.
La domanda non è se le chiavi esterne ti rallentino. Certo che lo fanno. La domanda è cosa ottieni in cambio, e se ne hai effettivamente bisogno.
Cosa Stai Davvero Scambiando
Lascia che ti faccia un esempio concreto. Stai costruendo un sistema di monitoraggio meteorologico con tabelle per stazioni meteo, dispositivi sensore, letture dei sensori e stati degli USA.
Metti le chiavi esterne su tutto? Vediamo cosa cambia davvero e quali sono le conseguenze:
Gli stati degli USA probabilmente non cambiano. Il Wyoming non verrà rinominato tanto presto. Non hai bisogno di una chiave esterna per validare i codici degli stati a ogni inserimento quando sai che i dati di riferimento sono statici. Quella è un’inutile overhead.
Le stazioni meteo vengono aggiunte, spostate e smantellate. Ma ecco una domanda: vuoi che le letture storiche “perdano” la loro stazione se qualcuno cancella accidentalmente un record di stazione? Forse vuoi effettivamente che quei dati restino intatti anche se la stazione non c’è più. Questo significherebbe che tratti le letture come un’istantanea storica piuttosto che un riferimento live, il che cambia se una chiave esterna abbia anche solo senso.
Le letture dei sensori vengono inserite migliaia di volte al minuto. Ogni controllo di chiave esterna significa una ricerca. Ogni ricerca crea contesa sulle tue tabelle. Se una validazione lenta fa sì che la tua coda di inserimento si intasi e perdi dati in tempo reale, quello è un tipo diverso di perdita di dati rispetto ad avere un record orfano.
Capisci dove voglio arrivare. La scelta non è tra performance e correttezza come concetti astratti. Riguarda quale specifico fallimento sei più disposto a tollerare dati i tuoi vincoli effettivi e le tue conseguenze effettive.
Se riferimenti sbagliati significano dati di fatturazione corrotti o violazioni normative, probabilmente vuoi le chiavi esterne a proteggerti indipendentemente dal costo in termini di performance. Se una validazione lenta significa che perdi per sempre dati in tempo reale dei sensori perché la tua coda trabocca, allora forse la validazione è il compromesso sbagliato.
Quando le Scritture Veloci Contano Davvero
Quindi hai deciso che hai bisogno della massima velocità di scrittura. La tua coda si sta accumulando, le transazioni vanno in timeout e i controlli delle chiavi esterne stanno causando problemi che hai effettivamente misurato (non solo ipotizzato).
Hai alcune opzioni. Potresti cambiare il tuo livello di isolamento delle transazioni da SERIALIZABLE a READ COMMITTED, che è più veloce ma rinuncia ad alcune garanzie di consistenza. Potresti fare commit in batch, inserendo 1000 righe per transazione invece che una alla volta per ammortizzare l’overhead delle FK. Oppure potresti denormalizzare in una struttura di log append-only dove non stai nemmeno cercando di validare i riferimenti.
Quella terza opzione non è barare, tra l’altro. È solo un design diverso:
CREATE TABLE sensor_log ( id BIGSERIAL PRIMARY KEY, recorded_at TIMESTAMPTZ NOT NULL, data JSONB NOT NULL -- { station_id, sensor_id, temp, humidity, ... });
CREATE INDEX ON sensor_log USING GIN (data);CREATE INDEX ON sensor_log (recorded_at);Nessun join. Nessun controllo di chiave esterna. Basta accodare dati ed eseguire query per intervallo di tempo o indice GIN sul blob JSONB. È “best practice”? Probabilmente no nel senso in cui lo insegnano i libri di testo sui database. Funziona quando stai inserendo 50.000 righe al minuto su un Raspberry Pi? Assolutamente sì.
Il disaccordo avviene quando le persone trattano la “best practice” come un imperativo morale piuttosto che un pattern che funziona bene in scenari comuni ma potrebbe non adattarsi al tuo caso.
La Trappola della Normalizzazione
I corsi di database adorano insegnare la normalizzazione. Evita la duplicazione a tutti i costi. Terza Forma Normale o niente.
Quindi ti ritrovi con qualcosa del genere: Ordini → ArticoliOrdine → Prodotti → Varianti → Colori → Taglie
Sei join di tabelle solo per rispondere a “Ho ordinato la maglietta rossa o quella blu lo scorso Natale?” E guai se devi includere il nome del prodotto, perché quello è altre tre join più in là nella gerarchia del catalogo.
Ma aspetta. La giustificazione di solito è “E se il brand cambia il modo in cui etichetta il Blu?” Se succede, vuoi davvero che gli ordini storici cambino colore retroattivamente? Ovviamente no. Quando qualcuno ha piazzato quell’ordine, ha comprato una “Maglietta Blu, Taglia M” così come esisteva in quel momento, non come un riferimento astratto a una voce di catalogo che potrebbe essere aggiornata in seguito.
Questo vale la pena approfondirlo perché è sottile. Alcuni dati sono fondamentalmente un’istantanea, non un riferimento. Quando tratti dati di tipo snapshot come se fossero riferimenti live, finisci con questa assurda proliferazione di join per ricostruire qualcosa che avrebbe dovuto essere semplicemente denormalizzato al momento della scrittura.
Memorizza {"colore": "blu", "taglia": "M"} direttamente sull’ordine. Hai finito.
Riconoscere i Dati Snapshot
Come fai a sapere quando qualcosa dovrebbe essere uno snapshot? Chiediti se è un record di un punto nel tempo:
Gli ordini catturano i dettagli del prodotto così come esistevano al momento dell’acquisto. I log di audit registrano lo stato dell’utente quando ha eseguito un’azione. Le tabelle cronologiche preservano lo stato del record prima di un aggiornamento. I flussi di eventi catturano cosa è successo, quando, con quali dati.
Se la risposta è “sì, questo sta registrando un momento nel tempo”, smetti di normalizzarlo. Inizia a fare snapshot.
Blob Opachi
C’è un’altra categoria oltre agli snapshot: i dati in cui non esegui mai query. Li memorizzi semplicemente e li recuperi interi.
Le configurazioni dei modelli LLM come {"model": "gpt-4", "temperature": 0.7, "max_tokens": 2000} non sono qualcosa per cui esegui query per temperatura. Recuperi l’intera config per ID richiesta quando ti serve. Payload JWT dopo la decodifica, log di richieste/risposte API per debugging, oggetti di preferenze utente con impostazioni del tema e flag di notifica. Questi sono tutti blob opachi. Non hai bisogno di normalizzazione. Non hai bisogno di chiavi esterne. Cacciali in JSONB e vai avanti con la tua vita.
Il join a 6 tabelle per scoprire di che colore era la maglietta ordinata? Quella non è normalizzazione corretta. È pensiero confuso sul fatto che tu stia memorizzando un riferimento o un valore.
(Anche se fai attenzione: questo può ritorcersi contro in modo spettacolare se in seguito hai bisogno di eseguire query su quei dati. Vedi La Seduzione del JSONB per quando questo approccio crea il suo stesso incubo.)
La Scala È Contesto
Sentirai persone dire “le chiavi esterne non scalano”. Ma la scala è completamente relativa al tuo hardware e alla tua architettura.
Un Raspberry Pi che registra 10.000 letture di sensori al minuto su una scheda microSD? Quella è legittimamente alta scala per quell’hardware. AWS Aurora con IOPS provisionate che gestisce miliardi di righe? Puoi usare chiavi esterne fino a scoppiare senza sudare.
Il limite duro effettivo non riguarda il conteggio delle righe o il volume di scrittura. È lo sharding.
Quando la tua tabella Utenti vive sul Server A e la tua tabella Ordini vive sul Server B, le chiavi esterne fisicamente non possono funzionare. Il database non ha alcun meccanismo per far rispettare un vincolo attraverso i confini di rete. A quel punto, stai già eseguendo job in background per trovare orfani e implementando pattern di consistenza eventuale.
Questo accade nel SaaS multi-tenant dove ogni tenant ottiene il proprio database isolato per conformità, o in deploy IoT dove hai 50.000 dispositivi edge che eseguono SQLite localmente. Una volta arrivato lì, le chiavi esterne sono fuori dai giochi (letteralmente) indipendentemente dalle considerazioni sulle performance.
Ma finché non raggiungi quel confine architetturale, forse non ottimizzare prematuramente per i problemi di Netflix quando stai costruendo uno strumento interno per 10 utenti.
Come Appare Tutto Questo Nella Pratica
Invece di chiederti “dovrei usare le chiavi esterne”, prova a chiederti queste tre cose:
Cosa si rompe se questo riferimento è sbagliato? È una causa legale, fatturazione corrotta, violazione normativa? O è solo un join mancante che restituisce null nella tua dashboard analitica?
Cosa si rompe se la validazione è lenta? Perdi dati in tempo reale insostituibili? O le tue query impiegano solo 50 millisecondi in più?
Questi dati sono uno snapshot o un riferimento? Stai registrando come appariva qualcosa in un momento specifico, o stai puntando al valore corrente autorevole?
Da lì, i pattern emergono abbastanza naturalmente:
Transazioni finanziarie, sessioni di autenticazione, qualsiasi cosa dove la corruzione dei dati significa responsabilità legale probabilmente vuole le chiavi esterne indipendentemente dall’overhead in termini di performance.
Log ad alto volume, dati time series append-only, qualsiasi cosa dove stai scrivendo un milione di eventi al minuto probabilmente non ha bisogno di overhead di validazione su ogni scrittura.
Snapshot storici come ordini e log di audit, dati che recuperi sempre come blob completo come preferenze utente, schemi che non controlli come payload webhook da API esterne… questi spesso funzionano meglio denormalizzati.
Ma nota che ho detto “probabilmente” e “spesso”. Perché il contesto conta, e il tuo contesto è diverso dal mio.
Considerazioni Finali
Le chiavi esterne non sono un problema di performance. Sono un compromesso tra velocità di scrittura e integrità dei dati, e se quel compromesso ha senso dipende interamente dai tuoi colli di bottiglia specifici e dalle tue conseguenze specifiche.
Il vero problema è quando le persone rimuovono le chiavi esterne a causa di qualcosa che hanno letto sul “web scale” senza effettivamente misurare se hanno un problema di performance in scrittura o considerare cosa stanno rinunciando. Finisci per fare cargo-culting dell’architettura di Netflix su un progetto greenfield che processa 100 transazioni al giorno.
Forse il costo in termini di performance vale il tuo caso d’uso. Forse no. Ma almeno prendi quella decisione basandoti su cosa stai effettivamente ottimizzando, non su cosa pensi di dover ottimizzare.
Cosa stai ottimizzando?
Risorse
- Documentazione Vincoli di Chiave Esterna PostgreSQL
- Suggerimenti sulle Performance PostgreSQL
- Use The Index, Luke! - Chiavi Esterne
- Normalizzazione vs Denormalizzazione del Database