Refactoring database MySQL su Laravel: report da 47 minuti a 11 secondi senza upgrade hardware
A marzo 2025 mi ha contattato il responsabile IT di un'azienda metalmeccanica in provincia di Reggio Emilia - una PMI da circa 180 dipendenti che gestisce un portale B2B per la distribuzione di componentistica industriale verso tutta l'area DACH (Germania, Austria, Svizzera). Il portale girava su un VPS Contabo da 8 vCPU, 32 GB RAM e 400 GB SSD, con stack LEMP classico: Nginx 1.22, PHP-FPM 8.1, MySQL 8.0, e una codebase Laravel 9 che aveva cinque anni e mezzo di evoluzione organica sulle spalle. Il problema che mi hanno descritto al telefono era apparentemente banale: "il report mensile delle vendite è lentissimo." Quando ho chiesto quanto fosse lentissimo, la risposta è stata 47 minuti. Non millisecondi, non secondi - quarantasette minuti di esecuzione per un report che il backoffice generava il primo di ogni mese, bloccando di fatto le altre operazioni sul database per quasi un'ora.
Il database MySQL pesava 12 GB su disco. La tabella orders conteneva 4,7 milioni di record accumulati in cinque anni. La tabella order_items ne conteneva 18,3 milioni. Su orders erano definiti 38 indici - una cifra che da sola racconta una storia di panico incrementale: ogni volta che una query era lenta, qualcuno aggiungeva un indice. Nessuno ne aveva mai rimosso uno. Nessuno aveva mai verificato se quegli indici venissero effettivamente usati dall'optimizer. Il risultato: 12 dei 38 indici non erano mai stati toccati da una singola query dall'ultimo riavvio del server (sei mesi prima), ma continuavano a rallentare ogni INSERT e UPDATE sulla tabella perché InnoDB li manteneva aggiornati ad ogni scrittura.
Questo articolo descrive il metodo che ho applicato in quattro giornate di lavoro per portare quel report da 47 minuti a 11 secondi. Non è una lista di "trucchi per velocizzare MySQL" - è un processo di diagnosi, intervento chirurgico sullo schema e riconfigurazione del motore di storage che si può replicare su qualunque applicazione Laravel con un database cresciuto senza governo.
Perché la tua applicazione Laravel è lenta e il database non c'entra quasi mai come pensi?
In otto interventi su dieci che faccio su applicazioni Laravel lente, il problema non è "il database MySQL" inteso come software. MySQL 8 è un RDBMS maturo, stabile e performante. Il problema è come l'applicazione parla al database: query generate da Eloquent senza eager loading, SELECT * su tabelle con 40 colonne quando ne servono 3, indici che non corrispondono ai pattern reali di accesso, e configurazioni di InnoDB rimaste ai valori di default pensati per una macchina di sviluppo con 512 MB di RAM.
La diagnosi deve sempre precedere l'intervento. Sempre. Senza eccezioni. Il primo strumento è lo slow query log di MySQL - non con la soglia classica di 1 secondo che trovi nei tutorial, ma a 100 millisecondi, per catturare anche le query che individualmente sembrano veloci ma che vengono eseguite migliaia di volte al giorno:
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.1
log_queries_not_using_indexes = 1Sul VPS Contabo del cliente emiliano, dopo 24 ore di raccolta con queste impostazioni, lo slow log conteneva 14.200 query. Di queste, 11.800 erano varianti della stessa operazione: una subquery correlata nella stored procedure che generava il report mensile, che per ogni riga della tabella orders andava a cercare l'ultimo prezzo applicato nella tabella price_history - un classico anti-pattern in cui il database esegue una query annidata per ogni riga del result set esterno. Il secondo strumento è EXPLAIN ANALYZE, disponibile da MySQL 8.0.18, che a differenza del semplice EXPLAIN esegue effettivamente la query e ti mostra i tempi reali, non quelli stimati dall'optimizer:
EXPLAIN ANALYZE
SELECT o.id, o.total_amount,
(SELECT ph.unit_price FROM price_history ph
WHERE ph.product_id = oi.product_id
AND ph.valid_from <= o.created_at
ORDER BY ph.valid_from DESC LIMIT 1) as applied_price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at BETWEEN '2025-01-01' AND '2025-01-31'L'output di EXPLAIN ANALYZE mostrava un dato che spiegava tutto: la subquery correlata veniva eseguita 847.000 volte per il solo mese di gennaio - una volta per ogni riga di order_items collegata agli ordini del periodo. Con un tempo medio di 3,2 millisecondi per esecuzione, il conto è aritmetico: 847.000 × 3,2 ms = 45 minuti circa. Il report di 47 minuti non era un mistero, era una moltiplicazione.
Stai cercando un Consulente Informatico esperto per un intervento di questo tipo sulla tua applicazione Laravel? Nel mio profilo professionale trovi l'esperienza concreta su refactoring di database MySQL in produzione, tuning InnoDB e diagnostica avanzata su VPS Hetzner, Contabo, OVH e Digital Ocean.
L'anatomia di un database MySQL cresciuto senza governo
La subquery correlata era il sintomo più vistoso, ma il database del cliente aveva problemi strutturali profondi che si erano accumulati in cinque anni di sviluppo senza DBA e senza review. Il pattern è identico su decine di progetti che ho preso in carico: lo sviluppatore originale crea lo schema iniziale con Laravel, le migration funzionano, l'app parte. Poi lo sviluppatore cambia, o viene aggiunto un secondo, o il primo smette di fare code review. E lo schema cresce come un organismo senza piano urbanistico.
Ecco cosa ho trovato sulla tabella orders:
- 38 indici di cui 12 mai utilizzati dall'optimizer. Li ho identificati interrogando la vista
sys.schema_unused_indexesche MySQL 8 mette a disposizione tramite il Performance Schema:
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'b2b_portal'
AND object_name = 'orders';- Colonne varchar(255) ovunque:
order_reference(mai oltre 12 caratteri),shipping_method(4 valori possibili, candidata a ENUM),notesdefinita come varchar(255) ma che conteneva testi fino a 8.000 caratteri troncati silenziosamente per anni. - Nessuna foreign key: le relazioni tra
orders,order_items,customerseprice_historyesistevano solo a livello applicativo, non di schema. Zero integrità referenziale a livello di database e nessuna possibilità per l'optimizer di usare le relazioni per ottimizzare i JOIN. - Soft delete accumulati: la tabella
order_itemsaveva 18,3 milioni di record, ma 4,1 milioni avevanodeleted_atnon null - elementi eliminati logicamente ma mai rimossi, che appesantivano ogni query con unWHERE deleted_at IS NULLimplicito aggiunto dalloSoftDeletestrait di Laravel.
Il problema degli indici inutilizzati merita un approfondimento perché è il caso più comune e più sottovalutato. Ogni indice su una tabella InnoDB occupa spazio su disco e in memoria (nel buffer pool), e viene aggiornato ad ogni operazione di scrittura. Su una tabella con 4,7 milioni di record e migliaia di INSERT/UPDATE al giorno, 12 indici inutilizzati significano 12 strutture B-tree che il motore mantiene aggiornate senza che nessuna query ne tragga beneficio. Il costo non è solo di spazio - è di I/O e di contesa sui lock durante le scritture.
Per rimuoverli in sicurezza ho usato gli invisible indexes di MySQL 8, una funzionalità che permette di rendere un indice invisibile all'optimizer senza eliminarlo fisicamente. L'indice continua a esistere su disco e viene mantenuto aggiornato, ma l'optimizer lo ignora completamente nel piano di esecuzione. Se dopo una settimana di traffico reale nessuna query rallenta, puoi eliminarlo con certezza:
-- Fase 1: rendi l'indice invisibile (istantaneo, zero downtime)
ALTER TABLE orders ALTER INDEX idx_legacy_status INVISIBLE;
-- Fase 2: monitora per 7 giorni con slow query log e dashboard
-- Fase 3: se nessuna regressione, elimina fisicamente
ALTER TABLE orders DROP INDEX idx_legacy_status;In tre settimane ho eliminato tutti e 12 gli indici fantasma con questo metodo, senza un singolo incidente in produzione. Il tempo medio di INSERT sulla tabella orders è sceso del 22% - un effetto collaterale che il cliente non si aspettava ma che ha notato immediatamente nel backoffice.
Riscrivere la query killer: da subquery correlata a lateral join
Il fix della query del report mensile è stato il singolo intervento con il maggiore impatto. La subquery correlata - quella che veniva eseguita 847.000 volte - cercava per ogni order_item l'ultimo prezzo valido dalla tabella price_history. La riscrittura ha usato una tecnica che in MySQL 8.0.14+ si esprime con LATERAL JOIN - una join in cui la subquery può riferirsi alle colonne della tabella esterna, ma viene materializzata in modo molto più efficiente dal planner. Prima di riscrivere, però, serviva un indice composito sulla tabella price_history che coprisse esattamente il pattern di accesso della query, con un indice discendente su valid_from - una funzionalità introdotta con MySQL 8.0 che elimina la necessità di un filesort quando l'ORDER BY è DESC:
-- Indice composito con colonna discendente per evitare filesort
ALTER TABLE price_history
ADD INDEX idx_product_valid (product_id, valid_from DESC);-- Query riscritta con LATERAL JOIN
SELECT o.id, o.total_amount, ph_latest.unit_price as applied_price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN LATERAL (
SELECT ph.unit_price
FROM price_history ph
WHERE ph.product_id = oi.product_id
AND ph.valid_from <= o.created_at
ORDER BY ph.valid_from DESC
LIMIT 1
) ph_latest ON TRUE
WHERE o.created_at BETWEEN '2025-01-01' AND '2025-01-31';La differenza: da 847.000 esecuzioni della subquery a un singolo piano di esecuzione che sfrutta l'indice composito con scansione in avanti sul B-tree discendente. Tempo del report per il mese di gennaio: da 47 minuti a 28 secondi. Un fattore 100×. Ma 28 secondi erano ancora migliorabili.
InnoDB sotto il cofano: i parametri che cambiano tutto
Il secondo livello di intervento ha riguardato la configurazione di InnoDB, che sul VPS del cliente era rimasta ai valori di default installati con il pacchetto mysql-server di Ubuntu 22.04. La documentazione ufficiale MySQL sulle variabili di configurazione InnoDB è esplicita: su un server dedicato prevalentemente al database, il buffer pool dovrebbe occupare il 50-75% della RAM totale. Sul VPS del cliente, con 32 GB di RAM, il valore di innodb_buffer_pool_size era il default di 128 MB - lo 0,4% della memoria disponibile. Il database di 12 GB stava cercando di lavorare con una finestra di 128 MB sulla propria memoria: come leggere un libro di 600 pagine potendo tenerne aperte solo 6 alla volta.
[mysqld]
# Buffer pool: 20 GB su 32 GB totali (62%, il resto va a PHP-FPM, OS, Redis)
innodb_buffer_pool_size = 20G
innodb_buffer_pool_instances = 4
# Redo log: da MySQL 8.0.30 si usa innodb_redo_log_capacity
# al posto dei vecchi innodb_log_file_size + innodb_log_files_in_group
innodb_redo_log_capacity = 2G
# Durabilità: il default flush_log_at_trx_commit=1 è il più sicuro,
# ma su SSD affidabile il valore 2 è accettabile per molti workload
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
# I/O capacity: valori per SSD (il default 200 è pensato per HDD rotazionali)
innodb_io_capacity = 4000
innodb_io_capacity_max = 8000
# Persistenza buffer pool: ricarica le pagine calde dopo un restart
innodb_buffer_pool_dump_at_shutdown = ON
innodb_buffer_pool_load_at_startup = ONUn punto critico che Percona ha documentato in dettaglio nel febbraio 2026: il parametro innodb_redo_log_capacity ha sostituito la combinazione innodb_log_file_size + innodb_log_files_in_group a partire da MySQL 8.0.30. Se trovi ancora guide che ti dicono di impostare innodb_log_file_size = 512M, stanno usando una sintassi pre-8.0.30. Non è tecnicamente sbagliata - MySQL accetta ancora i vecchi parametri per compatibilità - ma è un segnale che la guida non è aggiornata.
E poi c'è la query cache. Il file originale di questo articolo - scritto nel 2025 - raccomandava query_cache_type = 1 e query_cache_size = 64M. Questa configurazione non solo è inutile su MySQL 8.0: è impossibile. La query cache è stata rimossa completamente in MySQL 8.0, dopo essere stata deprecata già in MySQL 5.7.20. Se il tuo my.cnf contiene query_cache_type, sei su MySQL 5.7 o stai copiando configurazioni da guide obsolete. In entrambi i casi, hai un problema più urgente della cache.
Dopo il restart di MySQL con la nuova configurazione, il report mensile è sceso da 28 secondi a 11 secondi. L'intero working set del database stava ora comodamente nel buffer pool, e le letture da disco fisico sono crollate del 96% nelle prime 24 ore di monitoring con Grafana. Il tuning di InnoDB è uno dei livelli di intervento - per il quadro completo che include PHP-FPM, OPcache e caching applicativo, ho descritto il metodo su un caso analogo nel mio articolo sulla riduzione di un checkout da 4,2 secondi a 280 millisecondi su Hetzner.
Sicurezza del database: cosa significa davvero in un contesto Laravel
La sicurezza di un database MySQL in un contesto Laravel non è una questione di "aggiungere password complesse" - è una questione di architettura dei permessi. Il principio è uno solo: l'utente MySQL che l'applicazione usa per connettersi deve avere esattamente i permessi che servono e niente di più. Su Laravel, nella stragrande maggioranza dei casi, l'utente applicativo ha bisogno di SELECT, INSERT, UPDATE, DELETE sul proprio database. Non ha bisogno di CREATE, ALTER, DROP, GRANT, o SUPER. Le migration si eseguono con un utente separato, tipicamente da CLI durante il deploy, non dall'utente che serve le richieste web:
-- Utente applicativo: solo DML, connessione cifrata obbligatoria
CREATE USER 'app_b2b'@'127.0.0.1'
IDENTIFIED WITH caching_sha2_password BY 'password_generata_con_openssl'
REQUIRE SSL;
GRANT SELECT, INSERT, UPDATE, DELETE
ON b2b_portal.* TO 'app_b2b'@'127.0.0.1';
-- Utente migration: DDL completo, usato solo da artisan migrate
CREATE USER 'deploy_b2b'@'127.0.0.1'
IDENTIFIED WITH caching_sha2_password BY 'altra_password_generata'
REQUIRE SSL;
GRANT ALL PRIVILEGES ON b2b_portal.* TO 'deploy_b2b'@'127.0.0.1';Il REQUIRE SSL forza la connessione cifrata anche su localhost. Sembra ridondante su un VPS dove app e database girano sulla stessa macchina, ma protegge contro scenari di lateral movement in cui un attaccante che ottiene accesso a un altro processo sulla macchina potrebbe intercettare il traffico MySQL sul socket TCP. Con require_secure_transport = ON nel my.cnf, MySQL rifiuta qualsiasi connessione non cifrata a livello globale.
Sul cliente emiliano, l'utente MySQL dell'applicazione era root@localhost con una password che conteneva il nome dell'azienda e l'anno di fondazione. L'intero backoffice, il portale clienti e le stored procedure di reportistica giravano con privilegi di superutente. Tra il principio del minimo privilegio e quella configurazione c'era la stessa distanza che c'è tra un sistema di allarme e una porta spalancata. Per chi gestisce applicazioni Laravel con dati sensibili - e nel B2B i dati degli ordini, i listini personalizzati e le anagrafiche clienti lo sono sempre - ho scritto un articolo dedicato all'hardening di MySQL su VPS unmanaged che copre in dettaglio la segregazione degli utenti, il binding su interfacce specifiche, l'audit log e la cifratura at-rest.
Migration sicure su tabelle con milioni di righe
L'ultimo intervento sul cliente emiliano ha riguardato il refactoring dello schema attraverso migration Laravel. Il problema: su una tabella orders da 4,7 milioni di righe, un ALTER TABLE classico può richiedere minuti o decine di minuti, durante i quali la tabella è potenzialmente bloccata in scrittura. MySQL 8.0 ha migliorato enormemente il supporto per le operazioni DDL online - la maggior parte degli ADD INDEX, DROP INDEX e ADD COLUMN può essere eseguita con ALGORITHM=INPLACE, LOCK=NONE, senza bloccare le scritture concorrenti. Ma ci sono eccezioni importanti: cambiare il tipo di una colonna, rimuovere una colonna, o modificare la lunghezza di un varchar oltre la soglia di byte per il prefisso di lunghezza (da varchar(255) a varchar(256), per esempio) richiede una copia completa della tabella.
Per le migration che richiedono copia della tabella, in produzione uso pt-online-schema-change di Percona Toolkit, che crea una tabella shadow, copia i dati in chunk, intercetta le scritture concorrenti tramite trigger, e alla fine fa un rename atomico. La migration Laravel resta nel repository per documentazione e per lo schema in staging, ma l'esecuzione su produzione passa per lo strumento di Percona:
# Esempio: cambiare notes da varchar(255) a TEXT su una tabella da milioni di righe
pt-online-schema-change \
--alter "MODIFY COLUMN notes TEXT" \
--execute \
--chunk-size=1000 \
--max-lag=1s \
D=b2b_portal,t=ordersIl parametro --max-lag=1s è critico: se il server accumula più di un secondo di ritardo nelle scritture rispetto al flusso di copia, lo strumento si ferma automaticamente e riprende quando il lag rientra. Questo previene il rischio di un backlog di scritture che potrebbe degradare le performance durante la migration. Sulle tecniche avanzate di ottimizzazione delle query Eloquent in contesti gestionali - dall'eager loading alle subquery all'automatic eager loading di Laravel 12 - ho scritto un articolo specifico che è il complemento naturale di quello che stai leggendo.
Il risultato complessivo sul cliente emiliano, dopo quattro giornate di lavoro distribuite su due settimane: report mensile da 47 minuti a 11 secondi, tempo medio di risposta del backoffice da 3,4 secondi a 420 millisecondi, tempo di INSERT sulla tabella orders ridotto del 22% grazie alla rimozione degli indici fantasma. Nessun upgrade hardware - lo stesso VPS Contabo da 8 vCPU e 32 GB che il cliente pagava già prima dell'intervento. Il costo dell'intervento: quattro giornate fatturate a tariffa standard. Il costo dell'alternativa che il responsabile IT stava valutando - migrare a un dedicated server da 128 GB - era di circa 280 euro al mese in più, 3.360 euro l'anno di OPEX ricorrenti, per un hardware che avrebbe mascherato il problema senza risolverlo e che avrebbe iniziato a soffrire degli stessi sintomi nel giro di 18-24 mesi, quando i dati fossero cresciuti ancora.
Se gestisci un'applicazione Laravel critica e il database ha iniziato a mostrare segni di lentezza - report che si allungano, backoffice che non risponde, timeout intermittenti - il problema è quasi sempre negli indici, nello schema o nella configurazione di InnoDB, non nell'hardware. E l'intervento più redditizio che puoi fare non è comprare un server più grosso, ma capire perché quello attuale non sta lavorando come dovrebbe. Contattami per una consulenza: in due giornate di diagnostica identifico i colli di bottiglia con slow query log, EXPLAIN ANALYZE e profiling del buffer pool, e in altre due applico le ottimizzazioni con deploy controllato e metriche comparate prima e dopo.