+39 3662317539/+34 620899163
info@simonecosci.com

Eloquent e il problema N+1: accorgersene prima che se ne accorga il cliente

Sviluppo Siti Web a Tenerife

Eloquent e il problema N+1: accorgersene prima che se ne accorga il cliente

Il codice è questo, e non ha niente di strano:

$ordini = Ordine::latest()->take(50)->get();

foreach ($ordini as $ordine) {
    echo $ordine->cliente->ragione_sociale;
}

Una query per prendere i cinquanta ordini, poi una query per ogni cliente: cinquantuno query
per una pagina. Questo è il problema N+1. In sviluppo, con dieci record di prova, non si vede:
la pagina risponde in trenta millisecondi e nessuno si accorge di niente. In produzione, con
un database vero e la latenza di rete fra applicazione e MySQL, la stessa pagina impiega due
secondi. Il cliente non ti scrive “hai un N+1 nel controller degli ordini”: ti scrive “il
gestionale è diventato lento”.

Perché sfugge

Sfugge perché il codice che lo causa è il codice più leggibile che ci sia. $ordine->cliente
è esattamente quello che vuoi scrivere in un template. Eloquent, dietro, fa lazy loading: la
relazione non è caricata, quindi la carica adesso, con una query. Fa la cosa giusta in modo
silenzioso, e il costo si vede solo sommando.

Peggiora quando il ciclo è dentro una view invece che nel controller, perché lì nessuno lo
cerca. E peggiora ancora con le relazioni annidate: $ordine->cliente->indirizzo in un ciclo
di cinquanta ordini fa centouno query.

La soluzione: dire prima cosa ti serve

L’eager loading carica le relazioni in una seconda query sola, con un WHERE IN:

$ordini = Ordine::with('cliente')->latest()->take(50)->get();

Due query invece di cinquantuno. Funziona anche annidato e su più relazioni:

$ordini = Ordine::with(['cliente.indirizzo', 'righe.prodotto'])->get();

Se la collection ce l’hai già in mano, load() fa la stessa cosa dopo:

$ordini->load('cliente');

Due dettagli che fanno la differenza nell’uso quotidiano. Il primo: puoi limitare le colonne,
ma devi includere la chiave che serve a ricomporre la relazione, altrimenti Eloquent non sa a
quale ordine attaccare quale cliente.

Ordine::with('cliente:id,ragione_sociale')->get();

Il secondo: se ti serve solo il numero di righe correlate, non caricarle. $ordine->righe->count()
tira su tutti i record per poi contarli; withCount lo fa fare al database e ti lascia un
attributo righe_count.

$ordini = Ordine::withCount('righe')->get();

Farlo esplodere in sviluppo

Qui sta la parte che conta, ed è la ragione per cui questo post esiste. Sapere cos’è l’N+1 non
serve a niente se poi te ne accorgi in produzione. Da Laravel 8.43 c’è un interruttore che
trasforma ogni lazy load in un’eccezione. In AppServiceProvider::boot():

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

Da quel momento, in locale, $ordine->cliente senza with('cliente') non fa una query di
nascosto: lancia una LazyLoadingViolationException e ti dice modello e relazione. Il bug
smette di essere una questione di attenzione e diventa un errore, come un typo. In produzione
resta disattivato: non vuoi che un N+1 dimenticato faccia una pagina bianca a un utente, vuoi
che sia lento e basta.

Se ti serve una via di mezzo, puoi decidere tu cosa fare della violazione invece di far
saltare tutto: loggarla, per esempio, così la vedi anche in staging senza rompere niente.

Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
    logger()->warning('Lazy load: '.get_class($model).'::'.$relation);
});

Per capire quante query fa davvero una pagina, senza installare niente, basta ascoltarle:

DB::listen(fn ($query) => logger($query->sql));

In un test è ancora più utile: contare le query e far fallire il test se superano una soglia
è il modo più solido per evitare che l’N+1 torni dentro fra sei mesi.

Cosa evitare

Non mettere with fisso sul modello. La proprietà protected $with = ['cliente'] sembra
la soluzione definitiva e invece sposta il problema: adesso ogni query su Ordine carica il
cliente, anche le venti in cui non serve. L’eager loading si decide dove si conosce l’uso, cioè
nel controller o nel repository, non nel modello.

Non caricare relazioni enormi solo per contarle o filtrarle. with('righe') su diecimila
ordini ti mette in memoria tutte le righe. Se ti serve un conteggio usa withCount, se ti serve
un filtro usa whereHas, se devi comunque scorrere tanti record usa chunk() o lazy() invece
di get().

Non risolverlo con la cache. Mettere una cache davanti a una pagina che fa cinquantuno
query non toglie le cinquantuno query: le sposta al primo che apre la pagina dopo ogni
invalidazione, e nasconde il problema fino al giorno in cui la cache non basta più.

L’N+1 non è un problema difficile. È un problema che non si vede finché non fa male, e l’unica
difesa che funziona davvero è renderlo visibile subito: due righe in AppServiceProvider e il
database smette di fare lavoro che nessuno gli ha chiesto.