Il problema delle query N+1 è un pattern in cui recuperare N record innesca una query in più per record – N+1 round trip invece di uno o due. È il bug di prestazioni grave più comune nelle applicazioni basate sui dati, ed è anche il più mimetizzato: ogni singola query è rapida, il codice si legge alla perfezione e la pagina funziona – fino al momento in cui arrivano i volumi di dati reali.
Punti chiave
| Domanda | Risposta |
|---|---|
| La forma | 1 query per l’elenco + N query per le relazioni = N+1 round trip |
| La causa | Lazy loading toccato dentro un ciclo – query invisibili a ogni iterazione |
| La firma | Molte query rapide e identiche – i log delle query lente non le vedono mai |
| Le vie d’uscita | Eager loading · join · IN per lotti · loader con portata di richiesta |
| Il moltiplicatore | Latenza di round trip × dimensione della pagina – brutale in rete |
Il bug, a carte scoperte
// 1 query: recupera 100 post
const posts = await postRepo.findRecent(100);
for (const post of posts) {
// +1 query PER OGNI POST – post.author sembra una proprietà,
// ma scatta il lazy loading: SELECT * FROM users WHERE id = ?
render(post.title, (await post.author).name);
}
// Log delle query: 1 + 100 = 101 round trip per un solo rendering di pagina
Il rimedio, come lo esprimono gli SDK – dichiarare la relazione in anticipo, e la piattaforma la recupera nella stessa richiesta:
// JavaScript / Node.js — Back4app JS SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
const query = new Parse.Query('Comment');
query.equalTo('post', post);
query.include('author'); // eager-load the pointer
const comments = await query.find(); // one round trip, total
comments.forEach((c) =>
render(c.get('text'), c.get('author').get('username')) // already loaded
); // Flutter / Dart — Back4app Flutter SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
final query = QueryBuilder<ParseObject>(ParseObject('Comment'))
..whereEqualTo('post', post.toPointer())
..includeObject(['author']); // eager-load the pointer
final response = await query.query(); // one round trip, total // iOS / Swift — Back4app Swift SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
let query = Comment.query("post" == post)
.include("author") // eager-load the pointer
query.find { result in
if case .success(let comments) = result { // one round trip, total
comments.forEach { render($0.text, $0.author?.username) }
}
} // Android / Kotlin — Back4app Android SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
val query = ParseQuery.getQuery<ParseObject>("Comment")
query.whereEqualTo("post", post)
query.include("author") // eager-load the pointer
query.findInBackground { comments, e -> // one round trip, total
if (e == null) comments.forEach {
render(it.getString("text"), it.getParseObject("author")?.getString("username"))
}
} Il costo, conti alla mano
L’aritmetica che le spiegazioni saltano – tempo totale ≈ query di elenco + (N × latenza di round trip):
| Dimensione pagina N | @1 ms/query | @5 ms/query | Per lotti (1–2 query) |
|---|---|---|---|
| 10 | ~11 ms | ~55 ms | ~10 ms |
| 100 | ~101 ms | ~505 ms | ~12 ms |
| 1.000 | ~1,0 s | ~5,0 s | ~20 ms |
Le correzioni pubblicate confermano il conto: pagine che scendono da 1,4 s a 0,16 s, endpoint 30× più rapidi e oltre. Due corollari da fissare: la paginazione mette un tetto a N – una dimensione di pagina limitata circoscrive il raggio d’impatto ancora prima del rimedio vero; e la versione in rete è peggiore – quando ognuna delle N chiamate è una richiesta HTTP invece di una query locale, si moltiplica per decine di millisecondi anziché per unità.
Le quattro vie d’uscita
| Rimedio | Come | Da preferire quando | Attenzione a |
|---|---|---|---|
| Eager loading / include | Dichiarare le relazioni sulla query | La schermata ha sempre bisogno della relazione | Includere troppo gonfia i payload |
| Join | Una sola istruzione recupera entrambi | Motore relazionale, letture di tipo report | L’esplosione di righe sui join larghi |
| IN per lotti | Raccogliere le chiavi, recuperare una volta | Qualunque stack, anche fatto a mano | Un round trip in più (accettabile) |
| Loader con portata di richiesta | Raggruppamento automatico durante l’esecuzione | Resolver GraphQL, codice a livelli | Deve essere per richiesta, non globale |
L’ultima riga è il caso celebre di GraphQL: i resolver scattano per ogni oggetto genitore e ricreano il ciclo lato server, e il pattern loader – raccogliere le chiavi in un tick, recuperare una volta, memoizzare per richiesta – è il rimedio standard del settore. Una regola sottile viaggia con esso: i loader hanno portata di richiesta; un loader globale diventa una cache obsoleta piena di bug di autorizzazione.
Rilevamento: dare la caccia alle query rapide
La firma di N+1 è l’inverso del normale lavoro sulle prestazioni: si cercano molte query rapide e identiche, non una query lenta – ed è per questo che i log delle query lente, lo strumento abituale, non la vedono mai. I metodi che funzionano: il logging di debug dell’ORM (la stessa istruzione, N parametri diversi, un solo rendering); le viste a span dell’APM, dove la cascata di span brevi e identici è inconfondibile; e il meno costoso di tutti – contare le query di un singolo caricamento di pagina in sviluppo, con una soglia in testa: una pagina di elenco deve costare un numero di query a una cifra, non un multiplo delle sue righe. Anche il gemello lato scrittura merita il suo audit: un ciclo con un insert per elemento è N+1 in scrittura, e si risolve con operazioni in massa (bulk).
Casi d’uso tipici
- Viste a elenco con autori, proprietari o stati – la casa canonica: ogni feed, inbox e tabella che collega persone a elementi.
- API GraphQL – i campi di elenco annidati sono strutturalmente N+1 finché non esistono i loader.
- Database documentali – una find per ogni documento referenziato; si risolve con include, IN per lotti o incorporamento, secondo le regole di modellazione.
- Fan-out tra microservizi – un elenco dal servizio A, una chiamata HTTP al servizio B per ogni elemento; gli endpoint composti e i BFF esistono per porre fine a questo.
- Job in background – il ciclo che elabora 10.000 record con due query ciascuno, costando ore in silenzio.
Lazy vs. eager loading: matrice decisionale
| Caricare in anticipo quando… | Restare lazy quando… |
|---|---|
| La relazione compare su ogni riga | La relazione sta dietro un clic |
| Il ciclo è il pattern di accesso | L’accesso avviene un record per volta |
| N ha la dimensione di una pagina o più | N è garantito minuscolo |
| La latenza è visibile all’utente | Un task in background può permettersi uno scostamento |
| Questo bug è stato appena corretto qui | C’è una misurazione, non un’ipotesi |
E la regola di audit permanente che sopravvive a qualunque matrice: qualunque relazione toccata dentro un ciclo è colpevole finché il log delle query non dimostra il contrario.
Limiti e trade-off
- L’eager loading può correggere troppo. Includere relazioni pesanti ovunque scambia N+1 con payload gonfiati e join larghi – conviene includere ciò che la schermata mostra, non l’intero grafo.
- Anche i join hanno il loro salto nel vuoto. I join uno-a-molti duplicano le righe del genitore per ogni figlio; a fan-out alto il recupero per lotti in due query batte il singolo join.
- I loader aggiungono macchinario. La portata di richiesta, l’invalidazione della cache dentro la richiesta e le finestre di raggruppamento sono codice vero – è il prezzo del batching automatico.
- I framework lo reintroducono in silenzio. Serializzatori, helper dei template e revisioni del tipo “solo un campo in più” sono il modo in cui le pagine corrette regrediscono; il controllo del numero di query va nella CI, non nella memoria delle persone.
- Il rimedio è per percorso, non globale. N+1 è un bug di pattern di accesso; ogni nuova schermata ripropone la domanda.
N+1 su Back4app
Back4app è una piattaforma open-source di Backend as a Service (BaaS) che combina database gestito, API REST e GraphQL generate automaticamente, autenticazione, archiviazione di file e funzioni serverless con Cloud Code. I suoi SDK fanno del rimedio l’idioma corrente invece della correzione a posteriori: le relazioni sono Pointer tipizzati, e include() – i tab di codice qui sopra – li recupera nella stessa richiesta, raggruppati lato server. L’API GraphQL risolve le query annidate senza fan-out per campo, e i valori predefiniti di paginazione limitano N prima che metta i denti. Il ciclo che causa N+1 non ha un modo naturale di essere scritto – che è il miglior tipo di rimedio: quello che nessuno deve ricordare.
Domande frequenti
Che cos'è il problema delle query N+1 in parole semplici?
Si recupera un elenco di N record con una query, poi il codice emette una query in più per ogni record per i suoi dati collegati – cento post diventano centouno query. Ogni query è rapida di per sé, ed è esattamente per questo che il problema si nasconde: niente è abbastanza lento da allarmare qualcuno, finché la pagina non arriva a fare centinaia di round trip.
Che cosa causa le query N+1?
Il lazy loading come comportamento predefinito. ORM e SDK permettono di navigare le relazioni come proprietà di un oggetto – post.author – ed eseguono in modo trasparente una query al momento dell'accesso. Basta mettere quell'accesso dentro un ciclo su N risultati e si sono scritte N query in silenzio. L'astrazione che ha reso piacevole l'accesso ai dati ha reso invisibili anche i round trip.
Come si risolve il problema N+1?
Quattro vie d'uscita standard, da scegliere caso per caso: l'eager loading – dichiarare in anticipo sulla query di includere la relazione; un join che recupera entrambi in una sola istruzione; il batching – raccogliere le N chiavi esterne e recuperarle con una sola query di appartenenza a un insieme; e i loader con portata di richiesta, che raggruppano in automatico. Tutte e quattro trasformano N+1 round trip in uno o due.
Quanto è più lento N+1 davvero?
Basta fare il conto: ogni round trip costa da uno a cinque millisecondi prima che la query venga nemmeno eseguita, quindi 100 righe a 5 ms aggiungono mezzo secondo contro una sola query raggruppata da circa 10 ms. Le correzioni pubblicate riportano pagine passate da circa 1,4 secondi a 0,16 ed endpoint di API trenta volte più rapidi. Il conto peggiora in modo lineare con la dimensione della pagina – e in modo catastrofico attraverso la rete.
Perché il problema N+1 è così comune in GraphQL?
Perché i resolver girano per campo e per oggetto. Una query per i post con i loro autori esegue una volta il resolver dei post e N volte quello dell'autore – il ciclo dell'ORM rinato lato server. La cura canonica è il pattern loader: un batcher con portata di richiesta che raccoglie gli ID degli autori durante l'esecuzione ed emette un solo recupero raggruppato, memoizzato per la richiesta.
Come si rilevano le query N+1?
Conviene cercare molte query rapide e identiche, non una query lenta – quella è la firma. Il logging di debug dell'ORM mostra la stessa istruzione con parametri diversi; i log delle query lente la mancano del tutto, perché ogni singola query è veloce; gli strumenti di APM segnalano il pattern di span in modo esplicito. L'abitudine che la intercetta presto: leggere il log delle query di un solo rendering di pagina e contare.
Il problema N+1 si presenta anche nei database documentali e nelle API REST?
Ovunque i dati abbiano relazioni. Negli archivi documentali è una find per ogni documento referenziato – si risolve con i meccanismi include, con query di appartenenza a un insieme raggruppate oppure con l'incorporamento. Su REST è un endpoint di elenco più una chiamata HTTP per elemento – peggio della versione sul database, perché la latenza di rete fa impallidire quella delle query; endpoint composti, endpoint di batch e linguaggi di query esistono in gran parte per eliminarlo.
Il lazy loading è sempre sbagliato?
No – è sbagliato dentro i cicli. Il lazy loading è esattamente giusto quando i dati collegati servono raramente: si paga sull'unico record che ne ha bisogno, invece di pagare in anticipo per tutti gli N. La disciplina sta nel sapere quale pattern di accesso ha ciascuna schermata: le relazioni sempre necessarie si caricano in modo anticipato, quelle raramente necessarie in modo lazy, e tutto ciò che sta dentro un ciclo passa sotto audit.