Non si diagnostica un server rallentato guardandolo
"Il sito è lento" non è una diagnosi, è un sintomo — e un sintomo che può nascere in una dozzina di punti diversi della catena: il database, il pool di connessioni, l'event loop di Node, una singola query non ottimizzata, la rete. Prima di toccare qualsiasi cosa, abbiamo instrumentato il sistema per vedere davvero cosa succedeva sotto carico, invece di indovinare: monitoraggio dell'event-loop delay, log automatico di ogni richiesta più lenta di un secondo, ed endpoint dedicato (/health/pressure) che espone in tempo reale p50/p99/max dell'event loop e la memoria del processo.
Solo con questi dati in mano abbiamo guardato il carico reale della macchina: circa il 20% in condizioni normali. Un server quattro volte più libero di quanto servisse — la prova più diretta che il collo di bottiglia non era l'hardware, ma qualcosa nel codice che lo usava male.
Tre cause distinte, non una sola
La prima: un connection pool del database sottodimensionato (massimo 5 connessioni), che sotto carico produceva una cascata di errori ConnectionAcquireTimeout — il sito sembrava giù, ma il database era semplicemente pieno di richieste in coda, in attesa di una connessione libera che non arrivava mai.
La seconda: diverse query che restituivano colonne di testo intere e non necessarie — descrizioni complete di articoli su endpoint che dovevano solo elencarli. Escludere quei campi ha portato l'endpoint dei menu da 1,3 secondi a 5 millisecondi, e la ricerca interna a 24ms. Non abbiamo aggiunto un limite diretto sulla query dei menu, nonostante sembrasse la correzione più ovvia: la struttura Sequelize di quella query, con relazioni annidate, avrebbe reso il limite fragile e rischioso da introdurre senza un ambiente di staging dedicato — meglio una cache di 60 secondi, che rende il conteggio delle righe ininfluente senza toccare SQL delicato.
La terza, la più seria: un vero e proprio cache stampede sulla homepage. Riproducendo deliberatamente un picco di 40 richieste simultanee — lo stesso ordine di grandezza di un vero momento di punta — la homepage rispondeva in 30-38 secondi con errori 500, event loop bloccato. Ogni singola richiesta ricalcolava da zero gli stessi identici dati invece di condividere un unico risultato in corso di elaborazione.
La correzione, non l'upgrade
Pool di connessioni portato da 5 a 25 — zero ConnectionAcquireTimeout da allora — e una cache a single-flight con stale-while-revalidate: la prima richiesta calcola il dato, tutte le altre richieste arrivate nel frattempo aspettano lo stesso risultato condiviso invece di ricalcolarlo ciascuna per conto proprio. Lo stesso identico test di carico, ripetuto dopo la correzione, ha dato 40 richieste su 40 rispondenti 200, con p99 di 92ms.
In parallelo, la mappa interattiva della homepage è stata spostata fuori dal rendering iniziale e caricata solo lato client, portando il peso dei dati caricati subito da 4MB a 28KB — con un deploy in fasi pensato apposta per non avere mai una finestra in cui la mappa potesse rompersi. Un'operazione di pulizia collegata ha liberato 132 GB di spazio disco sul server.
Una regola per decidere, non una sensazione
Risolte le cause immediate, restava una domanda più a lungo termine: quando servirà davvero dividere il carico su più processi? Invece di deciderlo a sensazione, abbiamo installato un campionatore che registra ogni minuto CPU ed event-loop pressure, e fissato in anticipo la regola per interpretarli: se la CPU satura stabilmente un core intero nei picchi e il p99 dell'event loop sale a centinaia di millisecondi, si passa al clustering; se invece tornano i timeout di connessione ma la CPU resta bassa, basta alzare ulteriormente il pool. Una decisione che si prende leggendo settimane di dati reali, non discutendone a caldo durante il prossimo rallentamento.
Perché non abbiamo comprato un server più grande
Un upgrade avrebbe fatto sparire il sintomo per un po', a un costo ricorrente più alto — senza risolvere nulla. Un pool sottodimensionato e una cache stampede non si accorgono di girare su un server più veloce: si ripresentano al primo vero picco di traffico, semplicemente un po' più tardi. Misurare prima di decidere ha significato risolvere le cause reali, con la strumentazione lasciata attiva per accorgersi subito se dovessero ripresentarsi in una forma nuova.
Risultati
- Carico server misurato al 20% prima di intervenire: confermato che il problema non era hardware
- Zero
ConnectionAcquireTimeoutdopo aver portato il pool da 5 a 25 connessioni - Stesso test di carico (40 richieste simultanee): da 30-38s con errori 500 a tutte 200 con p99 di 92ms
- Homepage: payload dati iniziale da 4MB a 28KB
- 132 GB di spazio disco liberati sul server
- Una regola dati-driven, già scritta, per decidere in futuro se e quando scalare orizzontalmente

