De ce am rescris în Go serverul de table online în timp real al Table42
Cum am făcut jocul de table online mai rapid sub încărcare: am mutat calea critică în timp real pe un mic serviciu scris în Go.
Orice joc online trăiește sau moare printr-un singur lucru: cum se simte atunci când atingi o piesă și aștepți ca tabla să se miște. Dacă acel moment este instantaneu, jocul pare viu. Dacă se bâlbâie — chiar și cu o secundă — totul pare stricat, oricât de bine ar fi făcut restul.
Aceasta este povestea despre cum am făcut acel moment mai rapid și mai stabil în Table42 — jocul nostru gratuit de table online multiplayer — mutând partea în timp real a backendului nostru de pe framework-ul care rulează restul aplicației pe un serviciu mic, construit special, scris în Go. Este scrisă pentru două tipuri de cititori: dacă vrei doar să afli de ce jocul se simte mai bine acum, prima jumătate este pentru tine. Dacă vrei detaliul tehnic — testele de încărcare, arhitectura, compromisurile — totul se află în a doua jumătate, marcat clar. Iar dacă preferi să joci table online chiar acum, dă-i drumul — restul va fi tot aici după aceea.
Varianta scurtă
Backendul nostru era o singură aplicație Dart construită pe framework-ul Serverpod. Făcea totul: conturi, prieteni, turnee, achiziții în aplicație, chat — și gameplay-ul live în sine. Serverpod este cu adevărat excelent pentru asta — a permis unei echipe mici să livreze rapid un produs client-server complet și tipizat și încă rulează majoritatea Table42 și astăzi. Dar gameplay-ul live este un tip de muncă fundamental diferit de restul, iar pe măsură ce mai mulți oameni jucau în același timp, cele două au început să concureze pentru aceleași resurse. Partea sistemului care are cel mai mult nevoie să fie rapidă — mutarea pieselor într-o partidă live — împărțea aceeași bandă cu tot restul.
Așa că am desprins partea de gameplay live într-un serviciu dedicat propriu. Acesta se ocupă doar de calea critică în timp real: zaruri, mutări, starea tablei și menținerea sincronizării ambilor jucători. Tot restul a rămas exact acolo unde era. Rezultatul este că o seară aglomerată de matchmaking nu mai încetinește momentul în care faci o mutare — iar cele două jumătăți ale sistemului pot crește acum independent.
De ce contează așteptarea
Într-o partidă de table online multiplayer, mesajele care zboară între telefonul tău și serverul nostru sunt minuscule și constante: am dat cu zarurile, iată ce a ieșit; am mutat această piesă; este o mutare permisă; iată noua tablă; e rândul tău. Zeci dintre acestea pe fiecare joc, toate sensibile la latență, toate trebuind să ajungă la ambii jucători aproape instantaneu — asta înseamnă multiplayer în timp real când dai cu zarurile împotriva unui adversar live.
Este o formă de muncă foarte diferită de, să zicem, încărcarea listei de prieteni sau procesarea unei achiziții. Acestea se întâmplă ocazional și pot tolera câteva sute de milisecunde. Mutările live nu pot — o întârziere de o secundă la o mutare este diferența dintre un joc care pare receptiv și unul care pare cu lag.
Când un singur sistem se ocupă de ambele, munca ocazională-dar-grea poate sta în calea muncii constante-dar-ușoare. Exact asta am început să vedem.
Povestea tehnică
De aici încolo, intrăm în inginerie. Dacă nu e genul tău, concluzia de mai sus este esența: jocul este mai rapid pentru că munca lui cea mai sensibilă la timp rulează acum pe cont propriu.
Punctul de plecare
Backendul original este o aplicație Serverpod scrisă în Dart și merită spus clar: Serverpod este un framework excelent. Ne-a oferit multe gratis — SDK-uri client generate, un protocol tipizat partajat între aplicația Flutter și server, autentificare, acces la baza de date, stream-uri WebSocket — și a permis unei echipe mici să avanseze remarcabil de rapid. Încă rulează majoritatea produsului și astăzi: autentificare, utilizatori, prieteni, turnee, achiziții în aplicație, chat și unelte de administrare, și nu avem planuri să schimbăm asta.
Gameplay-ul în timp real rula în interiorul aceluiași proces. Serverul era autoritativ: dădea cu zarurile, valida fiecare mutare conform regulilor de table, deținea starea canonică a tablei și trimitea actualizări către ambii clienți prin stream-uri WebSocket. Corect și curat — dar împărțea un runtime cu fiecare alt endpoint din produs.
Găsirea zidului
Înainte de a schimba ceva, voiam cifre, nu presupuneri. Așa că am construit un harness de teste de încărcare care generează jucători sintetici care se autentifică, intră în matchmaking, sunt împerecheați în jocuri reale și joacă mutări permise aleatorii cap-coadă — trecând prin exact aceleași stream-uri WebSocket pe care le folosește un client Flutter real. Fără scurtături, fără endpoint-uri simulate.
Câteva alegeri deliberate au făcut rezultatele demne de încredere:
- Regiuni diferite, hardware separat. Generatorul de încărcare rula într-un datacenter (Nuremberg) și lovea serverul din altul (Helsinki), la aproximativ 30 ms distanță. Asta este mai aproape de un utilizator real decât un benchmark pe aceeași mașină și garanta că generatorul de încărcare nu putea fura CPU de la serverul pe care îl măsuram.
- Concurență cu ramping automat. Harness-ul dubla numărul de jucători simultani pe un program fix și înregistra o captură la fiecare cinci secunde: latența autentificării, latența matchmaking-ului și latența confirmării mutărilor la p50/p95/p99, plus numărul de erori și de jocuri expirate.
- Praguri de rupere oneste. Am definit „stricat” din start — p95 la confirmarea mutărilor peste 1,5 s, sau rata de eroare a mutărilor peste 2% — astfel încât testul declara eșecul obiectiv în loc să ne uităm noi la un grafic.
Forma rezultatelor era clară. La concurență redusă, serverul era perfect sănătos: cu câteva zeci de jucători, confirmările mutărilor veneau înapoi în aproximativ 100–180 ms. Dar pe măsură ce harness-ul creștea — 80, apoi 160, apoi 320 de jucători simultani — calea în timp real se degrada brusc. p95 la confirmarea mutărilor urca în zona de mai multe secunde, coada p99 se întindea mult mai rău, matchmaking-ul încetinea, iar jocurile începeau să moară pe timeout-ul de abandon de patru minute: câteva la început, apoi zeci. Undeva în jurul pragului de 320 de jucători, pragurile de rupere se declanșau.
Concluzia importantă nu era „Dart e lent” sau „Serverpod nu face față” — niciuna nu este adevărată, iar amândouă rulează confortabil restul Table42 până în ziua de azi. Era ceva mai simplu: un model general de procesare a cererilor, care duce fiecare endpoint greu din produs deodată, nu este forma ideală pentru un torent de mesaje de joc minuscule și critice la latență. Asta este valabil pentru aproape orice framework „all-in-one” — calea critică își dezvoltase propriul set de cerințe și avea nevoie atât de un model de concurență reglat exact pentru asta, cât și de propriul runtime, ca să nu mai concureze cu tot restul.
Decizia: un sidecar, nu o rescriere
Mișcarea tentantă — rescrierea întregului backend — era și cea greșită. Cea mai mare parte a suprafeței Serverpod (conturi, achiziții în aplicație, turnee, chat) funcționa bine și nu avea nicio legătură cu gâtul de sticlă. Rescrierea ei ar fi însemnat un risc enorm pentru zero beneficiu.
În schimb, am folosit modelul sidecar. Am construit un serviciu mic, separat, care deține doar calea critică a jocului în timp real și am lăsat Serverpod să dețină tot restul. Cele două rulează cot la cot pe același VPS și partajează aceleași Postgres și Redis, deci nu există nicio problemă de sincronizare a datelor între ele — doar o împărțire curată a responsabilităților. Raza de impact era mică, lansarea putea fi incrementală, iar dacă mergea prost puteam reveni la varianta anterioară.
Pentru acel serviciu am ales Go, tocmai datorită modului în care gestionează concurența.
Arhitectura: o goroutine per cameră
Designul de bază este o goroutine per cameră de joc.
În Go, o goroutine este o unitate extrem de ușoară de execuție concurentă — costă câțiva kilobytes, nu un thread complet de sistem de operare — iar runtime-ul Go multiplexează multe mii dintre ele pe nucleele CPU disponibile. Această proprietate face designul posibil: fiecare partidă activă își primește propria goroutine care deține exclusiv starea acelei partide.
Asta aduce două lucruri care contează enorm pentru un server de joc:
- Fără lock-uri pe calea critică. Deoarece o singură goroutine deține starea fiecărei camere, fiecare aruncare de zaruri, mutare și actualizare a tablei pentru acea partidă este procesată una câte una de acea goroutine. Alte părți ale sistemului vorbesc cu o cameră trimițându-i o comandă, nu întinzându-se să-i atingă datele. Nu există stare mutabilă partajată pentru care să se concureze pe calea critică, ceea ce înseamnă fără contenție pe lock-uri și fără curse de date — clasa de bug-uri de concurență este eliminată prin design, nu prin depanare.
- Izolare naturală. O cameră aglomerată sau care se comportă greșit nu poate corupe sau bloca alta. Zece mii de camere liniștite nu costă aproape nimic cât timp așteaptă.
În jurul acestor goroutine de cameră stă o distribuție de sprijin subțire:
- Un hub de camere la nivel de proces — o hartă simplă în spatele unui lock optimizat pentru citire — care oferă căutarea O(1) a unei camere după ID. Lock-ul este ținut doar pentru căutarea microscopică; toată munca reală per-cameră se întâmplă în spatele propriei goroutine a fiecărei camere, așa că hub-ul nu devine niciodată un gât de sticlă.
- Un strat WebSocket non-blocant. Bucla de citire a conexiunii face cât mai puțin posibil: decodează plicul unui mesaj primit și îl predă goroutinei potrivite a camerei sau a lobby-ului. Munca grea nu se întâmplă niciodată pe calea de citire a socketului, așa că o operațiune lentă nu poate bloca conexiunea.
- Un pool de conexiuni către Postgres (prin
pgx) și un protocol de rețea compact și versionat ale cărui tipuri de mesaje reflectă deliberat enum-urile Serverpod. Acel ultim detaliu este ceea ce a permis aplicației Flutter să vorbească cu noul serviciu folosind același vocabular partajat pe care îl cunoștea deja — am adăugat un client de joc dedicat pe partea aplicației și am putut migra traficul live pe serviciul Go joc cu joc, în loc de o singură comutare riscantă.
Totul se compilează într-un container minuscul: un build Docker multi-stage care produce o imagine de runtime distroless. Imagine mică, pornire rapidă și o amprentă de memorie modestă — ceea ce contează când împarte o mașină cu backendul principal.
De ce este mai rapid la încărcare — sincer
Suntem deliberat prudenți aici: avem un baseline riguros pentru calea veche (testele de încărcare de mai sus) și nu vom cita cifre precise „de după” pentru care nu am publicat datele. Dar motivele pentru care noul design rezistă mai bine la concurență sunt structurale, nu speranțe:
- Concurență care se potrivește cu sarcina. Un joc live este modelat natural ca un actor independent, de lungă durată, condus de mesaje. Goroutine-per-cameră îl mapează unu-la-unu. Mii de partide simultane devin mii de goroutine ieftine pe care runtime-ul le planifică pe fiecare nucleu — în loc de mii de cereri care stau la coadă printr-un model construit pentru o altă formă de trafic.
- Fără contenție pe calea critică. Serializarea stării fiecărei camere prin propria goroutine elimină lock-urile din partea sistemului care rulează cel mai des și contează cel mai mult. Mai puțină suprasarcină de coordonare înseamnă o latență mai previzibilă, mai ales în coada p99 unde calea veche suferea cel mai rău.
- Izolare a resurselor. Serviciul în timp real și backendul principal nu mai împart un runtime. Un val de matchmaking într-o seară aglomerată nu poate înfometa codul care validează mutarea ta, și viceversa. Fiecare poate fi scalat, profilat și analizat pe cont propriu.
- Un runtime compilat și previzibil. Un binar Go nativ cu un garbage collector de latență redusă gestionează uniform rafalele de mesaje mici și frecvente — exact profilul de trafic al tablei live.
Ce am păstrat și ce ți-am spune să copiezi
Partea de care suntem cei mai mulțumiți nu este alegerea limbajului — este reținerea. Nu am rescris un sistem funcțional. Am identificat singura componentă ale cărei cerințe divergaseră de tot ce era în jur, i-am dat acelei componente runtime-ul de care avea de fapt nevoie și am lăsat restul în pace. Bază de date partajată, cache partajat, protocol compatibil, migrare incrementală, revenire ușoară.
Dacă reții un singur lucru din asta: înainte să apelezi la o rescriere, fă teste de încărcare suficient de oneste încât să găsești zidul real, apoi mută doar lucrul care chiar se lovește de el.
Vrei să simți diferența singur? Poți juca table online gratis — fără cont — împotriva unor adversari reali în partide multiplayer rapide și fiabile, chiar în browser la play.table-online.ro. Preferi telefonul? Ia-ți Table42, jocul nostru de table online, din App Store sau Google Play și dă cu zarurile împotriva jucătorilor din toată lumea.
Zaruri bune. 🎲
Gata să joci?
Joacă acum