Illustrazione dell'articolo Il problema N+1: individuarlo, comprenderlo e correggerlo

Il problema N+1: individuarlo, comprenderlo e correggerlo

La tua applicazione funziona bene in sviluppo, la metti in produzione, arrivano i dati e una pagina che rispondeva in cinquanta millisecondi ora ne impiega quattromila. Nel codice non è cambiato nulla; il colpevole è quasi sempre lo stesso. È il problema N+1.

È il problema di prestazioni più comune quando si usa un ORM. È anche uno dei più facili da correggere. Bisogna però saperlo individuare.

Che cos'è il problema N+1

Il nome deriva dal numero di query inviate al database. Una query recupera una lista di N elementi, poi una query aggiuntiva per ogni elemento carica un dato correlato. Totale: N + 1.

Un esempio in Laravel.

$articles = Article::all();

foreach ($articles as $article) {
    echo $article->author->name;
}

Tre righe che sembrano innocue, ma ecco cosa viene realmente inviato al database.

SELECT * FROM articles;

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
-- et ainsi de suite

Con dieci articoli, undici query. Con mille articoli, mille e una.

Ecco la trappola! In sviluppo, il database contiene dieci righe di test, la pagina risponde immediatamente, il problema è invisibile e compare solo quando il volume aumenta. In altre parole, in produzione.

Perché gli ORM producono questo problema

Non è un bug. È la conseguenza diretta del caricamento lazy. Un meccanismo intenzionale.

Un ORM non può sapere in anticipo di cosa avrai bisogno. Se caricasse tutte le relazioni ogni volta, anche la query più semplice riporterebbe metà del database. Carica quindi l'oggetto principale e aspetta che tu richieda una relazione prima di recuperarla.

$article = Article::find(1);
// Una query su articles

$article->author;
// Seconda query, attivata da questo accesso

Su un singolo oggetto, questo comportamento va bene. In un ciclo diventa un problema. Ogni iterazione genera il proprio round trip verso il database.

Il costo reale non è in realtà il tempo di esecuzione SQL. Ogni query comporta un round trip di rete, un'elaborazione da parte del motore e un passaggio nel livello di connessione. Con un database remoto, un millesimo di secondo per query diventa un secondo intero dopo mille iterazioni.

Individuarlo prima della produzione

In Laravel

Il modo più diretto consiste nel generare un errore non appena una relazione viene caricata lazy.

// In AppServiceProvider::boot()
Model::preventLazyLoading(!app()->isProduction());

Fuori dalla produzione, ogni relazione non caricata eager genera una LazyLoadingViolationException. È drastico ma efficace. Non puoi più lasciar passare il problema.

Se preferisci una versione che non interrompa la pagina, registra il problema nei log invece di lanciare un'eccezione.

Model::preventLazyLoading(!app()->isProduction());

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    Log::warning('Relation chargée à la volée : ' . $model::class . '::' . $relation);
});

Puoi quindi aprire storage/logs/laravel.log e vedere esattamente dove sono presenti i tuoi N+1.

Per osservare il traffico SQL reale:

DB::listen(function ($query) {
    Log::debug($query->sql, ['temps' => $query->time]);
});

Laravel Debugbar e Telescope mostrano anche il numero di query per pagina, segnalando i duplicati.

In Spring Boot e Hibernate

Abilita le statistiche di Hibernate.

spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG

A ogni transazione, Hibernate mostra il numero di query preparate e il tempo impiegato. Una differenza tra il numero di entità e il numero di query è immediatamente evidente.

Per vedere SQL leggibile:

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Non lasciare mai queste opzioni attive in produzione perché il volume dei log diventerebbe ingestibile.

La libreria datasource-proxy va oltre. Permette di far fallire un test oltre una certa soglia di query. È il modo migliore per impedire una regressione.

Correggere in Laravel

Eager loading

La soluzione di base è with().

$articles = Article::with('author')->get();

foreach ($articles as $article) {
    echo $article->author->name;
}

Due query, indipendentemente dal numero di articoli.

SELECT * FROM articles;
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5);

Eloquent raccoglie gli ID, esegue una sola query con in e poi associa i risultati in memoria.

Relazioni annidate

// Articolo, il suo autore, i suoi commenti e l'autore di ogni commento
$articles = Article::with('author', 'comments.user')->get();

Limitare le colonne

Caricare l'intera riga per visualizzare solo un nome è uno spreco.

$articles = Article::with('author:id, name, avatar')->get();

Attenzione alla trappola. La chiave esterna deve sempre essere presente nell'elenco. Senza id qui, Eloquent non può associare l'autore all'articolo e la relazione torna vuota senza alcun errore.

Contare senza caricare

Per visualizzare il numero di commenti, non caricare i commenti.

// Sbagliato: carica tutti i commenti solo per contarli
$articles = Article::with('comments')->get();
$count = $article->comments->count();

// Corretto: il conteggio viene eseguito dal database
$articles = Article::withCount('comments')->get();
$count = $article->comments_count;

withCount aggiunge una sottoquery alla query principale. Nessun oggetto inutile viene creato lato PHP.

Le stesse varianti esistono per gli aggregati.

$articles = Article::withSum('comments', 'score')
    ->withAvg('ratings', 'value')
    ->withExists('comments')
    ->get();

Eager loading condizionale

$articles = Article::with(['comments' => function ($query) {
    $query->where('is_approved', true)
        ->latest()
        ->limit(5);
}])->get();

Caricare successivamente

Quando la collection esiste già.

$articles = Article::all();

// A seconda del contesto
if ($needsAuthors) {
    $articles->load('author');
}

loadMissing() fa la stessa cosa ignorando le relazioni già caricate.

Eager loading predefinito

Se una relazione è necessaria in quasi tutti i casi.

class Comment extends Model
{
    protected $with = ['user'];
}

Da usare con cautela. Una relazione caricata ogni volta rende più pesanti tutte le query, comprese quelle che non ne hanno bisogno. Usalo solo per relazioni davvero indispensabili.

Correggere con JPA e Hibernate

Il principio è lo stesso, gli strumenti sono diversi.

La trappola dei valori predefiniti

Punto fondamentale e spesso ignorato. JPA non ha lo stesso comportamento predefinito a seconda del tipo di relazione.

Annotazione Caricamento predefinito
@OneToMany LAZY
@ManyToMany LAZY
@ManyToOne EAGER
@OneToOne EAGER

Le ultime due righe sono problematiche: un @ManyToOne EAGER significa che caricare un'entità carica anche il suo parent ogni volta, anche quando non ne hai bisogno. Su un'entità con diverse relazioni @ManyToOne, una sola lettura può generare una cascata di join.

Tutti raccomandano la stessa cosa: imposta tutto su LAZY e poi carica esplicitamente ciò di cui hai bisogno.

@Entity
public class Article {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private User author;

    @OneToMany(mappedBy = "article", fetch = FetchType.LAZY)
    private List<Comment> comments;
}

JOIN FETCH

L'equivalente diretto di with() di Laravel.

@Query("""
    SELECT a FROM Article a
    JOIN FETCH a.author
    WHERE a.publishedAt IS NOT NULL
    """)
List<Article> findPublishedWithAuthor();

Attenzione alla differenza tra join e join fetch. Un semplice join serve a filtrare, non carica la relazione. Solo join fetch la porta in memoria.

Entity graph

Più flessibili, separano la query dalla strategia di caricamento.

@Entity
@NamedEntityGraph(
    name = "Article.withAuthorAndComments",
    attributeNodes = {
        @NamedAttributeNode("author"),
        @NamedAttributeNode("comments")
    }
)
public class Article { }
public interface ArticleRepository extends JpaRepository<Article, Long> {

    @EntityGraph(value = "Article.withAuthorAndComments")
    List<Article> findByPublishedAtIsNotNull();
}

Il vantaggio è evidente: lo stesso metodo può avere diverse strategie di caricamento a seconda delle necessità senza duplicare la query.

Caricamento a lotti

Una soluzione intermedia, molto efficace e spesso dimenticata.

spring.jpa.properties.hibernate.default_batch_fetch_size=25

Hibernate raggruppa quindi i caricamenti lazy in batch. Invece di cento query, ne invia quattro con un in di venticinque ID. Non si passa da N+1 a 1, ma da N+1 a N/25 + 1. Nella maggior parte dei casi è più che sufficiente.

L'annotazione @BatchSize permette la stessa configurazione relazione per relazione.

Proiezioni DTO

La soluzione più performante quando hai solo bisogno di leggere.

public record ArticleSummary(Long id, String title, String authorName) { }
@Query("""
    SELECT new com.blog.dto.ArticleSummary(a.id, a.title, a.author.name)
    FROM Article a
    """)
List<ArticleSummary> findAllSummaries();

Una sola query e solo le colonne necessarie. Nessuna entità gestita dal contesto di persistenza. Per una pagina di elenco in sola lettura, è spesso la scelta più efficace.

Le trappole specifiche di Hibernate

Tre errori classici che sorprendono tutti almeno una volta.

MultipleBagFetchException

// Genera un'eccezione all'avvio
@Query("""
    SELECT a FROM Article a
    JOIN FETCH a.comments
    JOIN FETCH a.tags
    """)

Hibernate rifiuta di caricare due collection di tipo List nella stessa query. Il messaggio è chiaro, la causa lo è meno. Una List senza indice mantiene i duplicati. Due join produrrebbero un risultato ambiguo.

Tre soluzioni: sostituire List con Set, eseguire due query separate (il contesto di persistenza riunisce automaticamente i risultati) oppure usare @BatchSize sulla seconda collection.

Il prodotto cartesiano

Anche con i Set, due join fetch su collection producono un prodotto cartesiano nel database. Un articolo con venti commenti e cinque tag restituisce cento righe SQL. Hibernate elimina i duplicati in memoria, ma tutte le cento righe hanno attraversato la rete.

In pratica, una sola collection per query. Le altre passano tramite @BatchSize o una query separata.

Paginazione e JOIN FETCH

L'avviso HHH000104 è il più pericoloso di tutti perché il codice continua comunque a funzionare.

@Query("SELECT a FROM Article a JOIN FETCH a.comments")
Page<Article> findAll(Pageable pageable);

Hibernate non può applicare limit in SQL. Il join moltiplica le righe e un limite interromperebbe le collection a metà. Quindi carica l'intera tabella e poi esegue la paginazione nella memoria Java.

Con mille articoli, la tua pagina di venti elementi ne carica mille. Nessun errore, solo un'applicazione che crolla non appena il volume aumenta.

La soluzione consiste nel dividere l'operazione in due passaggi.

// 1. Recuperare gli ID della pagina senza join
@Query("SELECT a.id FROM Article a ORDER BY a.publishedAt DESC")
Page<Long> findPageOfIds(Pageable pageable);

// 2. Caricare queste entità con le loro collection
@Query("SELECT a FROM Article a JOIN FETCH a.comments WHERE a.id IN :ids")
List<Article> findAllWithCommentsByIds(@Param("ids") List<Long> ids);

L'eccesso opposto

Correggere un N+1 non significa caricare tutto in eager loading.

// Costoso e probabilmente inutile
$articles = Article::with([
    'author.profile.settings',
    'comments.user.roles',
    'tags',
    'category.parent',
])->paginate(15);

Se la tua vista mostra solo il titolo e il nome dell'autore, tutto il resto è trasferimento e memoria sprecati. Una sola query grande può essere più lenta di alcune query piccole.

La regola si riassume in una frase: carica in eager loading esattamente ciò che la vista utilizza, non ciò di cui potrebbe avere bisogno un giorno. L'eager loading risponde a una necessità osservata. Non è una precauzione.

Un metodo di lavoro

Quattro passaggi in quest'ordine.

Misura prima: conta il numero di query della tua pagina. In Laravel lo mostra Debugbar. In Spring lo forniscono le statistiche di Hibernate. Senza misurare, ottimizzi alla cieca.

Rendi visibile il problema: preventLazyLoading in Laravel, una soglia di query nei test Spring. Un problema segnalato dallo strumento non può più tornare di nascosto.

Correggi nel posto giusto: l'eager loading va dichiarato nel controller o nel repository, mai nella vista. Una vista non dovrebbe mai generare una query.

Verifica dopo: conta di nuovo. Passare da centouno a due query è qualcosa che si osserva, non che si presume.

Per approfondire

Commenti (0)

Lascia un commento

Non hai effettuato l'accesso

Puoi commentare indicando il tuo nome e la tua email. Il messaggio sarà pubblicato dopo la moderazione. Con un account appare subito e resta modificabile.

La tua email non sarà pubblicata.

Ancora nessun commento

Sii il primo a commentare questo articolo!