Un primario per le scritture, decine di repliche in lettura, cache davanti al DB e pooling aggressivo: come rendere sostenibile un workload soprattutto read-heavy senza shardare tutto.
Quando un’app cresce rapidamente, quasi sempre succede la stessa cosa: la prima versione “naïf” dell’architettura (un database che fa tutto) funziona benissimo… finché non funziona più. Il punto interessante è che, per workload prevalentemente in lettura, PostgreSQL può spingersi molto oltre ciò che molti team considerano “ragionevole”, a patto di adottare una serie di accorgimenti semplici e coerenti.
Qui sotto metto in ordine le leve più efficaci, con un filo conduttore chiaro: proteggere il nodo primario (writer) da tutto ciò che non è strettamente necessario, e rendere la lettura scalabile orizzontalmente.
1) Il punto di partenza che prima o poi scoppia
Il setup più comune all’inizio è:
- un solo nodo PostgreSQL
- l’app legge e scrive lì
- l’aumento di traffico porta a:
- CPU alta
- tante connessioni simultanee
- latenza che sale
- errori intermittenti (timeout, 5xx)
Questo non è “sbagliato”: è la scelta più veloce per partire. Il problema è che il database diventa contemporaneamente:
- storage
- read engine
- write engine
- punto di sincronizzazione
- collo di bottiglia
La strategia vincente è disaccoppiare i ruoli senza introdurre complessità prematura.
2) Cache davanti al DB: la prima riduzione brutale di carico
Se il traffico è prevalentemente read-heavy (tipico di prodotti con molte consultazioni e relativamente poche scritture), la mossa più impattante è introdurre una cache applicativa (es. Redis) tra app e database.
Perché funziona così bene
- intercetta richieste ripetute e “calde”
- riduce i roundtrip verso Postgres
- smorza gli spike
Il rischio da progettare: cache miss storm
La cache non è solo “mettere Redis e via”. Quando un keyspace scade o una feature nuova genera pattern imprevisti, puoi avere ondate di miss che scaricano improvvisamente tutto su Postgres, saturandolo.
Buone pratiche (minime) per evitare l’effetto valanga:
- TTL con jitter (scadenze non allineate)
- stale-while-revalidate dove possibile
- protezioni lato app (rate limit, circuit breaker su query costose)
3) Read replica in massa: scalare la lettura senza toccare il writer
Una volta ridotta la pressione con la cache, il passo naturale è separare letture e scritture:
- 1 primary: scrive (e legge il minimo indispensabile)
- N read replica: servono letture
Con molte repliche distribuite geograficamente puoi ottenere:
- scalabilità orizzontale sulle read
- latenza più bassa per utenti lontani
- un primario più stabile (quindi più affidabile sulle write)
Serve un read router (o logica equivalente)
A livello applicativo devi instradare:
- query read-only verso repliche
- scritture (e letture strong-consistency) verso il primario
Il punto chiave è accettare una verità: con repliche avrai replication lag. Quindi definisci chiaramente:
- quali endpoint possono tollerare dati leggermente “vecchi”
- quali richiedono read-after-write (e quindi devono andare al primary)
4) Connection pooling: non è un’ottimizzazione, è igiene
Uno degli errori più costosi è lasciare che ogni richiesta (o peggio ogni utente) apra connessioni “a piacere” verso Postgres.
Sintomi tipici:
- esaurisci
max_connections - accumuli connessioni idle
- “connection storm” durante picchi o deploy
La soluzione standard in ecosistemi Postgres è un pooler come PgBouncer.
Perché cambia davvero la partita
- riusa un numero limitato di connessioni verso il DB
- riduce latenza di setup
- stabilizza il sistema durante i picchi
Una nota pratica: molti deployment moderni mettono un PgBouncer per istanza/replica (o per cluster), come proxy/pool davanti al database.
5) Query optimization: l’ORM non ti salva dalle join “tossiche”
Quando il carico cresce, spesso non sono “tutte le query” a essere un problema: sono poche query costose che, in certi momenti, mangiano CPU e degradano tutto.
Un pattern comune è l’esplosione di query generate da ORM con:
- join multiple
- tabelle grandi
- filtri non indicizzati
Linee guida pragmatiche
- evita join complesse “a cascata” quando non sono necessarie
- se la join è inevitabile, valuta di:
- spezzare la query in più query semplici
- spostare parte dell’aggregazione nel layer applicativo
Sì, “fare join nell’app” non è sempre elegante. Ma in sistemi ad alto traffico può essere una scelta razionale per togliere CPU al database e controllare meglio i costi.
6) Workload isolation: risolvere il problema del noisy neighbor
Quando più funzionalità condividono lo stesso Postgres, basta una feature nuova con query inefficienti per rallentare tutto (anche le parti critiche).
La soluzione è isolare i workload:
- separare per priorità (low/medium/high)
- instradare su istanze/repliche dedicate
- proteggere il traffico business-critical
Questo è particolarmente utile quando:
- lanci feature sperimentali
- hai campagne/effetti virali
- non vuoi che una moda del momento impatti l’API core
7) Load shedding: scegliere cosa far fallire (prima che fallisca tutto)
Quando il sistema è sotto stress estremo, l’obiettivo non è “tenere tutto su” ma:
- mantenere disponibili le operazioni essenziali
- degradare in modo controllato il resto
Il load shedding introduce regole per rifiutare o rimandare richieste non critiche quando:
- CPU/connessioni/queue superano soglie
- il replication lag aumenta
- la cache è in miss storm
È una di quelle misure che non “ottimizza” in condizioni normali, ma salva la disponibilità quando conta.
8) Sharding: non è vietato, ma non è sempre il primo passo
La tentazione, quando il DB soffre, è dire: “Shardiamo Postgres”.
Il problema vero dello sharding su un’app esistente è che spesso richiede:
- cambiare tanti endpoint
- ripensare query e chiavi di partizionamento
- gestire transazioni cross-shard
- mesi (o anni) di migrazione e rischio operativo
Se il carico è principalmente in lettura, puoi ottenere moltissimo con:
- cache + read replica + pooling + ottimizzazione query
E le scritture?
Se le write crescono e il primario diventa il limite, una strategia intermedia è spostare alcune scritture su uno storage separato e shardato (eventi, log, telemetry, dati “grossi” che non richiedono le stesse garanzie transazionali del core). Così:
- il primario rimane “pulito” per il dato critico
- scarichi write throughput su un sistema più adatto
Sintesi operativa (copiabile)
Se oggi hai un prodotto che sta crescendo e Postgres è sotto pressione, una roadmap concreta per un workload read-heavy è:
- Cache (con protezioni anti miss storm)
- Read replica + routing delle query
- PgBouncer (o pooling equivalente) davanti al DB
- Ottimizzazione query guidata da osservabilità (top query per CPU/latency)
- Workload isolation per priorità
- Load shedding per proteggere il core
- Solo dopo, se serve davvero: sharding (o split mirato delle scritture)
La lezione più utile è che “semplice” non significa “banale”: un’architettura lineare, applicata con disciplina (routing, pooling, isolamenti, guardrail), può portare PostgreSQL molto lontano senza trasformare la tua base dati in un progetto di migrazione infinito.
Articolo originale: https://frontendfacile.it/blog/postgresql-a-scala-planetaria-l-architettura-semplice-ma-disciplinata-per-regger
Top comments (0)