Doctrine ORM in Symfony 7: ottimizzare le query su database di grandi dimensioni
Doctrine è un ORM eccellente, e per la maggior parte delle applicazioni Symfony è la scelta giusta: ti permette di pensare in oggetti e dimenticare l'SQL. Il problema è che questa comodità ha un prezzo che resta invisibile finché il database è piccolo e diventa improvvisamente molto visibile quando le tabelle crescono. Un findAll() che in sviluppo restituisce trenta righe in un lampo, su una tabella di produzione con qualche milione di righe prova a istanziare qualche milione di oggetti in memoria, e il processo PHP muore con un out of memory prima ancora di renderizzare la pagina. In un progetto di data engineering su dataset di grandi dimensioni ho visto esattamente questo: un metodo innocuo, scritto quando i dati erano pochi, trasformarsi in una bomba a orologeria man mano che il volume cresceva. La buona notizia è che Doctrine dà tutti gli strumenti per gestire database grandi bene; la cattiva è che i suoi default sono pensati per la comodità, non per la scala, e sta a te sapere quando cambiarli. Vediamo come ottimizzare le query in Symfony 7: il controllo dell'hydration, i fetch join contro il lazy loading, il batch processing per le operazioni massive, e quando conviene scendere a SQL nativo senza buttare via la manutenibilità.
Il costo nascosto dell'hydration a oggetti
Per capire dove Doctrine spende, bisogna capire cosa fa quando esegue una query. Di default, Doctrine prende ogni riga restituita dal database e la idrata in un oggetto entità completo, con tutte le sue proprietà, e lo registra nella identity map, la struttura interna che tiene traccia di ogni oggetto caricato per garantirne l'unicità e il tracciamento delle modifiche. Questo è meraviglioso quando lavori con qualche decina di entità che devi modificare: Doctrine sa esattamente cosa è cambiato e genera le query di aggiornamento giuste. È un disastro quando carichi centomila righe che ti servono solo per leggere, perché paghi la creazione di centomila oggetti, il loro popolamento e la loro registrazione nell'identity map, tutto per dati che non modificherai mai.
Doctrine, di default, ti dà sempre il massimo: l'oggetto completo, tracciato e pronto da modificare. Su trenta righe è un regalo, su un milione è un conto che non avevi previsto. La maturità con un ORM è sapere quando rifiutare il regalo e chiedere meno.
Il punto chiave è questo: l'hydration a oggetti è la modalità più costosa, ed è quella di default. Per i casi di sola lettura, reportistica, esportazioni, liste di grandi dimensioni, è quasi sempre lo strumento sbagliato. La prima ottimizzazione, e la più redditizia, non è toccare le query, è cambiare come i risultati vengono materializzati in memoria a seconda di cosa ne devi fare.
Controllare l'hydration: oggetti, array, scalari
Doctrine offre diverse modalità di hydration, e sceglierle consapevolmente è la leva più potente sui dataset grandi. Quando ti servono entità da modificare, l'hydration a oggetti è giusta. Quando ti serve solo leggere, l'hydration ad array restituisce array associativi invece di oggetti, saltando la creazione delle entità e la registrazione nell'identity map, con un risparmio enorme di memoria e CPU. Quando ti servono solo pochi valori scalari, l'hydration scalare è ancora più leggera.
// Sola lettura su molte righe: niente oggetti, niente identity map
$rows = $em->createQuery('SELECT i FROM Invoice i WHERE i.year = :y')
->setParameter('y', 2026)
->getArrayResult(); // array, non entità: molto più leggero
// Solo valori scalari per un report
$totals = $em->createQuery('SELECT i.month, SUM(i.amount) AS total FROM Invoice i GROUP BY i.month')
->getScalarResult();La regola che applico è semplice: idrata a oggetti solo ciò che modificherai, idrata ad array o scalare tutto il resto. Su una lista di reportistica con decine di migliaia di righe, passare da getResult() a getArrayResult() può tagliare il consumo di memoria di un ordine di grandezza, senza cambiare una riga della query. È l'ottimizzazione che faccio per prima, perché è quella che costa meno e rende di più.
Se gestisci un'applicazione Symfony che su tabelle cresciute è diventata lenta o instabile, e vuoi rimettere in ordine il modo in cui interroga il database, nel mio profilo professionale trovi l'esperienza concreta sull'ottimizzazione di applicazioni con dataset di grandi dimensioni, dalla query all'infrastruttura.
Fetch join contro lazy loading: l'N+1 esiste anche in Doctrine
Come ogni ORM, Doctrine carica le associazioni in modo lazy di default: accedi a una relazione e parte una query. Su una lista, questo genera il classico problema N+1, una query per la lista e una per ogni elemento che tocca una relazione. La soluzione in Doctrine è il fetch join: dire alla query di caricare l'associazione nella stessa interrogazione, con una JOIN esplicita in DQL.
// N+1: la fattura e poi, per ognuna, il cliente, una query alla volta
$invoices = $em->getRepository(Invoice::class)->findAll();
// Fetch join: fatture e clienti in una sola query
$invoices = $em->createQuery(
'SELECT i, c FROM Invoice i JOIN i.customer c WHERE i.year = :y'
)->setParameter('y', 2026)->getResult();Da notare la differenza con un join SQL normale: selezionare anche c oltre a i dice a Doctrine di idratare il cliente insieme alla fattura, evitando la query separata. Quando ti servono solo alcune colonne, l'hydration parziale (selezionando esplicitamente i campi) riduce ulteriormente i dati trasferiti e idratati. Questi pattern di costruzione delle query sono il pane quotidiano dell'ottimizzazione, e li ho approfonditi parlando di Doctrine ORM avanzato, query builder e DQL: la differenza tra un'applicazione che fa quattro query per pagina e una che ne fa quattrocento sta quasi sempre qui.
Batch processing: operare su milioni di righe senza saturare la memoria
Questo è il vero scoglio dei database grandi, ed è dove il findAll() innocuo uccide il processo. Quando devi processare un numero enorme di righe, importarle, ricalcolarle, esportarle, non puoi caricarle tutte in memoria insieme. Doctrine offre l'iterazione per il batch processing, che restituisce le righe una alla volta invece di tutte in un array, ma con un'avvertenza fondamentale: l'identity map continua ad accumulare gli oggetti man mano che li carichi, e se non la svuoti periodicamente la memoria cresce comunque fino a saturarsi. La soluzione corretta combina l'iterazione con il clear periodico:
$query = $em->createQuery('SELECT i FROM Invoice i');
$i = 0;
foreach ($query->toIterable() as $invoice) {
// ... elabora la singola fattura ...
if (++$i % 1000 === 0) {
$em->flush(); // scrivi il batch
$em->clear(); // STACCA gli oggetti elaborati: libera la memoria
}
}
$em->flush();Il clear() ogni mille righe è la chiave: distacca dall'identity map gli oggetti già elaborati, permettendo al garbage collector di liberarli. Senza quella riga, anche l'iterazione satura la memoria su un dataset abbastanza grande, perché Doctrine continua a tenere il riferimento a tutto ciò che ha caricato. Con quella riga, puoi processare milioni di righe con un consumo di memoria costante. Per le operazioni massive che possono girare in background, spostare questo lavoro in un processo asincrono è spesso la mossa giusta, come ho descritto parlando di Symfony Messenger per le code asincrone su processi business critici.
La paginazione fatta bene su tabelle profonde
La paginazione sembra un problema risolto, e lo è finché non vai in profondità. La paginazione classica a offset (LIMIT 20 OFFSET 100000) degrada drammaticamente sulle pagine lontane, perché il database deve comunque scorrere e scartare le centomila righe prima di quelle che ti servono. Su una tabella grande, la pagina 5.000 può essere ordini di grandezza più lenta della pagina 1. Doctrine fornisce un Paginator che gestisce correttamente il conteggio e i fetch join, ed è giusto per la maggior parte dei casi, ma su dataset molto grandi con navigazione profonda la soluzione strutturale è la keyset pagination (o seek method): invece di "salta le prime N righe", si usa "dammi le righe dopo l'ultimo valore visto", sfruttando un indice sulla colonna di ordinamento. La pagina successiva diventa una WHERE id > :ultimo_id ORDER BY id LIMIT 20, costante in prestazioni a qualunque profondità. Non è sempre necessaria, ma quando la navigazione profonda conta, è la differenza tra una paginazione che regge e una che muore.
Gli indici: l'ORM non li mette al posto tuo
C'è un fraintendimento pericoloso secondo cui usare un ORM ti esime dal pensare al database. Falso. Doctrine genera l'SQL, ma gli indici li progetti tu, ed è dove si gioca gran parte delle prestazioni. Una query perfettamente ottimizzata a livello di hydration e di join resta lenta se la colonna su cui filtri o ordini non ha un indice, perché il database fa una scansione completa della tabella. La regola è la stessa di sempre: ogni colonna usata in una WHERE, in una JOIN o in un ORDER BY frequente dovrebbe avere un indice appropriato, e su Doctrine questo si dichiara con gli attributi #[ORM\Index] sull'entità. Lo strumento per capire se un indice manca o non viene usato è EXPLAIN sul piano di esecuzione della query, ed è un'abilità che chiunque ottimizzi un'applicazione su database grande deve avere. L'ORM ti astrae dall'SQL, non dalla fisica del database sotto. Su query che filtrano e ordinano insieme su più colonne, spesso la mossa decisiva è un indice composito che copra l'intera condizione: progettarlo richiede di guardare come la query usa davvero le colonne, non di indicizzarle a caso una per una.
Quando scendere a SQL nativo, senza perdere manutenibilità
Arriva un punto in cui combattere con l'ORM costa più che aggirarlo, e riconoscerlo è maturità, non resa. Per certe operazioni, aggregazioni complesse di reportistica, query con funzioni specifiche del database, calcoli massivi, l'SQL nativo o il livello DBAL di Doctrine è semplicemente lo strumento giusto, più chiaro e più veloce di una DQL contorta che cerca di esprimere qualcosa per cui non è nata. Il punto non è "ORM contro SQL", è usare ciascuno dove rende. La regola che applico è di tenere l'ORM per la logica di dominio e le scritture, dove il tracciamento delle entità e la sicurezza dei parametri valgono oro, e scendere a SQL nativo per le letture pesanti e analitiche, dove la potenza espressiva del database batte qualunque astrazione.
La condizione, però, è non perdere la manutenibilità: l'SQL nativo va incapsulato nei repository, non sparso nei controller, e va sempre parametrizzato, mai costruito per concatenazione, per non reintrodurre l'SQL injection che l'ORM evitava per costruzione. Tenuto dentro il suo confine, in un metodo di repository ben nominato, l'SQL nativo è una scelta pulita; sparso nell'applicazione, è il debito tecnico di domani. Questa convivenza tra ORM e SQL mirato è esattamente l'approccio che uso quando costruisco pipeline di analisi dati, come nel caso di un data lake PostgreSQL con Symfony 7 per l'analisi dati.
Doctrine è adatto ai database di grandi dimensioni?
Sì, a una condizione: che tu sappia quando smettere di usare i suoi default. L'ORM non è il problema, lo sono le scelte automatiche pensate per la comodità su dataset piccoli. Doctrine gestisce benissimo database grandi se controlli l'hydration (array e scalare per la lettura), se eviti l'N+1 con i fetch join, se processi le operazioni massive a blocchi con il clear periodico, se pagini con il metodo giusto in profondità, se progetti gli indici invece di sperare, e se scendi a SQL nativo dove l'ORM è lo strumento sbagliato. Sono sei leve, tutte già in Doctrine, e usarle è ciò che separa un'applicazione che scala da una che si pianta al primo milione di righe. L'errore non è scegliere Doctrine, è usarlo come se i dati restassero per sempre pochi.
Tirando le somme, ottimizzare Doctrine su database grandi non richiede di abbandonare l'ORM, richiede di capirlo abbastanza da sapere dove i suoi default smettono di convenire. La comodità dell'hydration a oggetti, del lazy loading e del caricamento in blocco è perfetta in piccolo e velenosa in grande, e il lavoro del senior è riconoscere il momento in cui quella comodità va sostituita con una scelta esplicita: leggere ad array, fare fetch join, iterare con clear, paginare a keyset, indicizzare a mano, scendere a SQL dove serve. Nessuna di queste è una magia, sono tutte strade già aperte dentro Doctrine, e percorrerle è la differenza tra un'applicazione che cresce con i suoi dati e una che ne diventa ostaggio. Se hai un'applicazione Symfony che era veloce e ora arranca sotto il peso di tabelle cresciute, o che satura la memoria su operazioni che un tempo erano innocue, contattami per una consulenza diretta: nella mia esperienza, il primo giro di ottimizzazione su hydration e batch processing restituisce quasi sempre il guadagno più grande con la modifica più piccola.