Performance PHP su Hetzner, OVH e Digital Ocean: come ho ridotto un checkout da 4,2 secondi a 280 millisecondi senza upgrade hardware

Performance PHP su Hetzner, OVH e Digital Ocean: come ho ridotto un checkout da 4,2 secondi a 280 millisecondi senza upgrade hardware

Il 9 gennaio 2025 mi ha contattato il proprietario di un'azienda veronese che gestisce un portale B2B di forniture per il settore HoReCa, con un fatturato di circa 6 milioni di euro l'anno e un picco stagionale concentrato nelle sei settimane prima dell'estate. Il portale girava su un server dedicato Hetzner AX52 (Ryzen 7 7700, 64 GB RAM DDR5, 2×NVMe da 1 TB in RAID 1) con stack LEMP standard - Nginx 1.24, PHP-FPM 8.2, MySQL 8.0 - e una codebase Laravel 10 che aveva otto anni di evoluzione organica sulle spalle. Il problema concreto: da metà dicembre i clienti che cercavano di completare un ordine vedevano il checkout impallarsi per 4-6 secondi, e in un certo numero di casi fallire del tutto con timeout HTTP 504 dal reverse proxy Cloudflare.

Il proprietario aveva già parlato con il suo account manager Hetzner e si stava facendo preventivare un upgrade a un AX102 (32 core, 128 GB RAM) per circa 380 euro al mese in più - quasi 4.500 euro l'anno di OPEX aggiuntivi. Quando mi ha chiesto un secondo parere al telefono, l'ho convinto a darmi otto ore di lavoro prima di firmare l'upgrade. Otto ore dopo, distribuite su due giornate lavorative, il checkout era a 280 millisecondi di mediana e 750 millisecondi al 99° percentile, con il 99,2% delle richieste sotto il secondo anche in fascia di picco. Hardware originale, zero euro di costi infrastrutturali aggiuntivi, zero modifiche alla tariffazione del provider.

Questo articolo è il distillato del metodo che applico in queste situazioni - non un elenco di "trucchi per velocizzare PHP" da blog motivazionale, ma il processo ordinato di diagnostica e remediation che ho rifinito su decine di clienti negli ultimi anni. Il principio guida che ripeto sempre ai titolari PMI è uno solo: l'hardware è quasi sempre l'ultima leva da tirare, non la prima. Un server più grosso ti compra qualche mese di tregua, ma il problema vero - la query inefficiente, l'indice mancante, la cache mal pianificata, il pattern N+1 dimenticato in un loop - resta lì e ti torna addosso al prossimo picco di traffico, peggio di prima e in un contesto in cui hai già speso i soldi dell'upgrade.

Perché "ottimizzare a sentimento" è l'errore che fa raddoppiare il budget IT della tua PMI?

L'errore numero uno che vedo fare in queste situazioni è esattamente quello che chiamo "ottimizzazione a sentimento". Lo sviluppatore o l'agenzia che ha ereditato il sistema apre un editor, guarda il codice del checkout, e inizia a "ottimizzare" cose che pensa siano lente - sostituendo un array_map con un foreach, aggiungendo memoize manuale ai metodi, riscrivendo le query Eloquent in raw SQL "perché vanno più veloci", togliendo abstraction layer "che rallentano". Il problema di questo approccio è che senza profiling reale stai ottimizzando le cose sbagliate, e ogni ora spesa a riscrivere codice che non era il collo di bottiglia è un'ora in più verso il fatidico upgrade hardware che a quel punto diventa inevitabile. Nel caso del cliente veronese, il primo dev che aveva guardato il sistema era convinto che il problema fosse "il template Blade troppo nidificato". Il rendering del template Blade contribuiva per 12 millisecondi al tempo totale di 4.200 millisecondi. Zero virgola tre per cento.

La regola che applico sempre nei primi trenta minuti di un intervento di performance è una sola, e non ammette eccezioni: prima profili con strumenti, poi parli e intervieni. Gli strumenti principali sono tre. Per il backend PHP uso Blackfire come profiler ufficiale di SensioLabs, documentato in dettaglio nella guida introduttiva, che ti dà una flame graph con la decomposizione esatta del tempo speso in ogni chiamata di funzione e in ogni query SQL generata dall'applicazione. Per il database attivo lo slow_query_log di MySQL, impostato a 100 millisecondi come soglia, in modo da catturare tutte le query oltre quella soglia per 24 ore consecutive di traffico reale. Per il frontend, quando serve, uso Lighthouse e WebPageTest. Lo slow query log si attiva con queste quattro righe in /etc/mysql/mysql.conf.d/mysqld.cnf:

[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.1
log_queries_not_using_indexes = 1

Sul cliente veronese, in trenta minuti di profiling Blackfire avevamo già la risposta definitiva: l'80% del tempo della richiesta veniva speso in 47 query identiche alla tabella quote_lines - il classico pattern N+1 generato da un foreach che non eager-loadava la relazione quoteLines sul Quote model. Il punto critico di questa diagnosi è che senza profiling quel pattern era completamente invisibile al code review tradizionale: il codice Laravel sembrava normale, un semplice loop su un model con accesso a un attributo relazionale. Ma in produzione, con dataset reali da 200-400 quote per cliente nel periodo di picco, generava una tempesta di query che il MySQL faticava a smaltire. La regola operativa che do ai team è: qualunque ciclo che accede a un attributo relazionale di un Eloquent model è sospetto fino a prova contraria. La prova contraria è un eager-load esplicito con with('relazione') o un commento in codice che spiega perché il caricamento lazy è intenzionale in quel punto specifico. Niente di meno.

Database: dove vive l'ottanta per cento del problema in un'app PHP lenta

Una volta identificato il pattern N+1 con il profiler, il fix è quasi banale: aggiungere with(['quoteLines.product', 'quoteLines.taxRate']) al query builder che caricava la lista dei preventivi nella schermata di checkout. Da 47 query a 3 query in totale per la pagina. Tempo del checkout sceso istantaneamente da 4.200 ms a 1.100 ms. Ma 1.100 ms erano ancora molti, troppi per un'esperienza utente accettabile, e il profiler ora indicava la seconda voce pesante: una singola query sulla tabella shipping_quotes che impiegava 740 ms su un dataset di circa 1,2 milioni di record storici accumulati negli anni. Un EXPLAIN mi ha rivelato il problema in dieci secondi di analisi:

EXPLAIN SELECT * FROM shipping_quotes
WHERE carrier_id = 7 AND weight_band = 'L'
  AND zip_code_prefix = '371' LIMIT 1;

Il piano di esecuzione mostrava type: ref, rows: 18432, key: idx_zip_code_prefix: la query filtrava su tre colonne (zip_code_prefix, weight_band, carrier_id) ma esisteva solo un indice singolo su zip_code_prefix, e il database scansionava migliaia di righe per ogni richiesta prima di trovare il match. Il fix è stato un indice composito sui tre campi nella loro selettività decrescente (carrier_id ha 7 valori distinti, weight_band ne ha 4, zip_code_prefix ne ha migliaia), applicato con una semplice migration Laravel e deployato durante una finestra a basso traffico di dieci minuti. La query è scesa da 740 ms a 8 ms. Il tempo del checkout dopo questo secondo fix: 320 ms. La regola operativa che insegno ai team su questo livello è: ogni query che appare nello slow log con frequenza superiore a 10 chiamate al minuto e tempo medio sopra i 100 ms va ispezionata con EXPLAIN, e ogni riga del piano che mostra type: ALL o rows sopra le mille unità va fixata con un indice prima di guardare qualunque altra ottimizzazione.

C'è poi il livello di tuning del MySQL stesso, che è spesso dimenticato sui server PMI perché "il MySQL viene preconfigurato dal provider". Quasi mai davvero, o quasi mai in modo sensato per il tuo workload specifico. Il parametro più importante è innodb_buffer_pool_size, che la documentazione ufficiale di MySQL 8 descrive con estrema chiarezza nella sezione Configuring InnoDB Buffer Pool Size: su un server dedicato prevalentemente al database, il valore consigliato è il 60-75% della RAM totale. Sul cliente veronese, il valore di default era 128 MB su un server con 64 GB di RAM - meno dello 0,2% della memoria disponibile. La modifica è una sola sezione in /etc/mysql/my.cnf più un riavvio chirurgico del servizio:

[mysqld]
innodb_buffer_pool_size = 16G
innodb_buffer_pool_instances = 8
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT

Dopo questa modifica e un systemctl restart mysql in finestra, l'intero working set del database stava comodamente in RAM e l'I/O fisico sui dischi NVMe è crollato del 94% nel monitoring sui primi trenta minuti. Sui clienti su cui faccio interventi di refactoring strategico del codice e del database in contesti Laravel legacy come ho descritto in un altro articolo dedicato, questo singolo parametro è quello che da solo produce i miglioramenti più visibili nelle prime 24 ore di intervento e convince il cliente che l'investimento vale la pena.

PHP-FPM, OPcache e JIT: i tre dial più sottovalutati su Hetzner e OVH

Una volta che il database ha smesso di essere il collo di bottiglia, il livello successivo del tuning è il PHP runtime. Le tre leve principali sono PHP-FPM (il process manager), OPcache (la cache del bytecode compilato) e JIT (il compilatore just-in-time introdotto con PHP 8.0) - e in nessuno dei tre casi le impostazioni di default che trovi su un server appena provisionato sono ottimizzate per il tuo workload reale. Su PHP-FPM la regola di calibrazione di pm.max_children è empirica ma solida: misuri quanta RAM consuma in media un worker (con ps aux --sort=-rss | grep php-fpm durante un carico realistico, oppure con il pannello status di FPM abilitato), dividi la RAM disponibile al PHP per quel valore, e tieni un margine di sicurezza del 20% per assorbire i picchi. Su un Hetzner AX52 con 64 GB di RAM, di cui 16 GB sono assegnati a InnoDB e 4 GB a Redis e 4 GB al sistema operativo e ai buffer del kernel, restano circa 40 GB disponibili per PHP. Se ogni worker pesa in media 80 MB di RSS, puoi tenere fino a 500 worker simultanei. Sul cliente veronese, il valore di default del pool era 50: il sistema saturava i worker durante i picchi e generava code di richieste in attesa nel socket Unix di FPM, che era una delle cause primarie dei timeout 504 intermittenti osservati dal cliente.

OPcache è la seconda leva, ed è quella più dimenticata nelle configurazioni di default. La documentazione ufficiale dell'estensione OPcache su php.net descrive un meccanismo di cache del bytecode compilato che, quando configurato correttamente, elimina completamente la fase di parsing del codice PHP a ogni singola richiesta. I tre parametri critici da impostare in /etc/php/8.2/fpm/conf.d/10-opcache.ini sono opcache.memory_consumption (target 256 MB su una codebase Laravel di media grandezza), opcache.max_accelerated_files (target almeno 20.000 file per Laravel più Composer più vendor), e soprattutto opcache.validate_timestamps=0 in produzione (disabilita il check di freschezza dei file PHP, riducendo a zero le syscall stat() su disco per ogni richiesta). Quest'ultimo parametro richiede che ogni deploy esegua un systemctl reload php8.2-fpm oppure un cache flush esplicito via CLI, ma è perfettamente compatibile con un workflow di deploy moderno e zero-downtime. Sul cliente veronese, OPcache era abilitato ma validate_timestamps era rimasto al valore di default 1: ogni richiesta faceva centinaia di syscall stat() inutili sul filesystem. Cambiare questo singolo parametro e riavviare FPM ha tagliato altri 35 millisecondi di latenza di base su tutte le pagine dell'applicazione, misurati sul monitoring post-intervento.

JIT è la terza leva, e qui sono deliberatamente più cauto nelle raccomandazioni. JIT in PHP 8.x può portare benefici significativi sui carichi CPU-bound (calcoli matematici, parsing massiccio di grandi stringhe, elaborazione di immagini via GD) ma per un'applicazione web tradizionale dominata da I/O verso database, cache e servizi esterni, l'incremento misurato è marginale (5-15%) e in alcune configurazioni specifiche può addirittura regredire le performance aggiungendo overhead di compilazione. Lo abilito solo dopo aver misurato un caso d'uso CPU-bound reale nel profiling iniziale. Per un'applicazione PMI tipica, JIT è una leva di ultima istanza, non un punto di partenza. C'è un caso in cui invece va valutato seriamente, ed è quando si sta pensando di passare da FPM a un runtime persistente come Octane: l'ho discusso in dettaglio nel mio articolo dedicato a Laravel Octane nel 2026 e quando ha senso adottarlo in una PMI con Swoole o RoadRunner, che è il complemento naturale di questo articolo per chi sta valutando il passaggio dal modello FPM a un modello asincrono persistente.

Caching applicativo: Redis, edge cache, e quando Varnish non serve

Una volta che database e runtime sono entrambi ottimizzati, la leva successiva è il caching applicativo. Il principio guida è elementare: non fare due volte un calcolo o una query se il risultato non cambia fra le due richieste consecutive. La domanda critica diventa: per quanto tempo il risultato è valido prima che diventi stantio? Per ogni dato dell'applicazione esiste una "TTL utile" - pochi secondi per dati che cambiano di continuo (stock disponibili, contatori in tempo reale), ore o giorni per dati semi-statici (cataloghi, listini, testi di pagine). Mappare l'applicazione su questa griglia di TTL è il primo lavoro di un caching strategico.

Lo strumento principale che uso è Redis, sia per cache di oggetti applicativi, sia per session storage, sia per code asincrone (lo vedremo nel paragrafo finale). Il pattern Laravel più semplice e più efficace è Cache::remember, che incapsula in una sola chiamata la logica "leggi dalla cache, e se manca calcola al volo e salva":

$listini = Cache::remember(
    "listini.cliente.{$cliente->id}",
    now()->addMinutes(15),
    fn () => Listino::query()
        ->where('cliente_id', $cliente->id)
        ->with(['articoli', 'sconti'])
        ->get()
);

Sul cliente veronese, il modulo che caricava i listini personalizzati per ogni cliente B2B faceva 3 query e 280 ms di lavoro totale ad ogni page load, anche per pagine che lo stesso cliente visualizzava cento volte al giorno con esattamente gli stessi dati di listino. Avvolgere quella logica in un Cache::remember con TTL di 15 minuti ha eliminato il 95% di quelle query (i listini cambiano raramente durante la giornata lavorativa), portando il tempo medio del modulo da 280 ms a circa 4 ms quando in cache e a 280 ms solo al primo accesso del quarto d'ora. La differenza è notte e giorno per l'esperienza utente, e il costo in RAM per Redis è trascurabile - qualche centinaio di megabyte per migliaia di chiavi serializzate.

Varnish è un'altra arma del tuning performance, ma molto più chirurgica e con meno casi d'uso di quanto la letteratura suggerisca. È un reverse proxy HTTP che memorizza intere pagine HTML davanti al web server. Quando funziona bene è straordinario - risposte in 1-2 ms direttamente dalla memoria di Varnish, senza nemmeno toccare PHP o il filesystem. Ma ha un vincolo strutturale grosso: funziona davvero solo per pagine anonime o per pagine che hanno una segmentazione di cache molto semplice basata su pochi header. Su un e-commerce B2B con utenti loggati, sessioni personalizzate, prezzi diversi per cliente, cataloghi filtrati per ruolo e carrelli persistenti, Varnish è significativamente meno utile di quanto sembri dai blog di tutorial generalisti. La documentazione ufficiale di Varnish Cache chiarisce molto bene questi vincoli nella sezione sulle cookie-based exclusions. Sul cliente veronese non l'ho nemmeno introdotto: il rapporto fra complessità di configurazione VCL e benefici reali attesi era sfavorevole, e la combinazione Redis + OPcache + indici MySQL + fix N+1 era già sufficiente a portare le performance dove servivano.

Code asincrone: spostare il lavoro pesante fuori dal request lifecycle

L'ultima leva, e quella più sottovalutata dai team che non hanno mai gestito un'applicazione sotto carico reale, è l'asincronia. Il principio è cristallino: qualunque operazione che richiede più di 200 millisecondi e che non deve essere completata prima di rispondere all'utente, va spostata in una coda asincrona. Esempi tipici che trovo in ogni progetto: invio di email di conferma ordine, generazione di PDF di fattura o bolla di accompagnamento, sincronizzazione con ERP esterno o magazzino, ricalcolo di statistiche di vendita, invio di notifiche push ai rider, esportazione di file CSV per il backoffice, webhook verso servizi terzi. Tutte queste operazioni, se eseguite in modo sincrono nel request lifecycle, allungano la risposta dell'utente di centinaia o migliaia di millisecondi senza alcun beneficio per lui - che vorrebbe soltanto vedere "ordine confermato" il più rapidamente possibile per passare alla cosa successiva.

Lo stack che uso su Laravel è la combinazione standard di Laravel Queues con Redis come driver, ben documentata nella guida ufficiale di Laravel sulle code, gestita in produzione con Horizon per la supervisione dei worker e la dashboard di monitoraggio real-time. Il pattern è semplice: creo una classe Job PHP per ogni operazione asincrona, la dispatcho con Job::dispatch($params) dal controller, e Horizon gestisce un pool di worker che la processano in background. L'operazione che prima bloccava la response per 800 ms ora ritorna in 5 ms (il tempo di scrivere il job serializzato in Redis), e il lavoro vero viene fatto da un worker dedicato senza alcun impatto sulla user experience in fase di checkout. Sul cliente veronese, dopo aver spostato in coda l'invio di tre email transazionali e la generazione del PDF di conferma ordine tramite dompdf, il tempo medio del checkout è sceso da 320 ms a 280 ms - gli ultimi 40 millisecondi recuperati. Per i team che vogliono andare oltre nel pattern asincrono e renderlo testabile in modo affidabile, ho descritto le tecniche aggiornate di modernizzazione dei job in coda Laravel con Queue::fake e withFakeQueueInteractions su Laravel 12 in un articolo dedicato al testing delle queue.

Il risultato finale sul cliente veronese è stato raggiunto in otto ore di lavoro effettivo distribuite su due giornate: profiling iniziale con Blackfire e slow query log (1 ora), fix del pattern N+1 con eager loading (1 ora), aggiunta dell'indice composito su shipping_quotes più finestra di deploy in staging e produzione (30 minuti), tuning di innodb_buffer_pool_size e OPcache (1 ora), introduzione di Redis come layer di cache sui listini personalizzati (2 ore), conversione delle email e del PDF in job asincroni gestiti da Horizon (2 ore), monitoring post-intervento con Grafana e confronto delle metriche pre/post (30 minuti). Tempo del checkout passato da 4.200 ms di mediana a 280 ms di mediana, con il 99,2% delle richieste sotto il secondo. Costo del consulente: una giornata e mezza fatturata a tariffa standard. Costo dell'upgrade hardware evitato: 4.500 euro l'anno di OPEX ricorrenti. Customer churn evitato durante il picco stagionale: non quantificabile in cifra precisa, ma il proprietario mi ha riferito un mese dopo l'intervento che gli ordini completati erano cresciuti del 18% rispetto allo stesso periodo dell'anno precedente, esclusivamente perché il checkout finalmente "non si rompeva più sotto carico". Tutte queste ottimizzazioni hanno anche un effetto secondario importante: riducono strutturalmente il carico sui server attuali e quindi posticipano il momento reale in cui un upgrade hardware diventa effettivamente necessario, cosa che si lega direttamente al piano di consolidamento del debito tecnico nei 90 giorni post-subentro che ho descritto in un altro articolo.

Se gestisci una PMI con un'applicazione PHP critica che inizia a mostrare segni di rallentamento sotto carico, e il tuo provider o il tuo developer interno ti sta proponendo un upgrade hardware come prima soluzione al problema, fermati e chiedi un secondo parere prima di firmare. Nel 90% dei casi che ho visto in dieci anni di consulenza, l'upgrade hardware era prematuro e mascherava un problema di profiling, codice o database che si poteva risolvere con poche ore di lavoro mirato a una frazione del costo. Scopri come lavoro con i clienti sul tema delle performance di applicazioni PHP critiche in produzione: in dieci anni di interventi di tuning su Hetzner, OVH, Digital Ocean e Aruba, il pattern si ripete quasi sempre identico - il vero collo di bottiglia non è quello che il cliente pensava quando mi ha chiamato. Se invece sei già nel pieno di un problema di lentezza in produzione e ti serve un consulente che applichi sul tuo progetto il metodo di profiling e remediation che ho appena descritto, contattami per una consulenza: in due giornate tipiche di lavoro identifico i tre principali colli di bottiglia, applico le ottimizzazioni più impattanti con deploy controllato, e ti consegno un report comparativo con le metriche prima e dopo, più una roadmap di priorità per le ottimizzazioni successive da fare nei mesi seguenti.

Ultima modifica: