L’architettura single-server funziona. Funziona bene. Funziona a lungo, spesso più a lungo di quanto il team che ci costruisce sopra si aspetti, e questa è la tesi centrale delle prime sei lezioni di questo corso. Una macchina, un Postgres, un processo applicativo, un nginx davanti, e puoi servire un business vero con utenti veri per anni.
E poi un giorno non funziona più. La domanda interessante non è “che aspetto ha il fallimento”. Gli outage sono rumorosi e ovvi. La domanda interessante è: quali sono i segnali precoci, i sintomi silenziosi che si presentano mesi prima che la pagina cada alle tre di notte, e cosa dovresti fare per ognuno di essi?
È di questo che parla questa lezione. Sei sintomi. Per ciascuno, cosa cercare, cosa sta davvero succedendo sotto al cofano, e una riga di anteprima sulla soluzione che il resto del corso dedicherà interi capitoli a spiegare.
Sintomo 1: contention sul database
Inizi a vedere query lente che, prese in isolamento, non dovrebbero esserlo. Il piano della query è a posto. L’indice viene usato. Su un database di test tranquillo la query torna in otto millisecondi. In produzione, la stessa query a volte impiega quattro secondi, a volte trenta. Iniziano a comparire errori del connection pool nei log. Compaiono deadlock sotto un normale carico di un martedì pomeriggio.
Il primo istinto è “il database è lento, ci serve una macchina più grossa”. A volte è la chiamata giusta. Più spesso, il database stesso sta bene; hai una macchina, un database, e un numero crescente di richieste concorrenti che si combattono le stesse risorse. Due richieste vogliono aggiornare la stessa riga. Venti richieste vogliono leggere una tabella su cui una transazione di lunga durata sta tenendo un lock. Il tuo max_connections è 200 e la dimensione del pool dell’app server, moltiplicata per le repliche, lo supera.
Cose da cercare:
- La latenza al 95esimo percentile sulle query semplici che cresce mentre la mediana resta piatta. Quella è la forma della contention, non della lentezza.
- Lock wait time come percentuale del tempo totale della query. Se è sopra il dieci percento, hai un problema di contention, non un problema di query.
- Errori del connection pool che dicono “timed out waiting for a connection” piuttosto che “could not connect.”
- Deadlock che non esistevano un trimestre fa, su tabelle che non hanno cambiato schema.
La mossa successiva è introdurre un connection pooler davanti al database (PgBouncer, RDS Proxy, ProxySQL), iniziare a separare il traffico di lettura dal traffico di scrittura con una read replica, e spingere le transazioni di lunga durata fuori dal request path. Connection pooling e read replica avranno un capitolo intero più avanti nel corso; la conclusione è che probabilmente hai trattato “il database” come una singola risorsa quando in realtà sono tre: gli slot di connessione, la capacità di lettura, e la capacità di scrittura. Ognuna può fallire indipendentemente.
Sintomo 2: un outage single-server smette di essere un piccolo problema
Quando avevi dieci utenti, l’ora di maintenance window al mese andava bene. Quando ne hai diecimila, la stessa ora ti costa soldi veri, rimborsi veri, e conversazioni vere con i dirigenti.
Lo spostamento qui non è tecnico. L’architettura è la stessa. Quello che è cambiato è il costo dell’outage. Iniziano a contare due numeri che prima non contavano:
- Recovery time objective (RTO): per quanto puoi essere down prima che il business sia in guai veri.
- Recovery point objective (RPO): quanti dati puoi permetterti di perdere se accade il peggio.
Quando l’applicazione era piccola, l’RTO era “ci arriviamo quando ci arriviamo” e l’RPO era “l’ultimo backup notturno”. Quando l’applicazione è di taglia media, l’RTO si misura in minuti e l’RPO in secondi. L’architettura single-server non può rispettare questi numeri, perché c’è esattamente una macchina e se quella è down, il sistema è down.
Cose da cercare:
- I tuoi postmortem degli incidenti iniziano a menzionare l’impatto sui ricavi in dollari, non in scuse.
- Il customer support inizia ad avere un playbook per il “deploy day” perché ricevono ticket ogni volta che spedite.
- Qualcuno, probabilmente in finanza, chiede “qual è il nostro uptime SLA” e tu non hai una risposta pulita.
La mossa successiva è smettere di avere una sola macchina. Può significare un hot standby database, un app tier active-active dietro un load balancer, o, alla fine, un setup multi-region. Il trade-off è che nel momento in cui hai più di una macchina, hai un sistema distribuito, e i sistemi distribuiti hanno i loro modi di fallire. È di questo che parla il Modulo 2.
Sintomo 3: i deploy causano outage
Il restart da trenta secondi che nessuno notava quando l’app era piccola adesso compare nella inbox del support. “Abbiamo provato a fare check-out alle 15:14 e non è andata”. Controlli i log. Le 15:14 erano quando hai rilasciato la nuova build. Il restart ha impiegato 28 secondi, durante i quali il server ha restituito 502 e un utente con i contanti in mano non te li poteva dare.
Questo è il momento in cui molti team decidono che hanno bisogno di deploy zero-downtime, e hanno ragione. La soluzione è ben capita: esegui più di una istanza dietro un load balancer, togli le istanze dalla rotation una alla volta, fai drain delle richieste in volo, avvia la nuova versione, fai health-check, rimettila in rotation. Il livello di orchestration, qualunque esso sia, gestisce la coreografia.
Cose da cercare:
- Picchi di errori legati ai deploy nelle dashboard, anche piccoli.
- Ingegneri che preferiscono spedire alle 2 del mattino “per stare al sicuro”.
- Una frequenza di deploy che è calata perché spedire fa paura.
L’ultimo è il segnale vero. Se il team spedisce meno spesso perché i deploy sono rischiosi, stai pagando una tassa su ogni feature che spedisci da adesso fino a quando non lo sistemi. La soluzione è, di nuovo, smettere di avere una sola macchina, ma in particolare smettere di avere un solo processo applicativo. Dietro lo stesso database, esegui due istanze dell’app. Poi deployale una alla volta. Il blast radius di un deploy passa da “tutti” a “le poche richieste in volo sull’istanza che si sta riavviando in questo momento”.
Sintomo 4: i backup superano la window
Hai impostato un dump logico notturno. pg_dump alle 2 del mattino, push su S3, fatto. Per due anni ha impiegato circa trenta minuti e ha usato una frazione di disco e rete. Un giorno qualcuno nota che il backup non ha finito prima che partisse il batch job mattutino. Guardi i log. Il dump adesso impiega quattordici ore. Si sovrappone con se stesso la notte successiva. Presto, non sarai in grado di fare il backup giornaliero senza che due backup girino in contemporanea, combattendosi l’IO.
Questo è l’equivalente del problema dei deploy, ma per il volume dei dati. La tecnica che funzionava a piccola scala (un unico dump logico gigante) non funziona a larga scala, e la soluzione è strutturale, non tattica. Passi a backup fisici (pg_basebackup, WAL archiving, o qualunque sia l’equivalente del tuo engine). Fai un backup completo una volta a settimana, backup incrementali ogni giorno, e WAL streaming continuo per il point-in-time recovery. Il backup completo potrebbe ancora impiegare quattordici ore, ma gira su una replica e non blocca la produzione.
Cose da cercare:
- Tempo wall-clock del backup come percentuale di 24 ore. Tutto ciò che supera il 30 percento è un avvertimento. Tutto ciò che supera il 50 percento è un’emergenza.
- Picchi di IO legati al backup che impattano la latenza delle query in produzione.
- “In realtà non abbiamo testato un restore negli ultimi sei mesi” detto da chiunque, in qualunque momento. (È un’emergenza a sé, separata dal problema della dimensione.)
Un backup che non puoi ripristinare nel tuo RTO non è un backup. Un backup che impiega più tempo della window tra un backup e l’altro non è un backup nemmeno lui. Entrambi i problemi compaiono alla scala e forzano lo stesso shift architetturale: i backup devono girare da qualche parte che non sia il primary, e devono essere incrementali.
Sintomo 5: un endpoint lento blocca tutto il resto
Il sales operations gira un report pesante su /admin/sales una volta al giorno. Scansiona un anno di order history, fa join contro prodotti e clienti, e ritorna un CSV. Prima impiegava dodici secondi, fastidioso ma accettabile. Adesso impiega tre minuti, e durante quei tre minuti il tuo endpoint /api/orders, che cento clienti al minuto colpiscono per fare check-out, diventa lento. A volte va in timeout.
Quello che sta succedendo qui è lineare. L’architettura single-server ha un processo, un connection pool, un set di connessioni al database, e un budget di CPU. La query di reporting li sta usando tutti. Le richieste customer-facing si accodano dietro. La soluzione è separare i workload: un pool di processi diverso per il lavoro di background e di reporting, una replica del database diversa per le query analitiche read-heavy, un ciclo di deploy diverso per il codice di reporting.
Cose da cercare:
- Latenza sugli endpoint customer-facing che correla con l’orario in cui un job pesante noto sta girando.
- Un connection pool dimensionato per il carico normale che si esaurisce ogni volta che gira il report.
- Grafici di CPU del database che mostrano un pattern sostenuto a un-processo-bloccato invece di un pattern rumoroso multi-processo.
La mossa successiva è introdurre un worker pool: un set separato di processi, idealmente un set separato di macchine, che gestiscono il lavoro di background, gli scheduled job, e le analytics pesanti. Le letture possono andare su una replica. Le scritture continuano ad andare sul primary. Il tier customer-facing resta piccolo e prevedibile.
Sintomo 6: la migrazione di schema non sta nella window
Hai una tabella da 200 milioni di righe. Devi aggiungere una colonna. Devi fare il backfill. L’approccio ingenuo (un singolo ALTER TABLE seguito da una UPDATE) lockerà la tabella o la riscriverà per ore. Hai una maintenance window del sabato sera di due ore. La migrazione impiegherà sei ore. Non puoi farla girare.
Questo è l’equivalente per lo schema dei sintomi 4 e 5. La tecnica che funzionava quando la tabella più grossa era da un milione di righe non funziona a duecento milioni. La soluzione è fare migrazioni online, a pezzi, con l’applicazione in esecuzione che continua a servire traffico. Aggiungi la colonna nullable. Fai il backfill in batch da diecimila righe con delay tra i batch. Aggiungi il vincolo NOT NULL alla fine come operazione separata e veloce. Usa feature flag per fare swap delle letture dalla forma vecchia alla forma nuova.
Cose da cercare:
- Un piano di migrazione che contiene le parole “downtime” e “weekend”.
- Migrazioni rimandate trimestre dopo trimestre perché non ci stanno.
- Un team che ha paura di evolvere lo schema, che negli anni diventa un team che ha paura di spedire prodotto.
La mossa successiva è adottare pattern e tooling per le migrazioni online. pg_repack e pt-online-schema-change esistono per una ragione. Il saggio di Stripe “Online Migrations”, che citeremo più avanti in questo corso, mette nero su bianco il playbook. Lo shift architetturale è da “lo schema è una cosa che cambiamo nelle maintenance window” a “lo schema evolve in continuazione e l’applicazione deve tollerare entrambe le forme per un po’”.
Le fasi della crescita
Ognuno dei sei sintomi sopra è un gradino sulla stessa scala. La scala assomiglia grosso modo a questa:
flowchart TB
A[Single server<br/>app + DB on one box<br/>up to ~50 req/s, 50 GB] --> B[Read replicas + pooler<br/>splits read from write<br/>up to ~500 req/s, 500 GB]
B --> C[Separate worker pool<br/>background work off the request path<br/>up to ~2k req/s, 2 TB]
C --> D[Service split<br/>independently deployable services<br/>up to ~10k req/s, 10 TB]
D --> E[Distributed data layer<br/>sharding, queues, caches<br/>up to ~50k req/s, 100 TB]
E --> F[Multi-region<br/>geographic redundancy<br/>global users, multi-PB]
I numeri su ogni gradino sono volutamente vaghi. Dipendono dal workload (read-heavy contro write-heavy, OLTP contro OLAP, costo medio per richiesta, rapporto picco-medio). Quel che non è vago è l’ordine. Quasi ogni team che cresce oltre il single server attraversa questi gradini in questa sequenza, all’incirca. Saltare un gradino è possibile ma costoso: un team che salta da un single server a un’architettura microservices-on-Kubernetes sta pagando per le fasi 2, 3 e 4 di complessità operativa senza avere il carico che la giustifichi. Vedremo nella prossima lezione, con tre case study reali, cosa succede ai team che fanno così.
Diagramma da creare: una mappa alternativa “sintomo-a-fase” che mette ognuno dei sei sintomi accanto alla fase della scala che lo affronta. Il sintomo 1 mappa alla fase B, il sintomo 5 alla fase C, il sintomo 3 alla fase B (e parzialmente D), e così via. Utile come riferimento di una pagina.
Di cosa parla il resto del corso
Se hai letto i sei sintomi e hai riconosciuto almeno uno come qualcosa che stai affrontando proprio adesso, il resto del corso è per te. Il Modulo 2 copre i fondamentali dei sistemi distribuiti. Il Modulo 3 copre lo scaling del data layer: repliche, pooling, sharding. Il Modulo 4 copre i worker tier e le code. Il Modulo 5 copre la decomposizione dei servizi. Il Modulo 6 copre le operations: observability, deploy, evoluzione dello schema su scala.
La struttura è deliberata. Non partiamo dai microservizi perché la maggior parte dei team non ne ha bisogno. Partiamo dai sintomi, perché i sintomi ti dicono qual è la prossima mossa da fare. Un’architettura non è un set di componenti alla moda; è un set di decisioni che hai preso, in ordine, in risposta a problemi specifici. Se non hai ancora il problema, non hai ancora bisogno della decisione.
La prossima lezione, una lezione di case study, parla di tre aziende che hanno resistito alla tentazione di over-engineerare più a lungo di quanto chiunque si aspettasse, e di cosa ci hanno guadagnato.