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

Hot keys și problema de rebalancing

Utilizatorul-celebritate cu un milion de followeri. Cum detectezi un hot key, trei strategii pentru a-l gestiona și de ce rebalancing-ul unui cluster live e mai greu decât pare.

Lecția precedentă a expus cele trei moduri de a împărți datele între mașini: hash partitioning, range partitioning și varianta directory-based. Toate trei presupun ceva ce rar e adevărat în producție: că munca, odată împărțită, e împărțită uniform. În sistemele reale, distribuția încărcării e mai neuniformă decât distribuția datelor. Un utilizator are un milion de followeri. Un produs e pe prima pagină a fiecărui site de știri pentru o zi. Un hashtag e în trending. Partiția care ține acea cheie primește trafic disproporționat, iar echilibrul atent aranjat în ziua unu încetează discret să mai funcționeze.

Această lecție e despre acel dezechilibru. Cum arată în monitorizare, de ce se întâmplă și cele trei răspunsuri oneste la el. Apoi trecem la problema înrudită a rebalancing-ului: când un nod intră sau iese din cluster, datele trebuie să se mute, iar mutarea datelor pe un sistem live e mult mai grea decât sugerează diagramele.

Cum arată un hot key

Simptomul de manual e un singur nod care lucrează și restul care lenevesc. Deschizi graficul de CPU per nod și vezi o linie la 90 la sută și restul la 10. Deschizi rata de cereri per nod și un nod primește de cinci ori mai multe query-uri decât colegii săi. Deschizi latența per task în monitorizare și histograma e bimodală: majoritatea operațiilor se termină rapid, o coadă încăpățânată se termină lent, iar coada lentă vine de la un singur shard.

Dacă monitorizarea ta merge mai în adâncime, de obicei poți localiza dezechilibrul pe o cheie specifică. Majoritatea data store-urilor moderne expun contoare de cereri per cheie sau per partiție, eșantionate sau agregate. Forma pe care o cauți e o coadă foarte lungă: un top-K în care prima cheie primește de zece sau o sută de ori mai mult trafic decât cheia mediană. Acela e hot key-ul.

Trei lucruri fac ca detectarea rapidă să merite. În primul rând, nodul fierbinte e un outlier de latență pentru orice altă cheie de pe acel shard, pentru că toate stau la coadă în spatele celebrității. În al doilea rând, nodul fierbinte e cel care va rămâne primul fără CPU, memorie sau disc, iar o cădere de un singur nod sub acea încărcare e începutul unui incident. În al treilea rând, calculul tău nominal de capacitate e greșit: dacă un nod e la 90 la sută și media clusterului e la 30, nu ai 70 la sută headroom, ai 10 la sută.

De ce apar hot keys

Cauzele comune merită numite, pentru că soluția corectă depinde de care dintre ele o ai.

Acces dezechilibrat la o entitate populară. Un utilizator-celebritate, un produs viral, un subiect în trending. Distribuția datelor e bună; pattern-ul de acces nu. Majoritatea utilizatorilor au o mie de followeri, câțiva au un milion. Majoritatea produselor vând zece bucăți pe zi, unul vinde o sută de mii în ziua de lansare. Partiția care ține acea entitate face muncă reală pe care celelalte n-o fac.

Alegere proastă a partition key-ului. O cheie care arăta echilibrată în abstract se dovedește a concentra traficul în practică. Partiționezi după country_code și descoperi că 60 la sută dintre utilizatori trăiesc într-o singură țară. Partiționezi o bază de date multi-tenant după tenant_id și descoperi că un singur tenant e mai mare decât celelalte o mie la un loc. Funcția de partiționare a făcut exact ce i-ai cerut; problema e datele.

Hot spots bazate pe timp. Range-partiționat după dată: partiția de azi primește toate scrierile și majoritatea citirilor, partiția săptămânii trecute primește citiri ocazionale, partiția de anul trecut e rece. Clusterul are datele distribuite uniform ca volum, dar încărcarea trăiește în întregime pe cel mai recent shard.

Thundering herd. Un cache miss pe o cheie populară face ca fiecare web server să interogheze simultan aceeași partiție din backend. Data store-ul vede un spike sincronizat brusc pe o singură cheie de la o sută de clienți. Partiția care ține acea cheie e ok în medie și în flăcări câte cinci secunde la rând.

Fiecare pattern are același simptom de suprafață (o partiție fierbinte) și o cauză rădăcină diferită. Diagnosticarea celei pe care o ai e munca ce justifică soluția.

Trei soluții oneste

Nu există un mod inteligent de a face un hot key să nu fie un hot key. Soluțiile oneste sunt: împrăștie cheia pe mai multe partiții, pune-o în spatele a ceva mai rapid, sau dă-i propria infrastructură. Fiecare are costuri.

Salting. Adaugă un sufix mic și aleatoriu la cheie înainte de hashing. În loc să scrii contoarele de followeri sub user:42, scrii sub user:42:0 până la user:42:15, alegând un sufix la momentul scrierii. Citirile trebuie să facă fan-out peste toate cele șaisprezece sub-chei și să agrege. Munca e acum răspândită pe șaisprezece partiții în loc de una, nodul fierbinte nu mai e fierbinte, iar costul e fan-out-ul de pe partea de citire și complexitatea aplicației de a trata cheia logică ca pe un set de chei fizice. Acest pattern revine în fiecare curs de procesare distribuită, inclusiv cursul de PySpark de pe acest site, unde e remediul standard pentru join-urile dezechilibrate.

Caching pentru hot key-ul. Pune un cache separat (Redis, în majoritatea stack-urilor) în fața store-ului partiționat care să absoarbă traficul de citire pentru cheile populare. Store-ul partiționat încă deține scrierile și coada lungă a cheilor reci; cache-ul deține setul mic de chei care sunt fierbinți. Asta mută problema hot key-ului de la baza de date la cache, iar Redis e construit exact pentru acea încărcare. Costul e suprafața operațională a unui al doilea store și întrebarea de cache invalidation (lecția 24 a acoperit varianta polyglot a aceleiași discuții).

Shard-uri dedicate pentru giganți. Dacă un utilizator e enorm, dă-i propria infrastructură. Twitter e exemplul canonic: conturile de celebritate nu sunt stocate sau distribuite la fel ca cele obișnuite, pentru că asta ar topi clusterul de fiecare dată când unul dintre ei tweet-uiește. Costul e operațional: un cod path separat, un set separat de shard-uri, un mod de eșec separat. Beneficiul e că gigantul încetează să contamineze profilul de încărcare al tuturor celorlalți.

Arborele decizional e cam așa: salting dacă pattern-ul de acces e dominat de citiri și setul de chei e suficient de mic pentru fan-out; caching dacă majoritatea traficului e citiri pe un set mic și fierbinte; shard-uri dedicate dacă dezechilibrul e structural și entitățile fierbinți sunt puține și nominate.

Detectarea hot keys în monitorizare

Câteva note practice despre detecție, pentru că soluția ajută doar dacă observi problema mai întâi.

Majoritatea bazelor de date distribuite expun metrici per partiție: număr de cereri, latență a cererilor, bytes intrați, bytes ieșiți, uneori sampling per cheie. Cassandra are nodetool tablestats și latență de coordonator per tabel. MongoDB expune contoare de operații per shard și un log de balancer pentru shard-uri. Redis Cluster expune metrici per nod și per slot. Pattern-ul pe care-l urmărești e variația între partiții, nu numerele absolute. Dacă cea mai fierbinte partiție face de cinci ori munca celei mediane, ai un hot key, fie că ai sau nu o problemă deja.

Top-K sampling la nivelul aplicației e cealaltă jumătate. Loghează cheile query-urilor lente, numără-le, alertează când o cheie depășește un prag. Versiunea brută e un hash map de dimensiune limitată cu cheile lente recente. Versiunea elegantă e un count-min sketch sau un algoritm de heavy-hitters. Oricare dintre ele îți spune ce cheie să investighezi când o partiție se încinge.

Rebalancing: când nodurile intră sau ies

Celălalt capăt al aceleiași probleme e rebalancing-ul. Hot keys sunt despre încărcare neuniformă pe o topologie statică. Rebalancing-ul e despre mutarea datelor când topologia se schimbă. Adaugi un nod pentru că traficul a crescut, sau scoți un nod pentru că a căzut, sau autoscaler-ul a decis să crească clusterul peste noapte. Indiferent de direcție, datele trebuie să se mute, iar mutarea datelor pe un cluster live în timp ce servește trafic e una dintre cele mai grele probleme operaționale din sistemele distribuite.

Există două abordări principale.

Static rebalancing cu multe partiții mici. Pre-aloci mult mai multe partiții decât noduri (modelul de vnode al Cassandrei e default 256 de noduri virtuale per nod fizic) și asignezi partiții întregi la noduri. Când un nod intră, își revendică partea sa de partiții de la nodurile existente; când unul iese, partițiile sale sunt redistribuite. Numărul de partiții nu se schimbă niciodată, doar atribuirea. Asta e simplă operațional și dă un profil de încărcare uniform. Costul e că trebuie să alegi numărul de partiții la momentul creării și să trăiești cu consecințele: prea puține partiții și nu poți scala dincolo de un număr mic de noduri, prea multe și overhead-ul de metadata devine propria problemă.

Dynamic rebalancing cu partition splits. Pornești cu un număr mic de partiții și le împarți când devin prea mari sau prea fierbinți. HBase împarte regiunile când trec un prag de dimensiune. Clusterele sharded MongoDB împart chunk-urile peste o dimensiune țintă și mută chunk-uri între shard-uri când încărcarea e neuniformă. Clusterul crește și se rebalansează singur pe baza condițiilor observate. Costul e complexitatea mecanicii de splitting și variabilitatea în comportamentul operațional: o partiție poate fi împărțită într-un moment în care ai prefera să nu fie, iar traficul de fundal al rebalancing-ului concurează cu traficul utilizatorilor pentru aceleași discuri și rețele.

Sistemele range-partiționate au un detaliu suplimentar: un range popular se încinge, un range rar e neutilizat, iar rebalancer-ul trebuie să știe atât despre încărcare, cât și despre volumul de date. Un splitter pur bazat pe dimensiune e greșit pentru range partitioning, pentru că un range mic dar fierbinte trebuie și el să fie împărțit.

De ce live rebalancing e mai greu decât pare

Diagramele de rebalancing sunt curate: o partiție devine prea mare, o linie o împarte în două, layer-ul de routing învață noul layout, traficul continuă. Realitatea e mai murdară.

Clienții trebuie să știe unde trăiesc datele. Dacă topologia se schimbă în timp ce un client e la mijlocul unei cereri, clientul trebuie să retry la noul owner, iar acel retry trebuie să fie sigur (lecția 16 a fost despre idempotență exact din acest motiv). Query-urile în zbor pot eșua. Tranzacțiile cross-partition pot fi împărțite la mijlocul zborului de către rebalancer dacă le permiți, deci rebalancer-ul trebuie să se coordoneze cu transaction manager-ul. Datele care se mută trebuie în continuare să servească citiri și să accepte scrieri, ceea ce de obicei înseamnă că partiția sursă rămâne autoritară până când destinația a recuperat, apoi un cutover scurt și sincronizat predă proprietatea.

Majoritatea sistemelor reale delegă întrebarea de topologie unui mic coordonator dedicat: ZooKeeper, etcd sau un grup de consensus built-in care rulează Raft. Clienții întreabă coordonatorul (direct sau prin lookups cache-uite) unde trăiește o cheie dată, iar coordonatorul gestionează schimbările de membership și de atribuire a partițiilor printr-un protocol de consensus. Acesta e exact rolul descris în lecția 14: configuration store-ul e zidul portant al întregului cluster și e susținut de consensus din aceleași motive ca orice altă decizie partajată.

flowchart LR
    Client[Client] --> Router[Routing layer]
    Router --> P1[Partition 1 - node A]
    Router --> P2a[Partition 2a - node B]
    Router --> P2b[Partition 2b - node C]
    Coord[(Coordinator - Raft)] -->|topology| Router
    P2a -.->|was Partition 2| P2b

Diagrama arată ce s-a schimbat. Partiția 2 a devenit prea fierbinte sau prea mare, coordonatorul a decis s-o împartă în 2a și 2b, iar layer-ul de routing a fost actualizat să trimită traficul pentru jumătatea inferioară la nodul B și jumătatea superioară la nodul C. Coordonatorul e singurul loc în care trăiește adevărul. Router-ul îl cache-uiește; partițiile îl implementează; clienții depind de orice crede router-ul în acel moment.

Ce pregătește această lecție

Hot keys și rebalancing sunt taxa operațională a partiționării. Poți face designul de partiționare corect în ziua unu și totuși să descoperi, șase luni mai târziu, că un cont de celebritate topește un shard, sau că job-ul nocturn de batch mută partiții două ore și concurează cu peak-ul de dimineață. Soluțiile există, dar fiecare are un cost, iar cea potrivită pentru workload-ul tău e cea al cărei cost echipa ta și-l permite.

Lecția următoare urcă un nivel: cum construiești un sistem SQL sharded peste aceste primitive? Opțiunile sunt routing la nivel de aplicație, o extensie Postgres precum Citus, o variantă MySQL precum Vitess, sau sharding-ul built-in al MongoDB și Cassandra. Fiecare face trade-off-uri diferite în jurul controlului, complexității și compatibilității SQL, iar migrarea de la unsharded la sharded e unul dintre cele mai grele proiecte de inginerie cu care se confruntă majoritatea echipelor. Aceea e lecția 29.

Citări și lecturi suplimentare

  • Martin Kleppmann, “Designing Data-Intensive Applications” (O’Reilly, 2017), Capitolul 6 (partitioning) și Capitolul 9 (consistency and consensus). Tratarea de lungimea unei cărți a problemelor de hot key și rebalancing.
  • Documentația Cassandra, “Adding, replacing, moving and removing nodes”, https://cassandra.apache.org/doc/latest/cassandra/operating/topo_changes.html (consultat 2026-05-01). Modelul de vnode în practică.
  • Documentația MongoDB, “Sharded Cluster Balancer”, https://www.mongodb.com/docs/manual/core/sharding-balancer-administration/ (consultat 2026-05-01). Splitting de chunk-uri și balancer-ul.
  • HBase Reference Guide, “Region Splitting”, https://hbase.apache.org/book.html#regions.arch (consultat 2026-05-01). Modelul de dynamic rebalancing.
  • Documentația etcd, https://etcd.io/docs/ (consultat 2026-05-01). Coordonatorul de referință pentru topologia clusterului.
Caută