HomeProgettiServiziDemo gratuitaTecnologieGuideContatti

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 · 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 ordine

Da 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 solo

Ti 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 ordini

Negli 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 ANALYZE mostra 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

  1. Quante query fa la pagina? Le conto con la barra di debug o nei log.
  2. Il numero cresce con le righe? Se sì, è un problema N+1.
  3. Quali relazioni vengono lette nel ciclo? Le carico in anticipo con JOIN o con una seconda query.
  4. Mi serve solo un conteggio o un totale? Lo faccio calcolare al database.
  5. Le query sono poche ma lente? Controllo indici, paginazione e dati caricati.
  6. 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.

Dalla guida al tuo progetto

Il tuo gestionale è diventato lento?

Posso analizzare le pagine più lente, misurare query e tempi di risposta e indicarti cosa correggere, partendo dagli interventi che danno più risultato con meno modifiche.