관찰 가능성 게임 백엔드: 지연 시간, Tickrate 및 플레이어 경험
게임 백엔드는 분산 아키텍처, 확장성 등 서류상으로 기술적으로 완벽할 수 있습니다. 자동 다중 영역 복제 - 동시에 플레이어에게는 재앙이 됩니다. 대기 시간 2초 동안 지속되는 300ms 스파이크, 최대 로드 시 128에서 64로 떨어지는 틱 속도, 영역 20분 동안 매치를 완료하지 못한 서버: 이러한 문제가 존재하지만 플레이어들이 부정적인 트윗을 퍼붓기 전까지는 올바른 도구를 볼 수 없습니다.
L'관찰 가능성 게임 분야에서는 단순히 Prometheus와 Grafana를 적용하는 것이 아닙니다. 어떤 서버에든. 도메인별 지표에 대한 깊은 이해가 필요합니다. 무엇을 하는가? 저하된 틱레이트 게임 경험과 어떤 관련이 있나요? p99 백분위수 대기 시간 일치 이탈률을 사용하면 됩니다. 플레이어 경험치 (PES)는 가장 중요한 지표입니다.
이 기사에서는 스택에서 게임 백엔드를 위한 완전한 관찰 시스템을 구축합니다. 기술(Prometheus, Grafana, OpenTelemetry, Loki)부터 게임별 측정항목까지 기술 성능과 플레이어 경험을 연관시키는 SLO입니다.
무엇을 배울 것인가
- 게임별 지표: 티켓 속도, 대기 시간, 패킷 손실, 서버 활용도
- 스택 관측 가능성: Prometheus, Grafana, OpenTelemetry, Loki, Jaeger
- 사용자 정의 측정항목을 사용하여 Go 게임 서버 계측
- 게임 백엔드용 Grafana 대시보드: 대기 시간 히트맵, 틱레이트, 활성 매치
- 지능형 알림: SLO 기반 및 임계값 기반
- 매치 수명 주기 문제 디버깅을 위한 분산 추적
- PES(플레이어 경험 점수): QoE에 대한 종합 지표
- 기술 성과와 비즈니스 지표(보유, 포기)의 상관관계
1. 게임별 지표
웹 백엔드의 표준 지표(HTTP 지연 시간, RPS 처리량, 오류율)가 필요합니다. 하지만 게임 백엔드에는 충분하지 않습니다. 게임 상황에서만 의미가 있는 측정항목이 있습니다.
게임 백엔드 지표: 완전한 분류
| 범주 | 미터법 | 단위 | 목표 | 영향 |
|---|---|---|---|---|
| 네트워킹 | 왕복 시간(RTT) | ms | < 80ms | 반응형 게임플레이 |
| 네트워킹 | 패킷 손실률 | % | < 0.1% | 순간이동, 러버밴딩 |
| 네트워킹 | 지터 | ms | < 20ms | 불규칙한 보간 |
| 게임 루프 | 서버 틱레이트 | 틱/초 | 목표 +/-5% | 게임플레이 정밀도 |
| 게임 루프 | 진드기 처리 시간 | ms | < 틱_기간 | 통과한 경우: 게임플레이 문제 |
| 게임 루프 | 상태 방송 지연 시간 | ms | < 50ms | 클라이언트의 "부실" 상태 |
| 성냥 | 경기 시간 | s | 게임 모드의 경우 | 균형, 재미 요소 |
| 성냥 | 이탈률 | % | < 5% | 사용자 불만 |
| 성냥 | 매치메이킹 시간 | s | 30초 미만 | 경기 전 참여 |
| 플레이어 | 동시 플레이어(CCU) | 세다 | 용량 계획 | 인프라 규모 조정 |
2. Go에서 게임 서버 계측
게임 서버는 전용 HTTP 엔드포인트에 Prometheus 측정항목을 노출해야 합니다. Go에서는 도서관
prometheus/client_golang 그리고 사실상의 표준. 여기서는 측정항목을 구현합니다.
중요: 틱레이트, 플레이어별 대기 시간, 활성 경기 상태.
// 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 대시보드
좋은 게임 백엔드 대시보드는 무작위 측정항목을 표시하지 않고 상황에 맞는 측정항목을 표시합니다. 문제가 있는지, 어디에 문제가 있는지 빠르게 이해하는 데 도움이 되는 시각적 상관관계가 있습니다. 포함해야 할 가장 중요한 패널은 다음과 같습니다.
// 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. SLO 기반 알림: 고정된 임계값을 넘어
고정된 임계값(예: "대기 시간 > 100ms")을 기반으로 하는 경고는 너무 많은 오탐지를 생성하거나 잘못된 부정이 너무 많습니다. 게임 백엔드는 밤의 대기 시간 등 다양한 특성을 가지고 있습니다. 피크시간대보다 낮습니다. 그만큼 SLO 기반 알림 (서비스 수준 목표) 측정 서비스가 목표를 달성하고 경고만 생성하는 시간의 비율 언제 오류 예산 곧 소진됩니다.
# 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. OpenTelemetry를 사용한 분산 추적
분산 추적은 일치 수명 주기의 복잡한 문제를 디버깅하는 데 필수적입니다. 매치메이킹 요청에 2초가 아닌 8초가 걸리는 이유는 무엇이며, 어떤 구성 요소가 필요합니까? 게임 루프의 중요한 경로에서 대기 시간이 발생합니다. OpenTelemetry(OTEL)는 오픈 소스 표준이 되었습니다. 추적을 위해 Jaeger 또는 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. 플레이어 경험 점수(PES): 중요한 지표
Il 플레이어 경험 점수 여러 기술 신호를 집계하는 복합 지표 사용자의 관점에서 경험의 품질을 나타내는 단일 값(0-100)으로 플레이어. 더 이상 별도의 측정항목을 추적하지 않습니다. 이러한 측정항목이 표시되는 최종 결과를 추적합니다. 게임 경험을 바탕으로 제작합니다.
-- 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;
PES의 해석
| PES 범위 | 분류 | 기대효과 | 행동 |
|---|---|---|---|
| 90-100 | 훌륭한 | 포기 < 2% | 없음 |
| 75-89 | 좋은 | 포기 2-5% | 모니터링 |
| 60-74 | 허용됨 | 포기 5-10% | 조사 |
| 40-59 | 타락한 | 포기 10-20% | 경고 + 개입 |
| 0-39 | 비평가 | 포기 > 20% | 롤백 또는 마이그레이션 |
7. Loki를 사용한 로그 집계: 구조화된 로깅
게임 서버 로깅은 다음과 같아야 합니다. 구조화된 (JSON) 및 관련 파일
측정항목을 통해 match_id, server_id e trace_id. 로키
(Grafana)를 사용하면 모든 콘텐츠를 색인화하지 않고도 레이블별로 로그를 검색할 수 있습니다.
Elasticsearch), 대량 구매 시 훨씬 저렴해집니다.
// 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
결론
게임 백엔드의 관찰 가능성에는 도메인별 접근 방식이 필요합니다. 적용하기만 하면 됩니다. 표준 웹 패턴. 게임별 지표(틱레이트, 플레이어당 RTT, 패킷 손실, 경기 포기)는 다음과 같은 복합 지표로 결합되어야 합니다. 플레이어 경험 점수 기술적인 성과와 플레이어의 실제 행동을 연관시키는 것입니다.
Prometheus + Grafana + Loki + Jaeger/Tempo 스택은 이에 대한 오픈 소스 표준이 되었습니다. 필요합니다. 열쇠와심층 계측 게임 서버의 처음부터, 나중에 생각한 것과는 다릅니다. 계측되지 않은 게임 서버이자 비행 계측기가 없는 비행기와 같습니다.
게임 백엔드 시리즈의 다음 단계
- 이전 기사: 클라우드 게이밍: WebRTC 및 Edge Node를 사용한 스트리밍
- 이것으로 Game Backend 시리즈가 끝났습니다.
- 관련 시리즈: 비즈니스용 MLOps - 프로덕션의 AI 모델
- 관련 시리즈: DevOps 프런트엔드 - CI/CD 및 모니터링







