Article

ゲーム業界バックエンド開発事例:高負荷対応で数百万人規模の同時接続を実現

高田晃太郎
ゲーム業界バックエンド開発事例:高負荷対応で数百万人規模の同時接続を実現

統計によると、PCゲーム配信プラットフォームのSteamは2024年12月に同時接続3,918万7,489人の過去最高を記録し¹、バトルロイヤル系タイトルの大型イベントでは2020年に1,230万人以上が同時参加した事例が報告されている²。なお、このSteamの記録は2025年3月にも更新が報じられている⁶。数値の大小に一喜一憂するより、ここで押さえたいのは、ゲームサーバーの同時接続が数百万規模でも珍しくなくなった現実だということだ。単なるオートスケールやキャッシュの追加では耐えられない局面が増え、接続数、秒間イベント、そしてファンアウトの三層で負荷を定義し、SLO(Service Level Objective)から逆算してアーキテクチャと実装を固定化するアプローチが現実的になっている⁴。CTO視点では、最初にSLOを確定し、状態の置き場と整合性の境界を明確にする設計判断が成否を分ける。以下に、数百万人規模の同時接続を捌くための実装の型と運用の要点を、実コードとともに整理する。

高負荷の正体を分解し、SLOから逆算する

まず数値(SLO)を先に置く。ゲームバックエンドでは、ログイン系、マッチメイク系、ゲーム内リアルタイム系という三つのホットパス(遅延とエラー率に直結する主要経路)に負荷が集中する。一般的な指標として、ログインはp99(99%のリクエストが収まる遅延)で400ms以内、マッチメイクはp99で2秒以内、ゲーム内RPCはp99で100〜150ms以内を目標に据えると現実的だ。SLOは遅延とエラー率を併記し、エラーバジェット(許容エラー枠)を運用の意思決定に直結させる⁴。この数値から、同期I/Oの段数、外部依存の切り離し、キャッシュTTL、そしてキュー・バッチの粒度が逆算される。

リアルタイム系のSLOを満たすには、接続の多重化と優先制御が効く。gRPCやHTTP/2のフロー制御でバックプレッシャー(押し返し)を効かせ、キュー深度で負荷の見える化を行う。サーバはキュー深度やCPUスロットル閾値を跨いだら自発的にロードシェッドし、上流に早めのデグレードを返す。以下はGoでのgRPCサーバ設定例で、キープアライブ、最大ストリーム数、デッドライン、そして簡易的なロードシェッドを備える(ステータスコードはDeadline/ResourceExhaustedを返す)。

package main

import (
    "context"
    "log"
    "net"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/keepalive"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type server struct{}

func (s *server) Ping(ctx context.Context, in *Empty) (*Pong, error) {
    if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < 20*time.Millisecond {
        return nil, status.Error(codes.DeadlineExceeded, "load shedding")
    }
    return &Pong{Msg: "ok"}, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil { log.Fatal(err) }

    ka := keepalive.ServerParameters{
        MaxConnectionIdle:  2 * time.Minute,
        MaxConnectionAge:   30 * time.Minute,
        Time:               30 * time.Second,
        Timeout:            5 * time.Second,
    }

    s := grpc.NewServer(
        grpc.MaxConcurrentStreams(128),
        grpc.KeepaliveParams(ka),
        grpc.ConnectionTimeout(5*time.Second),
    )

    RegisterHealthServiceServer(s, &server{})
    log.Println("listening :50051")
    if err := s.Serve(lis); err != nil { log.Fatal(err) }
}

SLOを守るための指針は単純で、ホットパスに同期ストレージを書かない、外部依存を二段以上連鎖させない、そして必ず不完全モードの動作(縮退運転)を用意することだ。プロフィールやランキングが一時的に古い値でもプレイ継続を許せるように、機能要件側で折り合いをつけておくと、負荷ピーク時の生存確率が劇的に上がる。

状態管理の境界を決め、スティッキーに捌く

数百万人規模では、セッション、ルーム、長期プロフィールという異なる性質の状態を同じストアに載せるのは禁物だ。短命なセッションはメモリやインメモリKVS、ルームの権威状態はゲームサーバのプロセス内、長期プロフィールは永続ストアという分担が実務的に堅い。セッション粘着性(スティッキー)を担保するために、接続ゲートウェイで安定的に同じバックエンドへルーティングする。ハッシュリング(consistent hashing)を使って、プレイヤーIDからルーム・セッションサーバへスティッキーに振り分けると、キャッシュヒットとレプリカ間同期の両方で得をする。

以下はGoでの一貫ハッシュを用いたルーティングの実装例だ。ノード離脱や追加に耐えつつ、プレイヤーを安定的に同一サーバへ送る。

package routing

import (
    "crypto/sha1"
    "fmt"
    "sort"
)

type node struct{ key string }

type ring struct {
    positions []uint32
    nodes     map[uint32]node
}

func newRing(backends []string) *ring {
    r := &ring{nodes: make(map[uint32]node)}
    for _, b := range backends {
        for v := 0; v < 100; v++ {
            h := sha1.Sum([]byte(fmt.Sprintf("%s#%d", b, v)))
            p := uint32(h[0])<<24 | uint32(h[1])<<16 | uint32(h[2])<<8 | uint32(h[3])
            r.positions = append(r.positions, p)
            r.nodes[p] = node{key: b}
        }
    }
    sort.Slice(r.positions, func(i, j int) bool { return r.positions[i] < r.positions[j] })
    return r
}

func (r *ring) pick(playerID string) string {
    h := sha1.Sum([]byte(playerID))
    p := uint32(h[0])<<24 | uint32(h[1])<<16 | uint32(h[2])<<8 | uint32(h[3])
    i := sort.Search(len(r.positions), func(i int) bool { return r.positions[i] >= p })
    if i == len(r.positions) { i = 0 }
    return r.nodes[r.positions[i]].key
}

セッションのDoSとスパイクを和らげるには、ゲートウェイの手前でレート制御を行う。RedisのトークンバケットをLuaで原子的に更新するのが軽くて強い。次の実装は1ユーザ・1秒あたりの許容量を設定し、バースト時にスムースに制御する例だ。

package ratelimit

import (
    "context"
    "time"

    redis "github.com/redis/go-redis/v9"
)

const script = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local burst = tonumber(ARGV[3])
local last, tokens = redis.call('HMGET', key, 't', 'tokens')
last = tonumber(last) or now
tokens = tonumber(tokens) or burst
local delta = math.max(0, now - last)
local refill = delta * rate
tokens = math.min(burst, tokens + refill)
local allow = 0
if tokens >= 1 then
  tokens = tokens - 1
  allow = 1
end
redis.call('HMSET', key, 't', now, 'tokens', tokens)
redis.call('EXPIRE', key, 2)
return allow
`

type Limiter struct{ rdb *redis.Client }

func New(addr string) *Limiter {
    return &Limiter{rdb: redis.NewClient(&redis.Options{Addr: addr})}
}

func (l *Limiter) Allow(ctx context.Context, key string, rate float64, burst float64) bool {
    now := time.Now().Unix()
    res, err := l.rdb.Eval(ctx, script, []string{"rl:" + key}, now, rate, burst).Int()
    if err != nil { return false }
    return res == 1
}

プロフィールやインベントリの永続化では、整合性の境界を明確にしてからAPIを設計する。ゴールド付与のような経済操作は二重実行を絶対に許容できないため、冪等性キーとユニーク制約で守る。PostgreSQLなら次のように最小限で対策できる。

CREATE TABLE grants (
  idempotency_key text PRIMARY KEY,
  player_id bigint NOT NULL,
  amount int NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
-- アプリ側は常に同じidempotency_keyでINSERTする
-- 二重送信でもON CONFLICTにより一度しか反映されない

スループットはキューとバッチで稼ぎ、順序はキーで担保する

リアルタイム経路に余計な重みを載せないために、課金検証、ログ集計、ランキング集計、ソーシャル通知はメッセージングで非同期化する。Kafkaのような分散ログはパーティションキーで順序を保証できるので、プレイヤーIDやルームIDでキーを決めるのが定石だ。プロデューサは冪等化、コンシューマは少なくとも一度+冪等処理という方針を徹底する³。

以下はSaramaを使ったGoのプロデュサ設定例で、冪等、有界リトライ、バッチングを有効にしている(Exactly-once相当の構成にはIdempotent + MaxInFlight=1の組み合わせが要点)。

package mq

import (
    "log"
    "time"

    "github.com/Shopify/sarama"
)

type Producer struct{ p sarama.SyncProducer }

func NewProducer(brokers []string) *Producer {
    cfg := sarama.NewConfig()
    cfg.Producer.RequiredAcks = sarama.WaitForAll
    cfg.Producer.Idempotent = true
    cfg.Producer.Retry.Max = 5
    cfg.Producer.Return.Successes = true
    cfg.Producer.Flush.Bytes = 256 * 1024
    cfg.Producer.Flush.Frequency = 5 * time.Millisecond
    cfg.Net.MaxOpenRequests = 1

    p, err := sarama.NewSyncProducer(brokers, cfg)
    if err != nil { log.Fatal(err) }
    return &Producer{p: p}
}

func (p *Producer) Send(topic, key string, value []byte) error {
    msg := &sarama.ProducerMessage{Topic: topic, Key: sarama.StringEncoder(key), Value: sarama.ByteEncoder(value)}
    _, _, err := p.p.SendMessage(msg)
    return err
}

コンシューマ側では、処理の冪等化が生命線だ。アウトボックスパターンを用いれば、アプリのDBトランザクションとイベント発行の一貫性を保ちやすい。ここでは簡易版として、処理済みIDをRedisに保持して二重実行を抑止する例を示す。

package consume

import (
    "context"
    "log"
    "time"

    redis "github.com/redis/go-redis/v9"
    "github.com/Shopify/sarama"
)

type Handler struct{ rdb *redis.Client }

func NewHandler(addr string) *Handler {
    return &Handler{rdb: redis.NewClient(&redis.Options{Addr: addr})}
}

func (h *Handler) Process(ctx context.Context, msg *sarama.ConsumerMessage) error {
    key := "done:" + string(msg.Key) + ":" + string(msg.Value[:16])
    ok, err := h.rdb.SetNX(ctx, key, 1, 24*time.Hour).Result()
    if err != nil { return err }
    if !ok { return nil }
    // 実処理: 経済操作など
    log.Printf("apply event key=%s offset=%d", msg.Key, msg.Offset)
    return nil
}

ファンアウトが支配的な通知は、配信の優先度を明確に分けると生存性が上がる。ゲームプレイに不可欠なイベントは低遅延チャネルで、SNS的なフィードは遅延許容チャネルへ分離する。WebSocketの大規模配信では、接続ブローカー層を置いてアプリからはPub/Sub越しにメッセージを投げる構成(Redisや専用ブローカーの活用)が管理しやすい。

package fanout

import (
    "context"
    "log"
    "time"

    redis "github.com/redis/go-redis/v9"
)

type Broker struct{ rdb *redis.Client }

func New(addr string) *Broker {
    return &Broker{rdb: redis.NewClient(&redis.Options{Addr: addr})}
}

func (b *Broker) PublishRoom(ctx context.Context, room string, payload []byte) error {
    ch := "room:" + room
    return b.rdb.Publish(ctx, ch, payload).Err()
}

func (b *Broker) RunSubscriber(ctx context.Context, room string, out chan<- []byte) {
    sub := b.rdb.Subscribe(ctx, "room:"+room)
    for msg := range sub.Channel() {
        select {
        case out <- []byte(msg.Payload):
        case <-time.After(10 * time.Millisecond):
            log.Printf("drop slow consumer room=%s", room)
        }
    }
}

マルチリージョンの現実解と、壊しながら運用する姿勢

ピークが地理的に分散するタイトルでは、マルチリージョンのアクティブ・アクティブが費用対効果に優れる。ルーティングはAnycastまたはGeoDNSで近傍に寄せ、セッションは各リージョンに閉じる。書き込み整合が厳しい資産管理はシャードごとにプライマリを決め、プレイヤーのホームリージョンを割り振るのが実務的だ。クロスリージョンのトランザクションを避ける代わりに、ユーザ移動時のみ明示的なマイグレーションを走らせる。グローバルランキングはCRDT(衝突解消可能データ型)や近似集計で最終的整合を採り、イベント終了時に確定処理を行う、という時間軸の分離もよく効く⁵。

多リージョンでは障害の相関が読みにくくなるため、能動的に壊して耐性を測定する。カオス実験でリージョン断、ブローカー分断、KVSスローダウンを再現し、SLO違反に至るまでの猶予と自動回復の成立を確認する。デプロイは常にダークローンチとカナリアで進め、p95遅延、エラー率、タイムアウト比、キュー深度、ガーベジのポーズ時間といったRED/USE系の主要メトリクスを眺めながら段階的に広げる。以下はアプリケーション側での簡易ヘルス判定とロードシェッドの例で、キュー深度やGCポーズ時間を見て早めに引き返す(簡易な閾値判定)。

package health

import (
    "net/http"
    "runtime/metrics"
    "time"
)

var qDepth func() int // 実装はキューに応じて差し替え

func Healthz(w http.ResponseWriter, r *http.Request) {
    samples := []metrics.Sample{{Name: "/gc/pauses:seconds"}}
    metrics.Read(samples)
    gcPauses := samples[0].Value.Float64()
    if qDepth != nil && qDepth() > 5000 { w.WriteHeader(http.StatusTooManyRequests); return }
    if gcPauses > 0.2 { w.WriteHeader(http.StatusTooManyRequests); return }
    w.WriteHeader(http.StatusOK)
}

func Shed(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if deadline, ok := r.Context().Deadline(); ok && time.Until(deadline) < 30*time.Millisecond {
            http.Error(w, "shed", http.StatusTooManyRequests); return
        }
        next.ServeHTTP(w, r)
    })
}

コストの観点では、ホットパスのRTT(往復時間)を短くすることが一番の節約になる。RTTが半分になれば必要ノード数は比例して減る。ノード大型化よりも水平分割に振り、スパイク時だけバーストできるようにユニットを小さく保つ。プロファイルとベンチを常設し、性能退行をCIで止める体制が、結果的にクラウドコストの最適化につながる。実務では、シリアライゼーション形式の見直し(Protocol Buffersの活用やJSON削減)、Nagleの抑止、N+1の解消、防御的コピーの削減といった地味な改善で、同一ハード上のスループットとレイテンシが大きく改善する例が多い。

ケースクロージャ:数字に落とした“実装の型”

締めに、上述の設計を数字に畳み込んでみる。ホットパスはp99=120ms、外部依存は1ホップ、セッションKVSはローカルAZのレプリカに限定、イベント処理はKafka経由で非同期化、ルーム状態はプロセス内で権威を持ち、スナップショットは5秒に一度だけ送る。レート制御はユーザ単位とIP単位の二層、冪等性はキー+DB制約で担保、ルーティングは一貫ハッシュで粘着性を確保。マルチリージョンはホーム固定で一貫性を守り、移動時にだけ明示的な移譲を走らせる。これは、数百万人規模でも季節イベントのトラフィックを含め破綻しにくい“型”の一例として汎用性が高い。

まとめ:SLOに忠実に、適切に諦め、勝つ

大規模ゲームのバックエンドは、万能の一枚岩ではなく、SLOを核にした複数の最適化が並走する有機体だ⁴。すべてを強整合で守ろうとすれば破綻するし、すべてを最終的整合に委ねれば不正と事故が増える。だからこそ、どの経路で何を諦めるかを最初に決めてから、状態の置き場、順序の担保方法、故障時の縮退モードを設計に織り込む。あなたのプロダクトでいま一番のボトルネックは、遅延、順序、整合、どれだろうか。SLOを1枚に書き出し、ホットパスの同期I/Oを洗い出し、ひとつずつ非同期化と冪等化を進めるところから始めてほしい。本稿のコードはひな形として流用できる。次のピーク時に、冷静に遅延曲線を眺めながらスイッチを切り替えられる運用者であるために、明日からのロードテストと可観測性(メトリクス・トレース・ログ)の改善を小さく回していこう。

参考文献

  1. GameBusiness.jp. 2024年12月8日、Steamの同時接続数が3918万7,489人の過去最高を記録. https://www.gamebusiness.jp/article/2024/12/10/23724.html
  2. AUTOMATON. 『フォートナイト』Travis Scottライブ「Astronomical」初日の同時接続1230万人、累計2700万人が参加(2020年4月). https://automaton-media.com/articles/newsjp/20200428-122788/
  3. Confluent. Introducing Exactly-Once Semantics in Apache Kafka. https://www.confluent.io/online-talk/introducing-exactly-once-semantics-in-apache-kafka/
  4. Google SRE Book. Service Level Objectives. https://sre.google/sre-book/service-level-objectives/
  5. Alibaba Cloud Topic. Large-scale leaderboard system: practice and challenge. https://topic.alibabacloud.com/a/large-scale-leaderboard-system-practice-and-challenge_8_8_31119741.html
  6. 4Gamer.net. Steam同時接続数の記録更新が報じられる(2025年3月、SteamDBベース). https://www.4gamer.net/games/038/G003821/20250303009/