Sviluppo web
Software lento? Prima di tutto controlla le query.
Un gestionale funziona bene i primi mesi, poi le pagine con gli elenchi diventano lente. Spesso il server o il database non c’entrano: è il codice che fa al database centinaia di domande dove ne basterebbero due. Questa guida spiega il problema delle query N+1, come riconoscerlo e come risolverlo.
Di Alessandro Negru · Sviluppatore software · · 7 min di lettura
01 / Riconoscerlo
Il sintomo: lento solo con i dati veri
Il segnale tipico è questo: in sviluppo la pagina è istantanea, in produzione impiega secondi. Oppure era veloce all’inizio e rallenta man mano che crescono clienti, ordini e documenti. Le pagine colpite sono quasi sempre elenchi e tabelle: ordini, fatture, articoli, report.
Prima di aggiungere memoria al server o cambiare hosting, conviene chiedersi quante query fa il programma per mostrare quella pagina. Se il numero cresce insieme alle righe in tabella, il problema ha un nome preciso.
02 / Il meccanismo
Il problema N+1, spiegato con un elenco di ordini
Immagina una pagina che mostra gli ultimi 50 ordini, ognuno con il nome del cliente. Ordini e clienti stanno in due tabelle diverse. Il codice più naturale chiede prima gli ordini, poi, riga per riga, il cliente di ciascuno:
ordini = Ordine.objects.order_by("-data")[:50]
for ordine in ordini:
print(ordine.numero, ordine.cliente.ragione_sociale)Sembra una sola operazione, ma al database arrivano 51 richieste: una per gli ordini e una per ogni cliente.
SELECT * FROM ordine ORDER BY data DESC LIMIT 50; -- 1 query
SELECT * FROM cliente WHERE id = 12; -- +1
SELECT * FROM cliente WHERE id = 7; -- +1
SELECT * FROM cliente WHERE id = 31; -- +1
-- ... altre 47 query uguali, una per ogni ordineDa qui il nome: 1 query per l’elenco più N query, una per ogni elemento. È come andare in magazzino a prendere un articolo alla volta, tornando ogni volta alla scrivania, invece di portarsi dietro la lista completa.
Ogni query, presa da sola, è velocissima. Il costo sta nella somma: ogni richiesta ha un tempo di andata e ritorno tra applicazione e database. Con un esempio indicativo, se ogni richiesta impiega 2 millisecondi, 50 righe costano 100 millisecondi; un elenco di 500 righe con due relazioni arriva a 1.001 query e a due secondi abbondanti di sola attesa.
03 / Nessuno lo scrive apposta
Perché succede anche in codice scritto bene
Quasi tutti i framework usano un ORM, uno strumento che permette di lavorare con i dati come oggetti invece di scrivere SQL a mano. Per non caricare dati inutili, l’ORM recupera le informazioni collegate solo quando servono: è il cosiddetto caricamento pigro, o lazy loading.
Comodo, ma invisibile: scrivere ordine.cliente sembra leggere un campo, invece fa partire una query. Spesso questo accade dentro un template o in un serializzatore di un’API, lontano dal punto in cui è stato scritto l’elenco. Con pochi dati di prova nessuno se ne accorge; il problema emerge quando il programma è in uso da mesi.
Succede anche tra frontend e backend: un’interfaccia che chiede la lista all’API e poi chiama un endpoint diverso per ogni riga crea lo stesso schema, con il costo di una richiesta di rete in più ogni volta.
04 / Misurare
Come accorgersene
Il modo più diretto è contare le query di una pagina. Gli strumenti cambiano con il linguaggio, ma l’idea è la stessa:
- In sviluppo, con una barra di debug. In Django la Django Debug Toolbar mostra numero, durata e testo delle query, ed evidenzia quelle ripetute.
- Nei log. Attivando il log delle query del database su una pagina, una lunga serie di righe quasi identiche è il segnale più chiaro.
- In produzione, dal database. Su PostgreSQL l’estensione pg_stat_statements raccoglie le statistiche delle query: una query semplice eseguita un numero enorme di volte merita attenzione.
- Con il monitoraggio. Gli strumenti che tracciano le richieste mostrano quante query fa ogni pagina e quanto tempo occupano. Ne parlo nella guida sul monitoraggio del software.
Alcuni framework possono anche bloccare il problema in sviluppo: Laravel, per esempio, permette di vietare il caricamento pigro e segnalare ogni relazione caricata riga per riga.
05 / La correzione
Come risolverlo
La soluzione è dire in anticipo all’ORM quali dati collegati serviranno, così li carica tutti insieme. Si parla di caricamento anticipato, o eager loading. In Django ci sono due strumenti principali, descritti nella guida ufficiale all’ottimizzazione.
Relazione verso un solo elemento: una JOIN
Ogni ordine ha un solo cliente. Con select_related il database restituisce ordini e clienti nella stessa query:
ordini = (
Ordine.objects
.select_related("cliente") # una JOIN: ordini e clienti insieme
.order_by("-data")[:50]
)Da 51 query si passa a una, e il numero resta uguale con 50 o con 5.000 ordini.
Relazione verso molti elementi: due query
Se invece mostri i clienti con i loro ordini, ogni cliente ne ha molti. prefetch_related carica tutti gli ordini necessari con una seconda query e li associa ai clienti in memoria:
clienti = Cliente.objects.prefetch_related("ordini")
# 2 query in tutto: i clienti, poi tutti i loro ordini in un colpo soloTi serve solo un numero? Fallo calcolare al database
Per mostrare “12 ordini” accanto a ogni cliente non serve caricare gli ordini. Il conteggio si chiede direttamente al database:
from django.db.models import Count
clienti = Cliente.objects.annotate(numero_ordini=Count("ordini"))
# il conteggio lo fa il database, senza caricare gli ordiniNegli altri linguaggi
Il principio è identico: in Laravel si usa with(), in Ruby on Rails includes, in SQL scritto a mano una JOIN o una query con WHERE id IN (…). Tra frontend e API, la correzione è un endpoint che restituisce già tutti i dati dell’elenco.
06 / Prevenzione
Evitare che ritorni
Un N+1 corretto può ricomparire alla prima modifica: basta aggiungere una colonna alla tabella che legge un’altra relazione. Il modo più affidabile per evitarlo è un test automatico che fissa il numero massimo di query di una pagina:
def test_elenco_ordini_numero_di_query(client, django_assert_max_num_queries):
# con 5 o con 500 ordini, le query non devono crescere
with django_assert_max_num_queries(5):
client.get("/ordini/")Il test usa la fixture django_assert_max_num_queries di pytest-django; Django offre anche assertNumQueries. Conviene prepararlo con più righe di dati, così un numero di query che cresce con le righe fa fallire il test prima del rilascio.
07 / Non solo N+1
Quando non è N+1
Se le query sono poche ma la pagina resta lenta, la causa è altrove. Le più frequenti:
- Indici mancanti. Una ricerca o un filtro su una colonna senza indice costringe il database a leggere tutta la tabella. Il comando
EXPLAIN ANALYZEmostra come viene eseguita una query e quanto costa. - Troppi dati in una volta. Un elenco senza paginazione o che carica colonne pesanti, come testi lunghi e allegati, anche quando non servono.
- Calcoli ripetuti a ogni richiesta. Report e totali che potrebbero essere calcolati una volta e conservati, invece di essere rifatti a ogni apertura.
- Risorse insufficienti. Solo dopo aver escluso le cause nel codice ha senso valutare server, database o hosting.
08 / Da tenere a portata di mano
La checklist per una pagina lenta
- Quante query fa la pagina? Le conto con la barra di debug o nei log.
- Il numero cresce con le righe? Se sì, è un problema N+1.
- Quali relazioni vengono lette nel ciclo? Le carico in anticipo con JOIN o con una seconda query.
- Mi serve solo un conteggio o un totale? Lo faccio calcolare al database.
- Le query sono poche ma lente? Controllo indici, paginazione e dati caricati.
- Ho un test che conta le query? Lo aggiungo per le pagine più usate.
Se gestisci un software e non sviluppi, puoi girare questa guida a chi lo mantiene e chiedere quante query fanno le pagine più lente. È una domanda precisa, e spesso la risposta indica dove intervenire senza riscrivere il programma.