Arhitectura datelor și a sistemelor, de la zero Lecția 7 / 80

Când prima arhitectură nu mai e de ajuns

Simptomele care spun că aplicația pe un singur server și-a atins plafonul. Contenție pe baza de date, deploy-ul care provoacă outage, backup-ul zilnic care durează mai mult de o zi.

Arhitectura pe un singur server funcționează. Funcționează bine. Funcționează mult timp, adesea mai mult decât se așteaptă echipa care construiește deasupra ei, și asta e teza centrală a primelor șase lecții ale acestui curs. O cutie, un Postgres, un proces de aplicație, un nginx în față, și poți deservi o afacere reală cu utilizatori reali ani de zile.

Și apoi într-o zi nu mai funcționează. Întrebarea interesantă nu e „cum arată eșecul”. Outage-urile sunt zgomotoase și evidente. Întrebarea interesantă e: care sunt semnele timpurii, simptomele tăcute care apar cu luni înainte ca pagina să cadă la trei dimineața, și ce ar trebui să faci cu fiecare în parte?

Despre asta e lecția. Șase simptome. Pentru fiecare, la ce să te uiți, ce se întâmplă de fapt dedesubt și o previzualizare de un rând a soluției pe care restul cursului o va explica pe parcursul mai multor capitole.

Simptom 1: contenție pe baza de date

Începi să vezi query-uri lente care, privite izolat, nu ar trebui să fie lente. Planul de query e ok. Indexul e folosit. Pe o bază de date de test liniștită query-ul se întoarce în opt milisecunde. În producție, același query durează uneori patru secunde, alteori treizeci. Erorile de connection pool încep să apară în loguri. Deadlock-uri apar la o încărcare normală de marți după-amiază.

Primul instinct e „baza de date e lentă, ne trebuie o cutie mai mare”. Uneori e adevărat. Mai des, baza de date în sine e ok; ai o singură mașină, o singură bază de date și un număr tot mai mare de cereri concurente care se luptă pentru aceleași resurse. Două cereri vor să actualizeze același rând. Douăzeci de cereri vor să citească un tabel pe care o tranzacție lungă ține un lock. max_connections e 200, iar dimensiunea pool-ului tău de pe app server, înmulțită cu replicile, depășește pragul.

La ce să te uiți:

  • Latența la percentila 95 pe query-uri simple urcă în timp ce mediana rămâne plată. Asta e forma contenției, nu a lentorii.
  • Timpul de așteptare pentru lock-uri ca procent din timpul total al query-ului. Dacă e peste zece la sută, ai o problemă de contenție, nu o problemă de query.
  • Erori de connection pool care spun „timed out waiting for a connection” în loc de „could not connect”.
  • Deadlock-uri care nu existau cu un trimestru în urmă, pe tabele a căror schemă nu s-a schimbat.

Următoarea mișcare e să introduci un connection pooler în fața bazei de date (PgBouncer, RDS Proxy, ProxySQL), să începi să separi traficul de citire de cel de scriere cu o read replica și să muți tranzacțiile lungi în afara căii de cerere. Connection pooling și read replicas primesc un capitol întreg mai târziu în curs; concluzia e că probabil ai tratat „baza de date” ca pe o singură resursă când de fapt sunt trei: sloturile de conexiune, capacitatea de citire și capacitatea de scriere. Fiecare poate ceda independent.

Simptom 2: un outage pe singurul server încetează să fie o problemă mică

Când aveai zece utilizatori, fereastra de mentenanță de o oră pe lună era ok. Când ai zece mii de utilizatori, aceeași oră te costă bani reali, refunduri reale și conversații reale cu directorii.

Schimbarea aici nu e tehnică. Arhitectura e aceeași. Ce s-a schimbat e costul outage-ului. Două numere încep să conteze, lucruri care nu contau înainte:

  • Recovery time objective (RTO): cât poți fi jos înainte ca afacerea să intre în probleme reale.
  • Recovery point objective (RPO): câte date îți poți permite să pierzi dacă se întâmplă ce e mai rău.

Când aplicația era mică, RTO-ul era „ne ocupăm când ne ocupăm” și RPO-ul era „ultimul backup de noapte”. Când aplicația e de mărime medie, RTO-ul se măsoară în minute și RPO-ul în secunde. Arhitectura pe un singur server nu poate atinge acele numere, pentru că există exact o mașină și dacă e jos, sistemul e jos.

La ce să te uiți:

  • Postmortem-urile de incidente încep să menționeze impactul asupra veniturilor în dolari, nu în scuze.
  • Customer support începe să aibă un playbook pentru „ziua de deploy” pentru că primește tichete de fiecare dată când livrezi.
  • Cineva, probabil din finance, întreabă „care e SLA-ul nostru de uptime” și nu ai un răspuns clar.

Următoarea mișcare e să nu mai ai o singură mașină. Asta poate însemna o bază de date hot standby, un strat de aplicație active-active în spatele unui load balancer sau, eventual, un setup multi-region. Compromisul e că în momentul în care ai mai mult de o mașină, ai un sistem distribuit, iar sistemele distribuite au propriile lor moduri de eșec. Despre asta e Modulul 2.

Simptom 3: deploy-urile provoacă outage

Restartul de treizeci de secunde pe care nimeni nu-l observa când aplicația era mică apare acum în inbox-ul de support. „Am încercat să facem checkout la 15:14 și a eșuat.” Verifici logurile. La 15:14 ai făcut rollout-ul noului build. Restartul a durat 28 de secunde, perioadă în care serverul a returnat 502-uri și un utilizator cu banii în mână nu putea să ți-i dea.

Ăsta e momentul în care multe echipe decid că au nevoie de zero-downtime deploys, și au dreptate. Soluția e bine înțeleasă: rulează mai mult de o instanță în spatele unui load balancer, scoate instanțele din rotație una câte una, drenează cererile în zbor, pornește versiunea nouă, fă-i health-check și pune-o înapoi în rotație. Stratul de orchestrare, oricare ar fi, se ocupă de coregrafie.

La ce să te uiți:

  • Spike-uri de erori legate de deploy în dashboard-uri, chiar și mici.
  • Inginerii preferă să livreze la 2 AM „ca să fie siguri”.
  • O frecvență de deploy care a scăzut pentru că livrările sunt înfricoșătoare.

Ultima e semnalul real. Dacă echipa livrează mai rar pentru că deploy-urile sunt riscante, plătești o taxă pe fiecare feature pe care-l livrezi de acum până când rezolvi problema. Soluția e, din nou, să nu mai ai o singură mașină, dar mai exact să nu mai ai un singur proces de aplicație. În spatele aceleiași baze de date, rulează două instanțe de aplicație. Apoi fă deploy pe rând. Raza de impact a unui deploy trece de la „toată lumea” la „cele câteva cereri în zbor pe instanța care se restartează acum”.

Simptom 4: backup-urile depășesc fereastra

Ai pus în picioare un dump logic de noapte. pg_dump la 2 AM, încarcă pe S3, gata. Timp de doi ani a durat aproximativ treizeci de minute și a folosit o fracțiune din disc și rețea. Într-o zi cineva observă că backup-ul nu s-a terminat înainte ca job-ul de batch de dimineață să pornească. Te uiți în loguri. Dump-ul durează acum paisprezece ore. Se suprapune cu el însuși în noaptea următoare. În curând nu vei mai putea face backup zilnic fără ca două backup-uri să ruleze simultan, luptându-se pentru IO.

Ăsta e echivalentul pe volum de date al problemei de deploy. Tehnica care a funcționat la scară mică (un dump logic gigantic) nu funcționează la scară mare, iar soluția e structurală, nu tactică. Treci la backup-uri fizice (pg_basebackup, WAL archiving sau echivalentul motorului tău). Faci un backup complet o dată pe săptămână, backup-uri incrementale zilnic și WAL streaming continuu pentru point-in-time recovery. Backup-ul complet ar putea dura tot paisprezece ore, dar rulează pe o replică și nu blochează producția.

La ce să te uiți:

  • Timpul real al backup-ului ca procent din 24 de ore. Orice peste 30 la sută e un avertisment. Orice peste 50 la sută e o urgență.
  • Spike-uri de IO legate de backup care afectează latența query-urilor de producție.
  • „Nu am testat efectiv un restore de șase luni” spus de oricine, vreodată. (Asta e o urgență separată, distinctă de problema de mărime.)

Un backup pe care nu-l poți restaura în RTO nu e un backup. Un backup care durează mai mult decât fereastra dintre backup-uri nu e nici el un backup. Ambele probleme apar la scară și forțează aceeași schimbare arhitecturală: backup-urile trebuie să ruleze altundeva decât pe primary și trebuie să fie incrementale.

Simptom 5: un endpoint lent îi blochează pe ceilalți

Sales operations rulează un raport greu pe /admin/sales o dată pe zi. Scanează un an de istoric de comenzi, face join cu produse și clienți și returnează un CSV. Dura douăsprezece secunde, ceea ce era enervant dar acceptabil. Acum durează trei minute, iar în timpul acestor trei minute endpoint-ul tău /api/orders, pe care o sută de clienți pe minut îl lovesc ca să facă checkout, devine lent. Uneori dă timeout.

Ce se întâmplă aici e simplu. Arhitectura pe un singur server are un proces, un connection pool, un set de conexiuni la baza de date și un buget de CPU. Query-ul de raportare le folosește pe toate. Cererile orientate spre clienți stau la coadă în spatele lui. Soluția e să separi workload-urile: un alt pool de procese pentru muncă de fundal și raportare, o altă replică de bază de date pentru query-uri analitice grele de citire, un alt ciclu de deploy pentru codul de raportare.

La ce să te uiți:

  • Latența pe endpoint-urile orientate spre clienți care se corelează cu momentul din zi când rulează un job greu cunoscut.
  • Un connection pool dimensionat pentru încărcare normală care se epuizează ori de câte ori rulează raportul.
  • Grafice de CPU pe baza de date care arată un tipar susținut de un-singur-proces-blocat în loc de un tipar zgomotos cu mai multe procese.

Următoarea mișcare e să introduci un worker pool: un set separat de procese, ideal un set separat de mașini, care se ocupă de munca de fundal, job-urile programate și analitica grea. Citirile pot merge pe o replică. Scrierile merg în continuare pe primary. Stratul orientat spre clienți rămâne mic și predictibil.

Simptom 6: migrarea de schemă nu mai încape în fereastră

Ai un tabel de 200 de milioane de rânduri. Trebuie să adaugi o coloană. Trebuie s-o populezi retroactiv. Abordarea naivă (un singur ALTER TABLE urmat de un UPDATE) va bloca tabelul sau îl va rescrie ore în șir. Ai o fereastră de mentenanță de două ore sâmbăta noaptea. Migrarea va dura șase ore. Nu o poți rula.

Ăsta e echivalentul pe schemă al simptomelor 4 și 5. Tehnica care a funcționat când cel mai mare tabel avea un milion de rânduri nu funcționează la două sute de milioane. Soluția e să faci migrările online, în bucăți, cu aplicația care rulează încă servind trafic. Adaugi coloana ca nullable. Faci backfill în batch-uri de zece mii de rânduri, cu pauze între batch-uri. Adaugi constrângerea NOT NULL la final ca o operație separată, rapidă. Folosești feature flags ca să comuți citirile de pe forma veche pe forma nouă.

La ce să te uiți:

  • Un plan de migrare care conține cuvintele „downtime” și „weekend”.
  • Migrări amânate trimestru după trimestru pentru că nu încap.
  • O echipă căreia îi e frică să evolueze schema, care în câțiva ani devine o echipă căreia îi e frică să livreze produs.

Următoarea mișcare e să adopți tipare și unelte de migrare online. pg_repack și pt-online-schema-change există dintr-un motiv. Eseul „Online Migrations” de la Stripe, pe care-l vom referenția mai târziu în curs, descrie playbook-ul. Schimbarea arhitecturală e de la „schema e ceva pe care-l schimbăm în ferestre de mentenanță” la „schema evoluează continuu și aplicația trebuie să tolereze ambele forme o vreme”.

Etapele creșterii

Fiecare dintre cele șase simptome de mai sus e o treaptă pe aceeași scară. Scara arată cam așa:

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]

Numerele de pe fiecare treaptă sunt deliberat vagi. Depind de workload (heavy pe citire versus heavy pe scriere, OLTP versus OLAP, costul mediu al unei cereri, raportul vârf-mediu). Ce nu e vag e ordinea. Aproape orice echipă care crește dincolo de un singur server trece prin aceste etape în această secvență. A sări o etapă e posibil, dar scump: o echipă care sare de la un singur server la o arhitectură de microservices-on-Kubernetes plătește pentru complexitatea operațională a etapelor 2, 3 și 4 fără a avea încărcarea care s-o justifice. Vom vedea în lecția următoare, cu trei studii de caz reale, ce se întâmplă cu echipele care fac asta.

Diagramă de creat: o hartă alternativă „simptom-la-etapă” care pune fiecare dintre cele șase simptome lângă etapa de pe scară care îl adresează. Simptomul 1 se mapează pe etapa B, simptomul 5 pe etapa C, simptomul 3 pe etapa B (și parțial D) și așa mai departe. Utilă ca referință de o pagină.

Despre ce e restul cursului

Dacă ai citit cele șase simptome și ai recunoscut cel puțin unul ca pe ceva cu care te confrunți acum, restul cursului e pentru tine. Modulul 2 acoperă fundamentele sistemelor distribuite. Modulul 3 acoperă scalarea stratului de date: replici, pooling, sharding. Modulul 4 acoperă straturile de workeri și cozile. Modulul 5 acoperă descompunerea în servicii. Modulul 6 acoperă operațiunile: observabilitate, deploy-uri, evoluția schemei la scară.

Structura e deliberată. Nu începem cu microservices pentru că majoritatea echipelor nu au nevoie de ele. Începem cu simptomele, pentru că simptomele îți spun ce mișcare să faci în continuare. O arhitectură nu e un set de componente la modă; e un set de decizii pe care le-ai luat, în ordine, ca răspuns la probleme specifice. Dacă nu ai încă problema, nu ai încă nevoie de decizie.

Lecția următoare, una de tip studiu de caz, e despre trei companii care au rezistat tentației de a supra-inginerii mai mult decât se așteptau toți și ce au scos din asta.

Caută