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

Query-uri cross-shard: fan-out vs co-location

Când datele sunt împărțite pe mai multe mașini, fiecare query are un cost proporțional cu numărul de shard-uri pe care le atinge. Strategiile pentru a ține numărul ăsta cât mai mic.

Cele trei lecții anterioare au parcurs mecanica partiționării unei baze de date pe mai multe mașini. Hash sharding, range sharding, problema rebalansării, problema cheilor fierbinți. La sfârșitul lecției 30 aveai un cluster sharded și o cheie de shard pe care o puteai apăra. Lecția asta e despre consecință: acum că datele tale sunt răspândite pe N mașini, fiecare query pe care-l rulezi are un cost care depinde de câte shard-uri trebuie să atingă, iar întreaga arhitectură e construită ca să țină numărul ăla cât mai mic. De obicei, unu.

Faptul de bază, spus direct: o bază de date sharded e rapidă pe query-uri pe un singur shard și lentă pe query-uri pe N shard-uri. Un query care nimerește pe un singur shard rulează la viteza unui shard, ceea ce pe Postgres sau MySQL înseamnă o milisecundă sau două pentru un lookup pe primary key. Un query care trebuie să facă fan-out către toate shard-urile rulează la viteza celui mai lent shard, plus overhead-ul de a îmbina rezultatele. Dacă shard-urile sunt în aceeași rețea, fan-out-ul ăla e de o cifră de milisecunde. Dacă sunt în regiuni diferite, sunt sute. În orice caz, scalează mai prost decât un query pe un shard, iar pe măsură ce clusterul crește devine mai rău, nu mai bun.

Tot jocul proiectării unui sistem sharded e să ții query-urile pe un singur shard ori de câte ori se poate. Cele două strategii pentru asta sunt co-location și fan-out, și sunt complementare, nu concurente.

Co-location: aranjează datele așa încât query-ul să rămână pe un shard

Co-location e strategia proactivă. Aranjezi layout-ul datelor așa încât rândurile pe care un query tipic trebuie să le privească împreună să trăiască pe același shard. Cheia de shard e pârghia. Alege-o astfel încât entitățile interogate împreună să împartă o cheie.

Alegerea clasică și aproape universală pentru aplicațiile SaaS e shard după user_id. Profilul unui user, comenzile lui, setările lui, sesiunile lui, notificările lui, fișierele încărcate: toate primesc ID-ul user-ului ca parte din cheia de shard, ceea ce înseamnă că toate trăiesc pe același shard. Orice query care filtrează după user_id (adică aproape orice query într-o aplicație centrată pe user) atinge exact un shard.

Asta funcționează pentru că workload-urile centrate pe user au o proprietate care se aliniază perfect cu modelul de sharding. Majoritatea query-urilor sunt scoped la un singur user. Comenzile mele sunt independente de ale tale: nu există un query normal care să trebuiască să facă join între comenzile mele și ale tale. Datele au o partiționare naturală pe axa user_id, iar pattern-ul de acces al aplicației o respectă.

Aceeași logică se generalizează. Un SaaS B2B multi-tenant se shardează după tenant_id în loc de user_id, pentru că unitatea naturală de localitate e tenant-ul, nu user-ul individual. O aplicație de mesagerie se shardează după channel_id (case study-ul Discord din lecția următoare e exact asta). Un joc social se shardează după world_id sau instance_id. Pattern-ul e același: identifică entitatea care deține majoritatea datelor și majoritatea query-urilor, și folosește ID-ul ei ca shard key.

Când nimerești cheia de shard, sistemul se simte aproape ca o bază de date non-sharded. Query-urile sunt rapide, latența e predictibilă, clusterul scalează liniar cu numărul de useri (sau tenanți, sau canale). Faptul că sunt cincizeci de mașini sub capotă e invizibil pentru codul aplicației, care doar filtrează după user_id și obține de fiecare dată performanță de un shard.

Fan-out: când chiar nu poți evita atingerea fiecărui shard

Unele query-uri nu pot fi făcute să trăiască pe un singur shard, oricât de deșteaptă ți-ar fi cheia de shard. „Câți useri s-au logat azi pe întreaga platformă” nu poate fi răspuns dintr-un singur shard, pentru că login-urile sunt răspândite pe toate după user_id. „Arată-mi toate comenzile plasate în ultima oră, sortate după valoare” nu poate fi răspuns dintr-un singur shard, din același motiv. Orice agregare între useri, filtrare după un câmp care nu e cheie de shard, sau ordonare globală, trebuie să se uite la fiecare shard.

Pentru aceste query-uri strategia e fan-out. Coordonatorul de query (uneori aplicația, alteori un router de nivel mediu, alteori chiar baza de date) trimite query-ul către fiecare shard în paralel. Fiecare shard își execută partea local și-și întoarce rezultatul. Coordonatorul îmbină rezultatele parțiale în răspunsul final.

Modelul de cost pentru fan-out e simplu. Latența e latența celui mai lent shard plus costul de merge. Throughput-ul e împărțit la N, pentru că fiecare query consumă acum un slot pe fiecare shard în loc de unul singur. Dacă faci prea multe query-uri fan-out, clusterul rulează efectiv la 1/N din capacitatea sa nominală. De aia fan-out-ul trebuie să fie excepția, nu regula.

Când rezultatele parțiale sunt mari (un sort global pe milioane de rânduri), pasul de merge devine în sine costisitor. Uneori merge-ul e mutat pe o mașină separată. Uneori aplicația acceptă o aproximare top-K în loc de un sort global adevărat. Uneori query-ul e rescris să folosească un alt store cu totul, ceea ce e drumul către warehouse-ul de analiză din lecția 65.

flowchart LR
    Q1[Query: orders for user 42] --> R1[Router]
    R1 -->|hash user_id 42| S1[(Shard 7)]
    S1 --> Resp1[1ms response]

    Q2[Query: top 100 orders today] --> R2[Router]
    R2 --> SA[(Shard 1)]
    R2 --> SB[(Shard 2)]
    R2 --> SC[(Shard ...)]
    R2 --> SD[(Shard N)]
    SA --> M[Merge top-K]
    SB --> M
    SC --> M
    SD --> M
    M --> Resp2[N-shard latency + merge]

Cele două pattern-uri de query, una lângă alta. Query-ul pe un shard e un singur hop. Query-ul fan-out e N hop-uri în paralel urmate de un merge. Diferența de cost e ceea ce face din alegerea cheii de shard cea mai consecventă decizie din sistem.

De ce „shard după user_id” a devenit religie

Trei motive, toate merită spuse cu voce tare pentru că alegerea e atât de reflexă în SaaS-ul modern încât inginerii uită adesea că a existat o decizie.

Workload-urile centrate pe user au query-uri scoped la user. Marea majoritate a query-urilor într-o aplicație SaaS încep cu „pentru acest user” sau „în acest tenant”. Când datele sunt așezate astfel încât datele fiecărui user trăiesc pe un shard, acele query-uri ating un shard. Arhitectura compune cu workload-ul.

Entitățile user sunt independente. Datele mele nu trebuie să fie unite cu datele tale pentru nicio funcționalitate normală. Comenzile, profilul și istoricul unui user sunt autonome. Nu există integritate relațională inerentă care să traverseze granița user-ului, deci pierderea join-urilor cross-user nu e dureroasă.

Raportarea poate fi mutată offline. Query-urile care chiar au nevoie de o vedere globală (dashboard-uri analitice, business intelligence, detecție de fraudă între useri) nu sunt critice ca latență. Pot fi rulate împotriva unui store analitic separat care ingerează date din toate shard-urile prin change-data-capture. Clusterul tranzacțional sharded e lăsat în pace să-și facă treaba de query pe un shard. Lecția 65 acoperă pattern-ul ăsta în detaliu.

Combinația înseamnă că pentru un SaaS centrat pe user, sharding-ul după user_id e aproape gratuit. Aproape orice query e natural pe un shard, cele câteva care nu sunt pot fi mutate într-un warehouse analitic, iar clusterul scalează la numărul de useri fără surprize arhitecturale.

Când sharding-ul după user_id nu funcționează

Trei situații, toate merită recunoscute.

Date multi-tenant cu vederi cross-user în interiorul unui tenant. Un SaaS B2B unde un tenant are mulți useri, iar un admin din acel tenant vrea să vadă toată activitatea de la toți userii săi, va fi nemulțumit de sharding-ul după user_id. Query-ul admin-ului e natural scoped la un tenant, dar datele sunt răspândite pe multe shard-uri (unul per user). Soluția e să shardezi după tenant_id în loc. Acum un query de admin e pe un shard, iar un query de user e tot pe un shard (pentru că datele user-ului trăiesc pe shard-ul tenant-ului). Compromisul e că un tenant foarte mare devine o partiție fierbinte, problema acoperită de lecția 28.

Funcționalități cross-user. Chat între doi useri. Un graf de prieteni. Un like pe postul altcuiva. Aceste funcționalități traversează inerent granița user-ului, și nicio cheie de shard nu le ține pe un singur shard. Răspunsurile pragmatice: stochează mesajele de două ori, o dată pe shard-ul fiecărui participant, astfel încât orice query despre „mesajele mele” să rămână pe shard-ul meu. Sau stochează datele cross-user într-un store separat, mai mic, non-sharded, dedicat tabelului de relații. Sau folosește materialised views event-driven care precalculează join-urile cross-user în inbox-uri per user. Toate trei sunt comune; niciuna nu e gratuită.

Lookup-uri globale după altceva decât user_id. „Găsește user-ul cu emailul alice@example.com” e un query global când e shardat după user_id, pentru că emailul trăiește pe orice shard care deține acel user, și nu știi pe care fără un index separat. Soluția standard e un mic tabel de lookup global (email -> user_id) menținut de fiecare scriere pe shard, sau ținut într-un store auxiliar non-sharded. Tabelul de lookup e suficient de mic ca să trăiască pe o singură mașină, restul datelor sunt sharded pentru scală.

Principiul general: când identifici un query care nu se potrivește cu cheia de shard, ai trei opțiuni oneste. Replică datele de referință mici pe fiecare shard astfel încât orice shard să poată răspunde local. Construiește un index secundar denormalizat (adesea într-un store separat) astfel încât și calea de acces alternativă să fie un lookup pe un shard. Acceptă fan-out pentru acel query specific și asigură-te că e suficient de rar încât clusterul să-l poată absorbi.

Replicarea tabelelor de referință

Un pattern care merită numit. Unele tabele sunt mici, se schimbă rar și sunt unite de fiecare query: coduri de țară, cursuri valutare, definiții de feature flag, categorii de produse, limite de plan. Dacă shardezi un tabel ca ăsta, fiecare join devine un fan-out, ceea ce strică totul.

Soluția e să replici tabelul pe fiecare shard. Fiecare shard ține o copie completă. Join-urile sunt locale. Update-urile sunt scrise pe fiecare shard, ceea ce e acceptabil pentru că sunt rare. Sistemele de tip Postgres au uneori suport explicit pentru asta: Citus le numește „reference tables”, Vitess are un concept similar. Într-o aplicație sharded făcută manual, sistemul de migrare împinge schimbări de schemă și de date pe toate shard-urile.

Materialised views pentru query-ul incomod

Cealaltă supapă de scăpare. Când un query chiar nu poate trăi pe un singur shard cu layout-ul natural al datelor, precalculezi răspunsul într-o structură care poate.

Exemplul clasic e un feed. Feed-ul user-ului A combină postări de la userii B, C, D, E. Dacă datele sunt shardate după user_id, construirea feed-ului lui A necesită fan-out pe oricâți useri urmărește A. Soluția e să menții feed-ul lui A ca propriul lui tabel, shardat după user_id-ul lui A. Când B postează, un eveniment scrie o copie denormalizată în tabelele de feed ale tuturor celor care-l urmăresc pe B. Costul construirii feed-ului e plătit la write time, distribuit și amortizat. Citirea e un lookup pe un shard.

Ăsta e pattern-ul fan-out-on-write, și e ce folosește orice produs social la scară mare pentru feed-uri. Twitter a fost public despre asta. Costul e real: postul lui B s-ar putea să fie scris în tabelele de feed ale unui milion de followeri. Compensarea e latență de citire constantă indiferent câți followeri are B. Modulul 5 acoperă instalațiile event-driven care fac asta operațional.

Numerele de performanță, concret

Un query pe un shard pe Postgres împotriva unui index de primary key: 0.5 până la 2 ms, inclusiv round-trip-ul rețelei locale. Stratul de routing adaugă cel mult o milisecundă deasupra.

Un fan-out pe 10 shard-uri pe aceeași rețea: aproximativ 5 până la 10 ms, dominat de cel mai lent shard plus o milisecundă de merge. Acceptabil ocazional, scump pe fiecare page view. Un fan-out pe 100 de shard-uri: 10 până la 30 ms în cel mai bun caz, mult mai rău dacă vreun shard e lent.

Un fan-out cross-region, unde shard-urile trăiesc în regiuni geografice diferite: minimum 100 până la 300 ms, limitat de latența rețelei inter-regiune. Asta e aproape întotdeauna prea lent pentru un query interactiv. Răspunsul standard e geo-sharding după cheie de regiune și acceptarea că query-urile globale nu sunt interactive. Aceste numere sunt motivul pentru care arhitectura e construită în jurul query-urilor pe un shard.

Forma unei aplicații sharded sănătoase

Ca să închidem: cum arată când sharding-ul e făcut bine?

Cheia de shard se potrivește cu pattern-ul dominant de query. Aproape orice query pe hot path filtrează explicit după cheia de shard, astfel încât stratul de routing să poată redirecta direct către shard-ul țintă. Un număr mic de tabele de referință sunt replicate pe fiecare shard pentru join-uri locale. Există un număr mic de query-uri cross-shard; sunt fie suficient de rare ca să facă fan-out, fie mutate în materialised views care le transformă înapoi în citiri pe un shard, fie mutate într-un store analitic care ingerează CDC din toate shard-urile.

Când aplicația crește, se adaugă mai multe shard-uri. Query-urile pe hot path continuă să fie pe un shard. Capacitatea clusterului crește liniar cu numărul de shard-uri. Asta e ce vor să spună oamenii când zic „sharding-ul funcționează”. E rezultatul nimeririi cheii de shard și al tratării query-urilor cross-shard ca pe excepția rară care trebuie să fie.

Lecția 32, ultima lecție din Modulul 4, parcurge migrarea de storage de la Discord în detaliu. E exemplul de manual al unui sistem a cărui arhitectură a fost construită în jurul unui pattern de query și unei chei de shard, și ale cărui migrări între trei baze de date diferite au păstrat aceeași formă.

Citări și lecturi suplimentare

  • „Designing Data-Intensive Applications” (Martin Kleppmann, O’Reilly, 2017), capitolul 6. Referința standard pentru partiționare, chei de shard și query-uri cross-partition.
  • Citus documentation, „Reference Tables”, https://docs.citusdata.com/en/stable/develop/reference_tables.html (consultat 2026-05-01). Tratamentul operațional al tabelelor de referință replicate într-un produs de Postgres-sharding.
  • Vitess documentation, https://vitess.io/docs/ (consultat 2026-05-01). MySQL sharding cu suport explicit pentru pattern-urile pe care le discută lecția asta.
  • Twitter Engineering, „The Infrastructure Behind Twitter: Scale”, https://blog.twitter.com/engineering/en_us/topics/infrastructure/2017/the-infrastructure-behind-twitter-scale (consultat 2026-05-01). Discuție publică despre fan-out-on-write pentru livrarea feed-ului la scară.
Caută