
Marzo 2025. La dashboard Grafana del nostro stack di hosting editoriale segna un picco prolungato: 200 query/s sostenute sul cluster MariaDB, hit rate Redis al 78%, latenza p95 sopra i 600ms su un sito che a fine 2023 stava sotto i 200ms con metà del traffico. La cache di WordPress stava ancora facendo il suo lavoro, ma non era più sufficiente per reggere il volume aggregato di un network che, su 40+ testate, aveva appena passato i 45 milioni di pageview/mese.
Questa è la cronaca tecnica di come abbiamo stratificato cinque layer di cache, dove ognuno copre un fallimento del successivo, e perché la frase aggiungi più cache è la risposta sbagliata al problema giusto. Vedi anche gli altri post di questa rubrica: Production.
Il punto di partenza: object cache e basta
L’architettura che ci portavamo dietro dal 2022 era quella standard di una buona installazione WordPress ad alto traffico: nginx con FastCGI, PHP-FPM con OPcache, MariaDB su replica MaxScale, Redis come object cache via il drop-in di Redis Object Cache. Niente di esotico. Aveva retto bene fino ai 15-20M di pageview/mese, con hit rate sull’object cache costantemente sopra il 90% e load average dei web node sotto il 2.
Quello che si è rotto tra fine 2024 e inizio 2025 non è stata una singola cosa: è stata la composizione fra crescita organica del network (più siti, più articoli vivi nelle prime 24h), aggiunta di blocchi dinamici condivisi cross-testata (sidebar most popular alimentata da Matomo, taxonomy widget cross-network) e un cambio di pattern di lettura: il traffico stava migrando dal classico referer Google + landing su articolo al Google Discover + sessioni multi-articolo. Più scroll, più richieste a /wp-json/, meno prevedibilità della cache key.
Object cache su Redis aiuta solo per il backend: ogni invocazione PHP guadagna in lettura DB. Ma se il TTFB dipende da PHP che gira ad ogni richiesta, e PHP gira anche solo per assemblare blocchi che cambiano una volta al giorno, hai già perso. La cache di WordPress, per come la usa il 90% delle installazioni, è cache della singola query, non della singola pagina.
Layer 1: full-page cache su Varnish per le anonymous
Il primo intervento è stato spostare le richieste anonime fuori da PHP. Varnish davanti a nginx, VCL custom che:
- bypassa la cache su qualunque richiesta autenticata (cookie
wordpress_logged_in_*) - bypassa su POST e su tutto il path
/wp-admin/e/wp-json/wp/v2/ - normalizza l’
Accept-Encodingper ridurre la frammentazione dell’oggetto - striscia i cookie analytics di marketing dalla cache key
Già qui il rapporto è cambiato: oltre il 90% del traffico editoriale è anonymous, e per quelle richieste non ci serve toccare PHP. TTFB sceso a 30-60ms su hit. Il problema, prevedibile: invalidare. Una pubblicazione editoriale aggiorna decine di articoli al giorno, modifica sidebar, cambia widget most popular. Stale content era inaccettabile per il caporedattore.
La soluzione è un purge selettivo via API: ogni save_post, transition_post_status, comment_post, update_option rilevante triggera un job in coda che invalida le URL coinvolte (l’articolo, la home, le category page, l’eventuale tag page, il feed RSS). Il job è idempotente e ha un dedupe a 30 secondi per non saturare Varnish con purge ridondanti durante un bulk update. Per i blocchi cross-network più costosi, abbiamo preferito stale-while-revalidate: il visitatore riceve la versione vecchia per max 60s, in background un job riscalda l’oggetto.
Layer 2: micro-cache nginx per i tail readers
Varnish copre bene i top articoli del giorno. Per il long-tail (articoli vecchi, archive, paginazione) abbiamo aggiunto fastcgi_cache nginx con TTL aggressivi: 10 minuti per le pagine archive, 1 ora per gli articoli più vecchi di 7 giorni. È una cache più piccola, su disco SSD locale del web node, che assorbe il rumore di scraper e bot e tiene alto il throughput senza dover andare a chiedere a Varnish o a PHP.
Configurazione minima:
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2
keys_zone=WORDPRESS:512m inactive=1h max_size=10g use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout updating http_500 http_502 http_503;
fastcgi_cache_lock on;
fastcgi_cache_valid 200 60m;
fastcgi_cache_valid 404 5m;
Il valore di fastcgi_cache_use_stale updating è importante: se la cache scade durante un picco, una sola richiesta (la prima) va a PHP, le altre ricevono la versione stale. Senza questo, in coda di renew si formano herd di richieste verso il backend.
Layer 3: CDN Cloudflare per gli static asset (e oltre)
Lo strato CDN sembra il più ovvio, ma è anche quello dove abbiamo tirato la coperta più in là. Cloudflare in front, con cache rule custom: tutto /wp-content/uploads/ e /wp-content/themes/*/assets/ con TTL di 30 giorni, immagini servite via Polish, JS/CSS minified e brotli a edge. Fin qui standard.
Il salto è stato spostare anche le HTML page dietro Cloudflare con Cache Reserve attivo, sui siti che lo permettevano (testate dove la home cambia con frequenza ma non al secondo). Cache TTL 5 minuti su HTML, purge programmatico via API Cloudflare quando cambia un articolo top. Il risultato: il 60-70% del traffico HTML viene servito a edge, senza neanche toccare il datacenter. La latenza geografica, su lettori in Polonia o Romania per i siti del network internazionale, è scesa da 250ms+ a 20-40ms.
L’edge case di cui non si parla mai: la cache di WordPress per gli admin loggati. Cloudflare e Varnish bypassano sui cookie autenticati, ma se hai un caporedattore loggato che pubblica e poi va a vedere il sito loggato, vede una cosa, l’utente anonimo ne vede un’altra per qualche minuto. Soluzione: dopo la pubblicazione, redirect lato editor verso un parametro ?cb=<timestamp> che bypassa entrambi i layer per quella sessione. Un trucco da sysadmin, non bello, ma efficace.
Layer 4: fragment cache per i blocchi cross-tenant
Su un network multi-testata, alcuni blocchi sono identici fra siti: il widget trending now alimentato da analytics aggregati, il latest from network che mostra titoli da altre testate del gruppo, le partnership banner. Cachearli per pagina è uno spreco. Cachearli sulla object cache serve a poco, perché il render finale è sempre dentro un template specifico del sito.
Soluzione: fragment cache come stringa HTML pre-renderizzata, con chiave globale (non tenant-specific) e TTL di 5-15 minuti a seconda del blocco. Il template del sito include il fragment via wp_cache_get('fragment:trending_now_v3'), e se manca lo rigenera con un job in coda. Il job che ricalcola il fragment è uno solo per tutta la rete, non uno per sito. Risparmio: da N renderizzazioni a una sola.
Layer 5: cache warming proattivo
L’ultimo layer è preventivo, non reattivo. Un cron ogni 5 minuti, alimentato dai segnali di trending interni (top articoli per pageview crescenti negli ultimi 30 minuti), riscalda Varnish e Cloudflare per gli URL che stanno per esplodere. Senza questo, su un articolo virale via Google Discover, il primo migliaio di visitatori prende cache miss e satura PHP-FPM. Con questo, la prima visita arriva e l’oggetto è già caldo.
Il warming riempie anche i fragment cache che potrebbero scadere durante la finestra di traffico. È una pipeline semplice: una query ClickHouse sui pageview real-time del nostro microservizio analytics su Matomo, un set di URL candidate, un curl con header che bypassa la cache di edge ma popola quella di origin.
La lezione: la cache non risolve la latenza del database
Tutto questo lavoro di stratificazione ha ridotto enormemente il carico sul backend. Ma a un certo punto della transizione abbiamo sbattuto contro un soffitto che la cache, da sola, non poteva superare: le query autenticate, le scritture editoriali, le operazioni di sync con AI Multisite. Quelle vanno tutte a database, e su un cluster MariaDB con 200 query/s sostenute la latenza p99 saliva.
La cache di WordPress, e tutta la stratificazione che le costruisci sopra, sposta il problema dal web tier al data tier. Non lo risolve. Per chiudere il cerchio abbiamo dovuto:
- introdurre read replica con MaxScale come router R/W splitting, con failover automatico (gli SQL
SELECTletti dalle replica, le scritture e leSELECT FOR UPDATEsul master) - ottimizzare query specifiche del wp-admin che facevano
SELECT *suwp_postscon join multipli (il classico problema di meta query non indicizzate) - denormalizzare alcune tabelle hot (i count di view per articolo non passavano più da
postmeta, ma da una tabella dedicata aggiornata in batch)
Il refactor del data tier è materia di un altro post. Quello che voglio chiudere qui è un’osservazione di metodo: la cache è fondamentale, ma è una toppa sulla latenza, non una cura. Se la cache sta nascondendo un problema strutturale, prima o poi quel problema esce. Su 45M di pageview esce in fretta.
Riepilogo: i 5 layer in produzione
┌─────────────────────────┐
┌────►│ Varnish full-page │ TTL 5-30 min, anonymous
│ └────────┬────────────────┘
[ Cloudflare CDN ]──────────┤ ▼
HTML reserve + assets │ ┌─────────────────────────┐
└────►│ nginx fastcgi_cache │ long-tail, TTL 1h
└────────┬────────────────┘
▼
┌─────────────────────────┐
│ PHP-FPM + WordPress │
│ Fragment cache (Redis) │ blocchi cross-tenant
│ Object cache (Redis) │ query / options
└────────┬────────────────┘
▼
┌─────────────────────────┐
│ MaxScale R/W splitting │
│ MariaDB primary + 2 RR │
└─────────────────────────┘
Tre numeri reali sul cluster del network editoriale, post-rollout completo (estate 2025):
- TTFB mediano sceso da 480ms a 95ms
- query/s sostenute al primary MariaDB scese da 200 a 45
- hit rate aggregato (Cloudflare + Varnish + nginx + Redis) sopra il 96%
Cosa mi sono portato a casa
Prima. Cache è un sostantivo plurale. Una sola cache, qualunque essa sia, è una soluzione parziale. Le installazioni WordPress che reggono volumi seri hanno tutte tre o quattro layer, ognuno con un TTL diverso e una key strategy diversa.
Seconda. L’invalidation è dove muoiono i progetti di cache. Costruire i layer è la parte facile. Tenere la cache in sync con la realtà editoriale, senza purge nucleari che fanno crollare il cache hit rate, è la parte difficile. Stale-while-revalidate, dedupe dei job di purge, fragment cache con chiave esplicita: questi sono i dettagli che fanno la differenza.
Terza. La cache non scala il database. Lo nasconde finché può. Quando non può più, devi avere già pronto il piano B sul data tier: replica, query optimization, denormalization. Se aspetti l’incidente, lo fai con le mani che tremano.
Dopo tre anni di iterazione su questa stratificazione, su un network di 40+ testate e 45M di visite/mese, il pattern è stabile. Ma ogni picco editoriale (un risultato sportivo, una notizia di cronaca, un trending di Discover) è ancora un piccolo stress test in produzione. Quello che sembrava cache di WordPress, oggi è un sistema distribuito con cinque attori. Ed è solo uno strato dell’architettura più ampia di AI Multisite.
