Pozorovatelnost Backend hry: Latence, Tickrate a Player Experience
Herní backend může být na papíře technicky dokonalý – distribuovaná architektura, škálování automatická, vícezónová replikace – a zároveň být katastrofou pro hráče. Latence 300 ms špičky, které trvají 2 sekundy, tickrate, který klesne ze 128 na 64 při špičkové zátěži, zóna server, který nedokončí zápasy po dobu 20 minut: tyto problémy existují, ale bez Neuvidíte ty správné nástroje, dokud vás hráči nezaplaví negativními tweety.
L'pozorovatelnost v herním poli to není jen aplikace Prometheus a Grafana na jakýkoli server. Vyžaduje hluboké pochopení metrik specifických pro doménu: co dělá a degradovaný tikrat pro herní zážitek, jak to souvisí s P99 percentilní latence s mírou odchodu zápasů, protože skóre hráčských zkušeností (PES) je nejdůležitější metrikou ze všech.
V tomto článku vytvoříme kompletní systém pozorovatelnosti pro herní backendy ze zásobníku technické (Prometheus, Grafana, OpenTelemetry, Loki) až po metriky specifické pro hry, až SLO, které korelují technický výkon se zkušenostmi hráče.
Co se naučíte
- Metriky specifické pro hry: tickrate, latence, ztráta paketů, využití serveru
- Pozorovatelnost zásobníku: Prometheus, Grafana, OpenTelemetry, Loki, Jaeger
- Instrumentace herního serveru Go s vlastními metrikami
- Panel Grafana pro herní backend: teplotní mapa latence, tickrate, aktivní zápasy
- Inteligentní upozornění: založené na SLO vs. na prahu
- Distribuované trasování pro ladění problémů životního cyklu zápasů
- Skóre hráčských zkušeností (PES): Složená metrika pro QoE
- Korelace technického výkonu s obchodními metrikami (udržení, opuštění)
1. Metriky specifické pro hry
Jsou nezbytné standardní metriky webového backendu (latence HTTP, propustnost RPS, chybovost). ale na backend hry to nestačí. Existují metriky, které mají smysl pouze v herním kontextu:
Metriky backendu hry: Kompletní taxonomie
| Kategorie | Metrický | Jednotka | Cíl | Dopad |
|---|---|---|---|---|
| vytváření sítí | Doba zpáteční cesty (RTT) | ms | < 80 ms | Responzivní hratelnost |
| vytváření sítí | Míra ztráty paketů | % | < 0,1 % | Teleportace, gumování |
| vytváření sítí | Jitter | ms | < 20 ms | Nepravidelná interpolace |
| Herní smyčky | Tickrate serveru | klíště/s | Cíl +/-5 % | Herní přesnost |
| Herní smyčky | Zaškrtněte čas zpracování | ms | < tečka_období | Pokud projde: hromadění hry |
| Herní smyčky | Latence státního vysílání | ms | < 50 ms | "Zastaralý" stav pro klienty |
| Zápas | Doba trvání zápasu | s | Pro herní režim | Rovnováha, zábavný faktor |
| Zápas | Míra opuštění | % | < 5 % | Uživatelská frustrace |
| Zápas | Čas dohazování | s | < 30s | Předzápasové angažmá |
| Hráč | Souběžní hráči (CCU) | počítat | Plánování kapacit | Dimenzování infrastruktury |
2. Vybavení herního serveru v Go
Herní server musí vystavit metriky Prometheus na vyhrazeném koncovém bodu HTTP. V Go, knihovna
prometheus/client_golang a de facto standard. Zde implementujeme metriky
kritické: tickrate, latence na hráče a stav aktivních zápasů.
// metrics/game_metrics.go - Definizione metriche Prometheus
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// Tickrate: quanti tick/secondo sta facendo effettivamente il server
// Idealmente coincide con il target (es. 64 o 128 tick/s)
ServerTickRate = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "gameserver",
Subsystem: "loop",
Name: "tickrate_hz",
Help: "Actual server tickrate in Hz",
}, []string{"match_id", "server_id", "region"})
// Tick processing time: quanto tempo impiega un singolo tick
// Se supera il tick period (es. 15.6ms per 64Hz), il loop accumula ritardo
TickProcessingTime = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "gameserver",
Subsystem: "loop",
Name: "tick_processing_seconds",
Help: "Time to process a single game tick",
// Bucket granulari per rilevare hickup
Buckets: []float64{0.001, 0.005, 0.010, 0.015, 0.020, 0.025, 0.050, 0.100},
}, []string{"match_id", "server_id"})
// RTT per player: latenza round-trip misurata lato server (ping-pong)
PlayerRTT = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "gameserver",
Subsystem: "network",
Name: "player_rtt_milliseconds",
Help: "Per-player round-trip time in milliseconds",
Buckets: []float64{10, 20, 40, 60, 80, 100, 150, 200, 300, 500},
}, []string{"player_id", "region", "platform"})
// Packet loss: % pacchetti persi per connessione player
PlayerPacketLoss = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "gameserver",
Subsystem: "network",
Name: "player_packet_loss_ratio",
Help: "Per-player packet loss ratio (0.0-1.0)",
}, []string{"player_id", "region"})
// Match attivi per regione
ActiveMatches = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "gameserver",
Subsystem: "match",
Name: "active_count",
Help: "Number of active game matches",
}, []string{"region", "mode"})
// Match abandonati (contatore totale)
MatchAbandonment = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "gameserver",
Subsystem: "match",
Name: "abandonment_total",
Help: "Total match abandonments",
}, []string{"region", "mode", "reason"})
// Matchmaking queue depth e wait time
MatchmakingQueueDepth = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "gameserver",
Subsystem: "matchmaking",
Name: "queue_depth",
Help: "Number of players waiting in matchmaking",
}, []string{"region", "mode"})
MatchmakingWaitTime = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "gameserver",
Subsystem: "matchmaking",
Name: "wait_seconds",
Help: "Time players wait in matchmaking queue",
Buckets: []float64{5, 10, 15, 20, 30, 45, 60, 120, 300},
}, []string{"region", "mode"})
)
// game_loop.go - Game loop con instrumentazione metriche
package gameserver
import (
"context"
"time"
"gameserver/metrics"
)
type GameLoop struct {
matchID string
serverID string
region string
tickRate int // target: 64 o 128
tickPeriod time.Duration
state *GameState
}
func NewGameLoop(matchID, serverID, region string, tickRate int) *GameLoop {
return &GameLoop{
matchID: matchID,
serverID: serverID,
region: region,
tickRate: tickRate,
tickPeriod: time.Second / time.Duration(tickRate),
}
}
func (g *GameLoop) Run(ctx context.Context) error {
ticker := time.NewTicker(g.tickPeriod)
defer ticker.Stop()
var tickCount int64
loopStart := time.Now()
for {
select {
case <-ctx.Done():
return nil
case tickTime := <-ticker.C:
tickStart := time.Now()
// Processa il tick del game loop
g.processTick(tickTime)
// Misura quanto e durato questo tick
tickDuration := time.Since(tickStart)
metrics.TickProcessingTime.WithLabelValues(
g.matchID, g.serverID,
).Observe(tickDuration.Seconds())
// Calcola tickrate effettivo ogni secondo
tickCount++
elapsed := time.Since(loopStart).Seconds()
if elapsed >= 1.0 {
actualTickRate := float64(tickCount) / elapsed
metrics.ServerTickRate.WithLabelValues(
g.matchID, g.serverID, g.region,
).Set(actualTickRate)
// Warn se tickrate e degradato di più del 10%
expectedMin := float64(g.tickRate) * 0.90
if actualTickRate < expectedMin {
// Questo verrà catchato dall'alerting Prometheus
log.Warnf("Tickrate degraded: %.1f Hz (target %d)",
actualTickRate, g.tickRate)
}
tickCount = 0
loopStart = time.Now()
}
}
}
}
// Misura RTT per ogni player durante ogni tick
func (g *GameLoop) measurePlayerNetworkStats(players []Player) {
for _, p := range players {
stats := p.GetNetworkStats() // Ottieni stats dalla connessione
metrics.PlayerRTT.WithLabelValues(
p.UserID, p.Region, p.Platform,
).Observe(float64(stats.RTTMs))
metrics.PlayerPacketLoss.WithLabelValues(
p.UserID, p.Region,
).Set(stats.PacketLossRatio)
}
}
3. Grafana Dashboard pro Game Backend
Dobrý backendový panel hry neukazuje náhodné metriky: zobrazuje metriky v kontextu správně, s vizuálními korelacemi, které vám pomohou rychle pochopit, zda je problém a kde. Zde jsou nejdůležitější panely, které je třeba zahrnout.
// Grafana dashboard configuration (JSON excerpt)
// Pannello 1: Latency Heatmap per tutte le regioni
{
"type": "heatmap",
"title": "Player RTT Distribution (all regions)",
"targets": [{
"expr": "sum(rate(gameserver_network_player_rtt_milliseconds_bucket[5m])) by (le)",
"legendFormat": "{{le}} ms",
"format": "heatmap"
}],
"color": {
"scheme": "RdYlGn", // Verde = bassa latenza, Rosso = alta
"reverse": true
},
"yAxis": { "unit": "ms" }
}
// Pannello 2: Tickrate per server (target line)
{
"type": "timeseries",
"title": "Server Tickrate by Match",
"targets": [{
"expr": "gameserver_loop_tickrate_hz",
"legendFormat": "Match {{match_id}} - {{region}}"
}],
"thresholds": [
{ "value": 58, "color": "yellow" }, // Warning: <91% di 64Hz
{ "value": 50, "color": "red" } // Critical: <78% di 64Hz
},
"fieldConfig": {
"defaults": {
"custom": {
"lineWidth": 2,
"fillOpacity": 10
}
}
}
}
// Pannello 3: Match Abandonment Rate (correlato con latenza)
{
"type": "stat",
"title": "Match Abandonment Rate (1h)",
"targets": [{
"expr": "rate(gameserver_match_abandonment_total[1h]) / rate(gameserver_match_total[1h]) * 100"
}],
"thresholds": [
{ "value": 3, "color": "yellow" },
{ "value": 7, "color": "red" }
},
"fieldConfig": {
"defaults": { "unit": "percent" }
}
}
// Pannello 4: Matchmaking Wait Time p95
{
"type": "timeseries",
"title": "Matchmaking Wait Time p50/p95/p99",
"targets": [
{
"expr": "histogram_quantile(0.50, rate(gameserver_matchmaking_wait_seconds_bucket[5m]))",
"legendFormat": "p50"
},
{
"expr": "histogram_quantile(0.95, rate(gameserver_matchmaking_wait_seconds_bucket[5m]))",
"legendFormat": "p95"
},
{
"expr": "histogram_quantile(0.99, rate(gameserver_matchmaking_wait_seconds_bucket[5m]))",
"legendFormat": "p99"
}
]
}
4. Upozornění na základě SLO: Za pevnými prahy
Upozornění založená na pevných prahových hodnotách (např. „latence > 100 ms“) vytvářejí příliš mnoho falešných poplachů nebo příliš mnoho falešných negativů. Herní backendy mají proměnlivou povahu: latence v noci a mnohem více nižší než ve špičce. The Upozornění na základě SLO (Cíl úrovně služeb) opatření procento času, kdy služba plní své cíle a generuje pouze upozornění když chybový rozpočet se chystá vyčerpat.
# Prometheus: definizione SLO e alerting rules
# File: prometheus/rules/game_slo.yaml
groups:
- name: game_backend_slos
rules:
# SLO 1: 99.5% dei player devono avere RTT < 100ms
- record: job:gameserver_rtt_slo:ratio_rate5m
expr: |
sum(rate(gameserver_network_player_rtt_milliseconds_bucket{le="100"}[5m]))
/
sum(rate(gameserver_network_player_rtt_milliseconds_count[5m]))
# Alert se RTT SLO < 99.5% (error budget a rischio)
- alert: GameRTTSLOBreach
expr: job:gameserver_rtt_slo:ratio_rate5m < 0.995
for: 2m
labels:
severity: warning
team: game-backend
annotations:
summary: "RTT SLO breach: {{ $value | humanizePercentage }} compliance"
description: |
Only {{ $value | humanizePercentage }} of players have RTT < 100ms.
SLO target: 99.5%. Error budget is being consumed.
# SLO 2: Tickrate deve essere >= 95% del target per 99% del tempo
- alert: GameTickRateDegraded
expr: |
(gameserver_loop_tickrate_hz / on(match_id) gameserver_loop_target_tickrate_hz)
< 0.90
for: 30s
labels:
severity: critical
team: game-backend
annotations:
summary: "Tickrate degraded on match {{ $labels.match_id }}"
description: |
Server {{ $labels.server_id }} tickrate is {{ $value | humanize }} Hz,
below 90% of target. Players experience visible gameplay degradation.
# SLO 3: Match abandonment rate < 5%
- alert: HighMatchAbandonmentRate
expr: |
rate(gameserver_match_abandonment_total[15m])
/
rate(gameserver_match_start_total[15m])
> 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "High match abandonment in region {{ $labels.region }}"
description: |
Abandonment rate is {{ $value | humanizePercentage }} in the last 15 minutes.
Investigate latency or matchmaking quality in this region.
# Alert: Matchmaking Queue stagnante (possibile bug)
- alert: MatchmakingQueueStagnant
expr: |
gameserver_matchmaking_queue_depth > 50
AND
rate(gameserver_match_start_total[5m]) == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Matchmaking queue stagnant in {{ $labels.region }}"
description: |
{{ $value }} players waiting, zero matches started in 5 minutes.
Possible matchmaker crash or configuration issue.
5. Distribuované trasování pomocí OpenTelemetry
Distribuované trasování je nezbytné pro ladění složitých problémů v životním cyklu shody: proč požadavek na dohazování trvá 8 sekund místo 2, jakou komponentu zavádí latence v kritické cestě herní smyčky. OpenTelemetry (OTEL) se stala standardem s otevřeným zdrojovým kódem pro trasování, s exportem do Jaeger nebo Tempo (Grafana).
// otel_setup.go - Configurazione OpenTelemetry per game server
package tracing
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
func InitTracer(ctx context.Context, serviceName, version string) (func(), error) {
// Esporta trace a Jaeger/Tempo via OTLP gRPC
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("otel-collector:4317"),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("failed to create OTLP exporter: %w", err)
}
res := resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(version),
attribute.String("environment", "production"),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
// Sample al 10% per ridurre volume (campiona tutti gli errori)
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),
)
otel.SetTracerProvider(tp)
return func() { tp.Shutdown(context.Background()) }, nil
}
// matchmaker.go - Tracing del matchmaking flow
func (m *Matchmaker) FindMatch(ctx context.Context, ticket MatchTicket) (*Match, error) {
tracer := otel.Tracer("matchmaker")
ctx, span := tracer.Start(ctx, "matchmaker.FindMatch")
defer span.End()
span.SetAttributes(
attribute.String("ticket.id", ticket.ID),
attribute.String("ticket.mode", ticket.Mode),
attribute.Float64("ticket.mmr", ticket.MMR),
attribute.String("ticket.region", ticket.Region),
)
// Fase 1: Recupera giocatori compatibili dal pool
ctx, poolSpan := tracer.Start(ctx, "matchmaker.FetchPool")
pool, err := m.fetchCompatiblePool(ctx, ticket)
poolSpan.SetAttributes(attribute.Int("pool.size", len(pool)))
poolSpan.End()
if err != nil {
span.RecordError(err)
return nil, err
}
// Fase 2: Algoritmo di matching
ctx, algoSpan := tracer.Start(ctx, "matchmaker.RunAlgorithm")
match, err := m.runGlicko2Algorithm(ctx, ticket, pool)
algoSpan.SetAttributes(
attribute.Int("candidates.evaluated", len(pool)),
attribute.Bool("match.found", match != nil),
)
algoSpan.End()
if match != nil {
span.SetAttributes(attribute.String("match.id", match.ID))
}
return match, err
}
6. Skóre hráčských zkušeností (PES): Metrika, na které záleží
Il Skóre hráčských zkušeností a složená metrika, která agreguje více technických signálů do jediné hodnoty (0-100), která představuje kvalitu zážitku z hlediska hráč. Již nesleduje samostatné metriky – sleduje konečný výsledek těchto metrik produkovat na herní zážitek.
-- ClickHouse: calcolo Player Experience Score per match
-- Eseguito in real-time ogni 60 secondi per match attivi
CREATE VIEW game_analytics.match_pes AS
WITH pes_components AS (
SELECT
match_id,
server_id,
region,
toStartOfMinute(server_ts) AS minute,
-- Componente 1: RTT Score (0-100)
-- RTT < 40ms = 100, RTT 40-80ms = lineare, RTT > 150ms = 0
avg(
multiIf(
toFloat64OrZero(payload['rtt_ms']) <= 40, 100,
toFloat64OrZero(payload['rtt_ms']) <= 80,
100 - (toFloat64OrZero(payload['rtt_ms']) - 40) * 1.5,
toFloat64OrZero(payload['rtt_ms']) <= 150,
40 - (toFloat64OrZero(payload['rtt_ms']) - 80) * 0.57,
0
)
) AS rtt_score,
-- Componente 2: Tickrate Score (0-100)
-- Tickrate >= 95% target = 100, < 70% = 0
avg(
multiIf(
toFloat64OrZero(payload['actual_tickrate']) /
toFloat64OrZero(payload['target_tickrate']) >= 0.95, 100,
toFloat64OrZero(payload['actual_tickrate']) /
toFloat64OrZero(payload['target_tickrate']) >= 0.70,
(toFloat64OrZero(payload['actual_tickrate']) /
toFloat64OrZero(payload['target_tickrate']) - 0.70) * 400,
0
)
) AS tickrate_score,
-- Componente 3: Packet Loss Score (0-100)
-- 0% loss = 100, 1% = 50, > 2% = 0
avg(
multiIf(
toFloat64OrZero(payload['packet_loss_pct']) <= 0, 100,
toFloat64OrZero(payload['packet_loss_pct']) <= 2,
100 - toFloat64OrZero(payload['packet_loss_pct']) * 50,
0
)
) AS packet_loss_score,
count() AS sample_count
FROM game_analytics.events_all
WHERE event_type = 'system.server_stats'
AND server_ts >= now() - INTERVAL 5 MINUTE
GROUP BY match_id, server_id, region, minute
)
SELECT
match_id,
server_id,
region,
minute,
-- PES: media pesata dei componenti
-- RTT ha peso maggiore perchè e la più percepita dai giocatori
round(
rtt_score * 0.45 +
tickrate_score * 0.35 +
packet_loss_score * 0.20,
1
) AS pes,
rtt_score,
tickrate_score,
packet_loss_score
FROM pes_components
ORDER BY minute DESC;
Výklad PES
| Rozsah PES | Klasifikace | Očekávaný dopad | Akce |
|---|---|---|---|
| 90-100 | Vynikající | Opuštění < 2 % | Žádný |
| 75-89 | Dobrý | Opuštění 2–5 % | Sledování |
| 60-74 | Přijatelný | Opuštění 5–10 % | Vyšetřování |
| 40-59 | Degradovaný | Opuštění 10–20 % | Upozornění + zásah |
| 0-39 | Kritik | Opuštění > 20 % | Vraťte zpět nebo migrujte |
7. Agregace protokolů s Loki: Strukturované protokolování
Logování herního serveru musí být strukturovaný (JSON) a související s le
metriky přes match_id, server_id e trace_id. Loki
(Grafana) vám umožňuje prohledávat protokoly podle štítku, aniž byste museli indexovat veškerý obsah (na rozdíl od
Elasticsearch), která je při velkém objemu mnohem levnější.
// logger.go - Structured logging con zap + Loki labels
package logging
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// GameLogger aggiunge automaticamente context fields utili per Loki
type GameLogger struct {
base *zap.Logger
matchID string
}
func NewMatchLogger(matchID, serverID, region string) *GameLogger {
logger, _ := zap.NewProduction()
return &GameLogger{
base: logger.With(
// Questi field diventano Loki labels per il filtering
zap.String("match_id", matchID),
zap.String("server_id", serverID),
zap.String("region", region),
zap.String("service", "game-server"),
),
matchID: matchID,
}
}
// LogMatchEvent: log strutturato per eventi di match
func (l *GameLogger) LogMatchEvent(event string, fields ...zap.Field) {
l.base.Info(event, fields...)
}
// Esempio di utilizzo nel game loop:
// l.LogMatchEvent("player.kill",
// zap.String("attacker", attackerID),
// zap.String("victim", victimID),
// zap.String("weapon", weapon),
// zap.Float64("distance", distance),
// zap.String("trace_id", traceID), // Correlato con OTEL trace
// )
// Loki query per investigare un match specifico:
// {match_id="match_789xyz"} |= "player.kill"
// {region="eu-west"} | json | rtt_ms > 150
// {service="game-server"} | json | level="error" | rate()[5m] > 10
Závěry
Pozorovatelnost herního backendu vyžaduje doménově specifický přístup: stačí použít standardní webové vzory. Metriky specifické pro hry (tickrate, RTT na hráče, ztráta paketů, opuštění shody) musí být kombinovány do složených metrik, jako je např Hráčské zkušenosti Skóre které korelují technický výkon se skutečným chováním hráčů.
Stack Prometheus + Grafana + Loki + Jaeger/Tempo se pro to stal open-source standardem potřeba. Klíč ahluboké instrumentace herního serveru od začátku, ne jako dodatečná myšlenka: herní server bez nástrojů a jako letadlo bez letových přístrojů.
Další kroky v sérii Game Backend
- Předchozí článek: Cloud Gaming: Streamování s WebRTC a Edge Node
- Toto je konec série Game Backend
- Související série: MLOps for Business – modely umělé inteligence ve výrobě
- Související série: DevOps Frontend - CI/CD a monitorování







