Obserwowalność Zaplecze gry: opóźnienie, częstotliwość kliknięć i wrażenia gracza
Backend gry może być technicznie doskonały na papierze - architektura rozproszona, skalowanie automatyczna, wielostrefowa replikacja – a jednocześnie być katastrofą dla graczy. Opóźnienie Skoki 300 ms trwające 2 sekundy, częstotliwość taktowania spada ze 128 do 64 przy obciążeniu szczytowym, strefa serwer, który nie kończy meczów przez 20 minut: te problemy istnieją, ale bez Nie widzisz odpowiednich narzędzi, dopóki gracze nie zasypią Cię negatywnymi tweetami.
L'obserwowalność w dziedzinie gier nie chodzi po prostu o zastosowanie Prometeusza i Grafany do dowolnego serwera. Wymaga głębokiego zrozumienia wskaźników specyficznych dla domeny: co robi A obniżony współczynnik taktowania jeśli chodzi o wrażenia z gry, jaki ma to związek z opóźnienie percentylowe p99 ze współczynnikiem rezygnacji z meczów, ponieważ wynik doświadczenia gracza (PES) jest najważniejszym ze wszystkich wskaźników.
W tym artykule budujemy ze stosu kompletny system obserwowalności backendów gier techniczne (Prometheus, Grafana, OpenTelemetry, Loki) po wskaźniki specyficzne dla gier, aż do SLO, które korelują wydajność techniczną z doświadczeniem gracza.
Czego się nauczysz
- Wskaźniki specyficzne dla gier: częstotliwość odświeżania, opóźnienia, utrata pakietów, wykorzystanie serwera
- Obserwowalność stosu: Prometheus, Grafana, OpenTelemetry, Loki, Jaeger
- Instrumentacja serwera gier Go z niestandardowymi metrykami
- Panel Grafana dla zaplecza gry: mapa cieplna opóźnień, częstotliwość odświeżania, aktywne mecze
- Inteligentne alerty: oparte na SLO a progowe
- Rozproszone śledzenie w celu debugowania problemów z cyklem życia dopasowań
- Wynik doświadczenia gracza (PES): Złożony wskaźnik QoE
- Korelacja wydajności technicznej z metrykami biznesowymi (utrzymanie, porzucenie)
1. Wskaźniki specyficzne dla gier
Niezbędne są standardowe wskaźniki backendu internetowego (opóźnienie HTTP, przepustowość RPS, stopa błędów). ale to za mało na backend gry. Istnieją wskaźniki, które mają sens tylko w kontekście gier:
Metryki zaplecza gry: pełna taksonomia
| Kategoria | Metryczny | Jednostka | Cel | Uderzenie |
|---|---|---|---|---|
| Sieć | Czas podróży w obie strony (RTT) | ms | < 80 ms | Responsywna rozgrywka |
| Sieć | Współczynnik utraty pakietów | % | < 0,1% | Teleportacja, gumka |
| Sieć | Drganie | ms | < 20 ms | Błędna interpolacja |
| Pętle gry | Szybkość serwera | tyk/s | Cel +/-5% | Precyzja rozgrywki |
| Pętle gry | Zaznacz Czas przetwarzania | ms | < okres_ zaznaczenia | Jeśli przejdzie: przeszkoda w grze |
| Pętle gry | Stanowe opóźnienie transmisji | ms | < 50 ms | Stan „nieaktualny” dla klientów |
| Mecz | Czas trwania meczu | s | Dla trybu gry | Równowaga, czynnik zabawy |
| Mecz | Wskaźnik porzucenia | % | < 5% | Frustracja użytkownika |
| Mecz | Czas dobierania partnerów | s | < 30 s | Zaangażowanie przed meczem |
| Odtwarzacz | Współbieżni gracze (CCU) | liczyć | Planowanie wydajności | Rozmiarowanie infrastruktury |
2. Oprzyrządowanie serwera gry w Go
Serwer gry musi udostępniać metryki Prometheusa na dedykowanym punkcie końcowym HTTP. W Go, biblioteka
prometheus/client_golang i de facto standardem. Tutaj wdrażamy metryki
krytyczne: częstotliwość odświeżania, opóźnienie na gracza i status aktywnych meczów.
// 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. Panel Grafana dla zaplecza gry
Dobry panel kontrolny zaplecza gry nie pokazuje przypadkowych wskaźników: pokazuje je w kontekście tak, z korelacjami wizualnymi, które pomogą Ci szybko zrozumieć, czy i gdzie występuje problem. Oto najważniejsze panele, które należy uwzględnić.
// 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. Alerty oparte na SLO: powyżej ustalonych progów
Alerty oparte na stałych progach (np. „opóźnienie > 100 ms”) dają zbyt wiele fałszywych alarmów lub zbyt wiele fałszywych negatywów. Backendy gier mają zmienny charakter: opóźnienia w nocy i wiele więcej niższa niż w godzinach szczytu. The Alerty oparte na SLO (cel poziomu usług). procent czasu, w którym usługa spełnia swoje cele i generuje tylko alerty kiedy budżet błędów zaraz się skończy.
# 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. Śledzenie rozproszone za pomocą OpenTelemetry
Rozproszone śledzenie jest niezbędne do debugowania złożonych problemów w cyklu życia dopasowania: dlaczego prośba o dobranie partnera zajmuje 8 sekund zamiast 2, jaki element wprowadza opóźnienie w ścieżce krytycznej pętli gry. OpenTelemetry (OTEL) stał się standardem open source do śledzenia, z eksportem do Jaeger lub 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. Wynik doświadczenia gracza (PES): Metryka, która ma znaczenie
Il Wynik Doświadczenia Gracza oraz metryka złożona, która agreguje wiele sygnałów technicznych na pojedynczą wartość (0-100), która reprezentuje jakość doświadczenia z punktu widzenia gracz. Nie śledzi już oddzielnych metryk – śledzi wynik końcowy tych metryk produkować na temat wrażeń z gry.
-- 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;
Interpretacja PES
| Seria PES'a | Klasyfikacja | Oczekiwany wpływ | Działanie |
|---|---|---|---|
| 90-100 | Doskonały | Porzucenie < 2% | Nic |
| 75-89 | Dobry | Porzucenie 2-5% | Monitorowanie |
| 60-74 | Do przyjęcia | Porzucenie 5-10% | Dochodzenie |
| 40-59 | Zdegradowany | Porzucenie 10-20% | Alarm + interwencja |
| 0-39 | Krytyk | Porzucenie > 20% | Przywróć lub migruj |
7. Agregacja dzienników za pomocą Lokiego: rejestrowanie strukturalne
Logowanie do serwera gry musi być zbudowany (JSON) i powiązane z le
metryki poprzez match_id, server_id e trace_id. Loki
(Grafana) umożliwia wyszukiwanie logów według etykiety bez konieczności indeksowania całej zawartości (w przeciwieństwie do
Elasticsearch), które są znacznie tańsze przy dużych nakładach.
// 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
Wnioski
Obserwowalność backendu gry wymaga podejścia specyficznego dla domeny: po prostu zastosuj standardowe wzorce sieciowe. Wskaźniki specyficzne dla gier (szybkość kliknięć, RTT na gracza, utrata pakietów, porzucenie meczu) należy połączyć w metryki złożone, takie jak Doświadczenie gracza Wyniki które korelują wydajność techniczną z rzeczywistym zachowaniem graczy.
Stos Prometheus + Grafana + Loki + Jaeger/Tempo stał się w tym przypadku standardem open source potrzeba. Klucz igłębokie instrumentarium serwera gry od początku, nie jak refleksja: serwer gier bez oprzyrządowania i jak samolot bez przyrządów pokładowych.
Kolejne kroki w serii Game Backend
- Poprzedni artykuł: Gry w chmurze: przesyłanie strumieniowe za pomocą WebRTC i węzła brzegowego
- To już koniec serii Game Backend
- Powiązane serie: MLOps dla biznesu – modele AI w produkcji
- Powiązane serie: DevOps Frontend - CI/CD i monitorowanie







