Scalarea infrastructurii WebSocket pentru jocuri realtime
Arhitectura practica pentru a scala WebSocket in jocuri realtime: sharding pe camere, backpressure, pub/sub, costuri si capcane de productie.

Daca strici strategia WebSocket intr-un joc realtime, pierzi jucatori in secunde si bugete in luni. Latența, reconectari in valuri, memorie care explodeaza — sunt simptomele unui design gresit.
Pe scurt: pentru jocuri realtime la scara, separa gateway-urile WebSocket de serverele de simulare, shard-uieste pe camera/instanta, foloseste un broker pub/sub (NATS sau Redis) pentru fan-out, aplica QoS per tip de mesaj (fiabil vs best-effort), instrumenteaza backpressure si fa capacity planning pe numar de conexiuni, bytes/sec si tick rate. Evita state-ul lipit de gateway si proiecteaza pentru reconectari in masa.
Modelul de trafic in jocuri realtime
Inainte de arhitectura, clarifica ce tipuri de pachete si garantii ai nevoie.
-
Tipuri de mesaje uzuale:
- Input de la client: butoane, vectori de directie, comenzi (mic payload, 5–60/s/player in jocuri rapide).
- Update de stare de la server (snapshot sau delta) catre client (mediu payload, 10–30/s).
- Evenimente fiabile: matchmaking, login, item rewards (rare, dar trebuie livrate 100%).
- Presence/typing/lobby (joase, dar multi subscriberi).
-
QoS recomandat:
- Best-effort neordonat pentru update-uri de stare (drop daca ramane in urma).
- Ordionat pentru input-uri per player (poate fi coalesced, dar nu reordonat).
- Fiabil si idempotent pentru evenimente critice (ack/retry, dedup).
-
Bugete sanatoase (reguli de bun simt, nu dogma):
- Tick server: 10–30 Hz pentru actiune, 5–10 Hz pentru lobby/social.
- Buget per client: 5–20 KB/s downstream si 1–5 KB/s upstream, compresie binara (Protobuf/FlatBuffers) obligatorie la scara pe mobil.
- Per nod gateway: planifica pe conexiuni simultane vs memorie (ex. 20–60k conexiuni/nod, in functie de limbaj si buffers), nu pe CPU.
Nimeni nu-si aminteste schema frumoasa dupa primul spike de 50k reconectari simultane — asa ca penseaza din start reconectari, backpressure si failover.
Arhitectura recomandata end-to-end
Obiectiv: sa poti adauga gateway-uri si sharduri de simulare fara sa repornesti tot si fara sticky state la margine.
Componente si flux
- Edge + TLS termination: Cloudflare (Spectrum/Proxied) sau Nginx/HAProxy + certmanager. Face upgrade la WebSocket si protejeaza de DoS volumetric.
- Gateway-uri WebSocket (stateless): autentificare token, rate limiting, mapare
connectionId -> playerId, routing la room via broker. Nu tin state de joc. - Broker pub/sub: NATS (JetStream pentru persistenta optionala) sau Redis (Streams/PubSub). Canal per camera/instanta.
- Servere de simulare (stateful, authoritative): procese care ruleaza starea jocului pentru un set de camere (shard). Produc update-uri catre canalele camerelor.
- Servicii auxiliare: matchmaking, lobby, presence, inventory. Persistenta in Postgres; cache in Redis; analytics in ClickHouse; media in S3 compatibil.
- Observabilitate: OpenTelemetry + Prometheus + Grafana + Loki. Sampling de traces pe rute cu latenta.
Flux tipic:
- Clientul face handshake TLS, upgrade la WS in edge.
- Gateway verifica JWT/nonce, ataseaza
playerId, intra in room (subscribe pe canalul camerei in broker). - Input-urile clientului sunt publicate pe
room:{id}:inputscatre shardul de simulare corespunzator. - Shardul simuleaza la fiecare tick si publica delta/snapshot pe
room:{id}:updates. - Gateway face fan-out catre toti clientii din camera, cu QoS per mesaj si backpressure per conexiune.
De ce nu tinem state in gateway
- Scale-out simplu: adaugi noduri fara migrare de state.
- Failover predictibil: reconectarile refac doar subscriberi la canale.
- Latenta controlata: simularile sunt apropiate de sursa datelor (poate in acelasi AZ sau chiar aceeași masina virtuala cu brokerul).
Exemplu de implementare minimalista (Node.js + ws + NATS)
// gateway.ts — gateway WebSocket cu backpressure pe conexiune si QoS simplu
import { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
import { connect, StringCodec } from 'nats';
const wss = new WebSocketServer({ port: 8080 });
const sc = StringCodec();
const HIGH_WATERMARK = 512 * 1024; // 512KB bufferedAmount — prag pentru drop pe best-effort
(async () => {
const nc = await connect({ servers: 'nats:4222' });
// mapari simple in memorie — in productie foloseste un registry partajat sau consistent hashing
const roomSubs = new Map<string, Set<any>>();
function joinRoom(ws: any, roomId: string) {
let set = roomSubs.get(roomId);
if (!set) {
set = new Set();
roomSubs.set(roomId, set);
// subscribe la updates ale camerei si fan-out la clienti
const sub = nc.subscribe(`room.${roomId}.updates`);
(async () => {
for await (const m of sub) {
const payload = m.data; // binar sau text
for (const client of set!) {
// QoS: best-effort pentru updates
if (client.readyState === 1) {
if (client.bufferedAmount < HIGH_WATERMARK) {
client.send(payload, { binary: true });
} else {
// drop frame — clientul e in urma, asteptam urmatorul tick
}
}
}
}
})();
}
set.add(ws);
ws.on('close', () => set!.delete(ws));
}
wss.on('connection', (ws, req) => {
const token = new URL(req.url!, 'http://x').searchParams.get('token');
let playerId = '';
try {
const decoded: any = jwt.verify(token || '', process.env.JWT_SECRET!);
playerId = decoded.sub;
} catch {
ws.close(4001, 'auth_failed');
return;
}
ws.on('message', (data: Buffer) => {
// Format: { t: 'join'|'input', roomId, payload }
const msg = JSON.parse(data.toString());
if (msg.t === 'join') {
joinRoom(ws, msg.roomId);
} else if (msg.t === 'input') {
// Ordine per player: foloseste subject distinct, shard-ul le ordoneaza
nc.publish(`room.${msg.roomId}.inputs.${playerId}`, sc.encode(JSON.stringify(msg.payload)));
}
});
// keepalive pentru NAT-uri care taie conexiunea
const ping = setInterval(() => {
if (ws.readyState === 1) ws.ping();
}, 25000);
ws.on('close', () => clearInterval(ping));
});
})();
Server de simulare (pseudo): proceseaza input-urile ordonate per player (folosind room.{id}.inputs.*), aplica logica si publica delta pe room.{id}.updates. Pentru entitati, trimite doar campurile schimbate per tick.
// sim.ts — schelet; in productie foloseste Protobuf/FlatBuffers si timp fix
import { connect, StringCodec } from 'nats';
const sc = StringCodec();
(async () => {
const roomId = process.env.ROOM_ID!;
const nc = await connect({ servers: 'nats:4222' });
const state = { players: new Map<string, any>(), tick: 0 };
const sub = nc.subscribe(`room.${roomId}.inputs.>`); // wildcard pe toti playerii
(async () => {
for await (const m of sub) {
const playerId = m.subject.split('.').pop()!;
const input = JSON.parse(sc.decode(m.data));
// coalesc input-urile — pastreaza doar ultimul vector per tick
state.players.get(playerId)!.pendingInput = input;
}
})();
setInterval(() => {
state.tick++;
// aplica input-urile si avanseaza simularile
for (const [pid, p] of state.players) {
if (p.pendingInput) {
// update position/velocity etc.
p.pos.x += p.pendingInput.dx * 0.016;
p.pos.y += p.pendingInput.dy * 0.016;
p.pendingInput = null;
}
}
// calculeaza delta minim (exemplu simplu)
const delta = { tick: state.tick, players: [...state.players].map(([id, p]) => ({ id, pos: p.pos })) };
nc.publish(`room.${roomId}.updates`, Buffer.from(JSON.stringify(delta)));
}, 1000 / 20); // 20Hz
})();
Observabilitate si SLO-uri
- SLI-uri minime: succes handshake (>99.9%), latenta mediana pe mesaj sub 50–100ms (in zona), rata de drop pe best-effort < 10% pe 1m.
- Dashboards: conexiuni active per gateway,
bufferedAmountpercentila 95, messages/sec per room, GC pause (Node/Go), CPU softirq. - Tracing: lega
connectionId,roomIdsitickpentru cauzare lenta.
Sharding si rutare: sticky vs brokered
Doua modele dominante pentru a ajunge de la nodul in care clientul este conectat la nodul unde se simuleaza camera:
| Abordare | Avantaje | Dezavantaje | Cand o folosesti |
|---|---|---|---|
| Sticky session + direct (gateway == simulare) | Latența minima, fara broker, simplu la inceput | Rebalansare grea, failover dureros, scaling limitat pe memorie | Joc mic/mediu, o regiune, fara risc de reconectari in masa |
| Stateless gateway + broker (NATS/Redis) | Scale-out lin, failover si reconectari previzibile, izolare a simularii | Cost si complexitate in plus, broker tuning | Joc multi-regiune, crestere rapida, cross-room features |
In experienta noastra ca builderi, modelul brokered castiga pe termen mediu pentru ca separa clar responsabilitatile. Pentru rutare inter-shard:
- Cheie de sharding:
roomIdprin consistent hashing (ex. JumpHash) pe un pool de shard-uri. - Registry: etcd/Consul sau propriul directory in Postgres unde scrii
roomId -> shardAddr. - Rebalansare: muta camerele reci intai; migrare live = snapshot + delta buffer + cutover sub 1s.
Global vs regional
- Ruleaza simularile cat mai aproape de jucatori (per regiune).
- Gateway-urile pot fi la edge in toata lumea; daca jucatorul intra intr-o camera din alta regiune, reconecteaza-l explicit (redirect semnalat din handshake) pentru a evita trombonarea pe WAN.
Backpressure, QoS si compresie
Backpressure nu e optional in WebSocket. Browserul si retelele mobile au buffers mici si latenta variabila.
- Per conexiune: verifica
bufferedAmountsi dropeaza update-urile best-effort cand treci pragul. Pastreaza evenimentele fiabile in cozi separate cu retry si ack. - Coalescing: comprima mai multe update-uri intr-un singur frame (per tick) si elimina entitatile neschimbate.
- Delta + snapshot periodic: trimite snapshot complet la fiecare N secunde si doar delte intre ele; ajuta mai ales clientii care se reconecteaza dupa pierderi.
- Codec-uri binare: Protobuf/FlatBuffers/MessagePack in loc de JSON. Eviti overhead si GC.
- Per-room rate limiting: daca o camera explodeaza in evenimente, protejeaza restul jocului prin caps pe messages/sec.
Exemplu simplu de pachetare delta vs snapshot:
type Entity = { id: number; x: number; y: number; hp: number };
function computeDelta(prev: Map<number, Entity>, next: Map<number, Entity>) {
const changes: any[] = [];
for (const [id, e2] of next) {
const e1 = prev.get(id);
if (!e1) { changes.push({ id, full: e2 }); continue; }
const patch: any = { id };
if (e1.x !== e2.x) patch.x = e2.x;
if (e1.y !== e2.y) patch.y = e2.y;
if (e1.hp !== e2.hp) patch.hp = e2.hp;
if (Object.keys(patch).length > 1) changes.push(patch);
}
return { t: 'delta', changes };
}
Ce se strica in productie
- Reconectari in valuri la schimbari de retea (mobile) sau deploy. Solutie: backoff exponential cu jitter la client, grace windows pentru reluarea sesiunii (resend last inputs), si rate-limits pe handshake la gateway.
- Memory bloat in gateway: buffers necuratate pe conexiuni lente. Solutie: praguri stricte pe
bufferedAmount, timeouts, si cozi limitate per tip de mesaj. - GC pauses (Node) sau fragmentare (C++). Solutie: binarizare payload, eviti alocari per mesaj, pool-uri de buffere.
- Broker ne-tuninguit: NATS fara limite pe retentii JetStream, Redis PubSub fara izolarea canalelor hot. Solutie: canale per room, partiionare (shards) a brokerului, cap pe history, metri pe messages/sec si latenta subscriptiilor.
- Kernel defaults:
somaxconnmic,ulimit -nmic, TIME_WAIT-uri multe. Solutie: tuning minim:
# sysctl.conf
net.core.somaxconn = 65535
net.ipv4.ip_local_port_range = 10240 65000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 15
net.ipv4.tcp_keepalive_probes = 4
# limits.conf
* soft nofile 200000
* hard nofile 200000
- TLS renegotiation si certificate expirate. Solutie: automatizare (LetsEncrypt + cert-manager) si roll over inainte de expirare.
- DDoS la layer 7 (spam WS upgrades). Solutie: WAF/edge rules, proof-of-work light pe handshake sau token pre-obtinut pe HTTPS.
- De-sincronizare de versiuni client-server. Solutie: versionare protocol cu fallback clar si circuit breaker la mismatch.
Pentru scenarii unde doar streaming unidirectional conteaza (ex. spectatori), SSE poate fi o alternativa mai simpla; am scris despre limite si reluare in ghidul de SSE reanudabile.
Costuri, buget si ROI
Nu exista pret unic; costul depinde de conexiuni simultane, bytes/sec si zone.
-
Modele de cost:
- IaaS clasic (EC2/VMs + NLB/ALB): cost predictibil pe noduri. Bun cand vrei control pe kernel si tuning.
- Edge/Workers (Cloudflare Workers/Durable Objects): latenta excelenta la margine, dar cost per request/event; potrivit pentru presence/lobby si jocuri simple.
- Serverless WS (API Gateway WebSockets, AppSync): management simplu, dar cost per mesaj/conexiune poate creste repede la volum ridicat.
-
Linii bugetare de gandit:
- Gateway-uri: in limbaje eficiente (Go/Elixir), poti rula zeci de mii de conexiuni/nod. Estimeaza 100–300MB RAM la 10k conexiuni + buffers aplicatie.
- Broker: NATS cu JetStream cere SSD si networking bun; Redis necesita memorie si poate sharda pe mai multe noduri.
- Observabilitate: 10–20% extra din factura compute pentru logs/metrics/traces nu este neobisnuit.
Comparatie sumara (tendinte, nu preturi contractuale):
| Optiune | Latenta | Operare | Cost la volum mare | Cazuri potrivite |
|---|---|---|---|---|
| EC2/VMs + NLB + NATS | Foarte buna in AZ | Medie (tuning necesar) | Eficient | Jocuri cu trafic constant/mare |
| Cloudflare Workers + Durable Objects | Excelenta la edge | Redusa (managed) | Poate fi mai mare per event | Presence/lobby, mini-jocuri |
| API Gateway WebSockets | Buna | Redusa | Creste rapid per mesaj/conexiune | Protocoale simple, volum mic/mediu |
ROI vine din retentia jucatorilor si simplitatea operarii. A investi timp in QoS si backpressure plateste prin scaderea crash-urilor client si a ticketelor suport. Pentru modele in care nu trebuie WS bidirectional pentru tot, combina cu SSE/HTTP pentru evenimente rare — cost per livrare mai mic, complexitate mai redusa (vezi si pattern-urile serverless la scara).
Testare si readiness la scara
- Load test realist: k6 are modul WS; simuleaza reconectari in valuri si retele mobile (tc/netem: latency, jitter, loss).
- Chaos: omoara shard-uri si brokeri in staging, verifica reintregirea si pierderi acceptabile.
- Drill pe deploy: canary pentru 1% conexiuni, apoi 10%, apoi tot.
- Error budgets: defineste SLO si nu lansa features care-l consuma; pune rate-limits la margine.
Securitate si anti-cheat (nivel infrastructura)
- Auth pe handshake cu JWT scurt, re-issuance pe reconectare.
- Semnaturi per input si limitare la N input-uri per secunda.
- Izolarea camerelor in canale dedicate (nu broadcast global), evita amplificarea.
- Observa tipare anormale (multi-join/leave, spikes pe
bufferedAmount) pentru potential botting.
Cand sa alegi altceva decat WebSocket
- Doar downstream, fara input: SSE este suficient si mai simplu de operat.
- Voice/video: WebRTC, nu WS (codare, NAT traversal, QoS media).
- State foarte mare si rar schimbat: polling/HTTP conditional poate fi mai ieftin.
FAQ
Ce tick rate sa aleg pentru jocul meu?
Alege in functie de mecanica. 20 Hz este un compromis bun pentru multiplayer action pe mobil. Pentru lobby/chat, 5–10 Hz ajunge. Mai sus de 30 Hz creste costul si sensibilitatea la jitter.
WebSocket sau UDP/WebRTC pentru jocuri rapide?
Pentru input-uri si stari usor tolerante la pierderi, UDP/WebRTC DataChannels ofera latenta si control superior. WebSocket este suficient pentru multe jocuri casual/strategie, dar nu pentru shooters ultra-rapide.
Cum asigur ordonarea mesajelor?
Ordine per key (playerId, roomId). La broker, foloseste subiecte separate sau partitii. In gateway nu reordona; in shard aplica ordonarea si ignora input-uri vechi pe baza de seq/tick.
Cate conexiuni poate duce un nod?
Depinde de limbaj, buffers si kernel. Practic, planifica 20–60k conexiuni/nod pentru gateway in Go/Elixir, mai putine in Node daca payload-ul e JSON. Masoara, nu ghici.
Ce broker sa aleg: Redis sau NATS?
Redis e simplu si rapid pentru fan-out in memorie; NATS ofera subiecte bogate, wildcard-uri si JetStream pentru persistenta/ack. Daca ai nevoie de replay/garantii, NATS e mai potrivit; pentru pur best-effort, Redis e suficient.
Cum tratez clientii lenti?
Impune prag pe bufferedAmount, dropeaza update-uri best-effort, trimite snapshot periodic, si deconecteaza dupa un timeout daca raman constant in urma pentru a proteja restul jucatorilor.
Key takeaways
- Separarea gateway-urilor WebSocket de simulari si brokerizarea traficului fac scalarea previzibila.
- Backpressure per conexiune si QoS per tip de mesaj sunt esentiale; fara ele, vei bloca memorie si vei creste latenta tuturor.
- Sharding pe camera cu consistent hashing simplifica echilibrarea; planifica migrare live cu snapshot + delta.
- Optimizeaza payload-ul: binar, delta, coalescing; JSON neoptimizat te costa memorie si CPU.
- Tuningul kernel si observabilitate reala (SLI/SLO) sunt la fel de importante ca schema de arhitectura.
- Alege modelul de cost/operator potrivit volumului; nu orice joc are nevoie de edge workers peste tot.
Daca construiesti un joc sau o platforma cu trafic realtime si ai nevoie de o arhitectura WebSocket care scaleaza fara surprize, scrie-ne — la MTBYTE proiectam, implementam si operam astfel de sisteme. Detalii pe /contact.
URMATORUL PAS
Ti-a placut abordarea?
Aplicam aceleasi principii in proiectele clientilor: AI, automatizari, produse care nu se sting dupa lansare.