可観測性ゲーム バックエンド: レイテンシー、ティックレート、プレイヤー エクスペリエンス
ゲーム バックエンドは理論的には理論的に完璧である可能性があります - 分散アーキテクチャ、スケーリング 自動的なマルチゾーン レプリケーションは、同時にプレイヤーにとっては大惨事になります。レイテンシ 2 秒間続く 300 ミリ秒のスパイク、ピーク負荷時に 128 から 64 に低下するティックレート、ゾーン 20 分間マッチを完了できないサーバー: これらの問題は存在しますが、 プレイヤーが否定的なツイートを殺到するまで、適切なツールは見つかりません。
L'可観測性 ゲーム分野では、単に Prometheus と Grafana を適用するだけではありません。 任意のサーバーに。ドメイン固有のメトリックについての深い理解が必要です。 は何をしますか 劣化したティックレート ゲーム体験について、それは p99 パーセンタイル遅延 マッチのチャーンレートに関しては、 プレイヤー経験値 (PES) はすべての指標の中で最も重要です。
この記事では、ゲーム バックエンドの完全な可観測性システムをスタックから構築します。 技術的 (Prometheus、Grafana、OpenTelemetry、Loki) からゲーム固有のメトリクスまで、最大 技術的なパフォーマンスとプレーヤーのエクスペリエンスを相関させる SLO。
何を学ぶか
- ゲーム固有のメトリクス: ティックレート、レイテンシー、パケットロス、サーバー使用率
- スタックの可観測性: Prometheus、Grafana、OpenTelemetry、Loki、Jaeger
- カスタムメトリクスを使用した囲碁ゲームサーバーの計測
- ゲーム バックエンド用の Grafana ダッシュボード: レイテンシ ヒートマップ、ティックレート、アクティブ マッチ
- インテリジェントなアラート: SLO ベースとしきい値ベース
- 一致ライフサイクルの問題をデバッグするための分散トレース
- プレーヤー エクスペリエンス スコア (PES): QoE の複合指標
- 技術的パフォーマンスとビジネス指標 (維持、放棄) の相関関係
1. ゲーム固有の指標
Web バックエンドの標準指標 (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) を使用すると、すべてのコンテンツにインデックスを付ける必要がなく、ラベルでログを検索できます ((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 + Yeter/Tempo スタックは、このためのオープンソース標準となっています。 必要です。鍵と深い計測 最初からゲームサーバーの、 後付けのようなものではなく、計器のないゲームサーバーや、飛行計器のない飛行機のようなものではありません。
ゲーム バックエンド シリーズの次のステップ
- 前の記事: クラウド ゲーム: WebRTC とエッジ ノードを使用したストリーミング
- ゲームバックエンドシリーズはこれで終わりです
- 関連シリーズ: ビジネス向け MLOps - 本番環境での AI モデル
- 関連シリーズ: DevOps フロントエンド - CI/CD とモニタリング







