コンテンツにスキップ
新しいタイプのオンラインゲームサーバーエンジンの設計アイデア

新しいタイプのオンラインゲームサーバーエンジンの設計アイデア

2024-05-08 03:15

分散型でありながら、シングルスレッドプログラムを書くように簡単で、データは自動的にプッシュされ、気にする必要のないAPIは隠蔽され、ビジネスロジックにのみ集中できるゲームサーバーアーキテクチャはあるだろうか?

きっかけ

初期のゲームサーバーはほとんどが C 言語で開発され、開発サイクルは遅く、デバッグは苦痛を伴った。その後、LuaJIT への移行が始まり、我々は比較的積極的で、2009年には切り替えていた。初期のオンラインゲームは性能を重視していたが、技術の進歩により性能の重要性は相対的に低下した。

機能の改善とリファクタリングを繰り返す中で、コードのタスクは依然として重く、ますます速くなる反復的な要求に追いつけないことに気づいた。多くの機能をすぐに実験してテストすることができず、改善したいなら、概念的にもう一度ゲームサーバーを設計し直す必要があった。

サーバー即データベース(スキーマ即API)

従来の構造は一般的に以下の通りだった:ゲームサーバーがクライアントからの命令を受け取り、サーバーロジックがデータベースを操作し、最終的に更新されたデータをクライアントに返す。このプロセス自体が複雑さをもたらすため、データベースを直接隠蔽するか、あるいは一体化する方が良い——ゲームサーバーがデータベースそのものである。

このような考えは実は自然なものだ。オンラインゲームは昔からリレーショナルデータベースをあまり好まなかった:最高の性能を求めて単純な MemoryMapping を直接使うか、Redis のような NoSQL を使うか。『最高のデータベースは常にカスタムデータベースである』という言葉があるが、それならサーバーをゲームクライアント向けに特化したデータベースインターフェースとして直接設計してしまえばよい。もちろん、バックエンドは依然として本物のデータベースが永続化を担当するが、データベース操作は完全に隠蔽され、内部で自動的に処理される。

以下、ゲームサーバーを db、バックエンドの本物のデータベースを backend と呼ぶ。

  • まず、ゲームクライアントは db に対して直接サブスクリプションクエリ(select ... where ...)を実行できる。サブスクリプション範囲内のデータ変更は、db が自動的にプッシュする。on_insert / on_update / on_delete などのイベントも含む。
  • テーブルにはシンプルな行レベルの権限を設定し、クライアントが自分に関連するデータのみをクエリできるようにする。ただし、一部のテーブルは guest 権限に設定できるべきだ。例えば、サーバーのオンライン人数ステータスなど、未ログインのクライアントもクエリできるようにする。
  • クライアントはサブスクリプションのみ可能で、書き込み権限はない。書き込みは db の関数を通じて行う。これが従来のゲームサーバーロジックだ。db である以上、ストアドプロシージャと呼んでもよい。ただし、このストアドプロシージャは、データベースのストアドプロシージャには通常備わっていない、ステートフルなメモリ内計算に特化している。
  • db のロジックコードにおける読み取り/書き込み操作はすべて一つのトランザクションにラップされ、自動的に backend にコミットされる。トランザクションが競合した場合は自動的に再試行される。これにより、コードを書く際に競合状態を考慮する必要がなくなり、デプロイもより柔軟になる。
    graph TD;
    subgraph "ゲームサーバー(DB)"
        DB1["サブスクリプション"];
        DB2["ロジック"];
    end
    Client["ゲームクライアント"]<--サブスクリプションデータストリーム-->DB1;
    Client--Call-->DB2;
    subgraph "Backend"
        DB1<--読み取り専用サブスクリプション-->レプリカ;
        DB2--トランザクション読み書き-->マスター;
        DB2--トランザクション読み取り-->レプリカ;
    end
  

この形式では、クライアント側もサーバー側も、非常に快適に書くことができる。 クライアントはデータをサブスクライブするだけで、リアクティブプログラミングを通じて UI やシーン内のオブジェクトを自動的に処理でき、サーバーロジックから大幅に分離される。 サーバー側は真のロジックのみを書けばよく、データの書き込み、更新、プッシュを気にする必要がなくなる。

特性のトレードオフ

不可能な三角(バリエーション)である性能、柔軟性、データ一貫性のバランスも取る必要がある。一つの特性を高めると、通常他の特性が低下する。

性能

Web App で一般的なロードバランシング分散構造を採用できる。これにより、性能は主に backend データベースによって制約される。性能の高い Redis を採用した場合、その長所と短所は以下の通り:

  • MQ が内蔵されており、別の層を追加する必要がない。
  • 垂直スケーリングで性能を向上できるが、データ一貫性のトレードオフが伴う。
  • データのメンテナンスと表示が不便だが、現在は AI の支援で解決できる。

柔軟性

柔軟性には障害とメンテナンスの側面が含まれる:

  • 高可用性に近いものを実現でき、サーバーエラー時にクライアントが気づかない再接続が可能。
  • ワンクリックでサーバーを起動でき、動的な増減が容易。
  • 単一サーバーの障害がクラスタに影響を与えないようにする。これには優れたデータ一貫性が必要で、障害時にデータのロールバック/修復などの操作が不要になる。
  • backend の動的な増減も考慮する必要がある。

データ一貫性

ゲームはデータ一貫性に高い要求を持つ。分散データの競合状態はどう解決するか?プログラムが クラッシュ / ダウン した際に書き込み途中のデータはどう処理するか?プレイヤーから金を差し引いたのにアイテムを配布しない、ということは絶対に許されない。これは必須項目だ。

性能のために分散を選択した以上、このアーキテクチャ下では、データ一貫性は結局トランザクションの問題だが、WATCH + MULTI トランザクションは Redis の性能を大幅に低下させ、柔軟性も低下させる。例えば、ClusterRedis プロキシ下では使用できない。

トレードオフの結果、選択されたのは「バージョン楽観ロック + Lua トランザクション」方式だ:バージョン番号が完全に一致し、すべてのチェックが通過した場合にのみ、Lua が一括書き込みを実行し、複数データの書き込みがすべて完了するか、すべて失敗するかを保証する。この方式は Redis の様々な Fork やアーキテクチャをサポートする。Lua を使用しているが、実測では WATCH + MULTI よりも高速だ。

柔軟性のために、インデックスへのロックは諦めた。つまり、ファントムリードを防ぐことはできない(ユーザーが存在するかどうかをクエリし、存在しなければ新しいユーザーを挿入する)。しかし、一般的にこの種の問題はそもそもUniqueインデックスを追加することで解決される。インデックスクエリの一貫性を犠牲にした代わりに、得られた柔軟性と性能向上は非常に大きい。

    ---
title: アーキテクチャ図
---
graph TD;
    Client1["クライアント"]-->DNS;
    Client2@{ shape: processes, label: "クライアント..."}-->DNS;
    DNS["ロードバランサーDNS"]-->Node_A;
    DNS-->Node_B;
    subgraph "Node1"
    Node_A["ゲームサーバー"]-->Worker_A1["Worker"];
    Node_A-->Worker_A2@{ shape: processes, label: "Worker..."};
    end
    subgraph "Nodes..."
    Node_B["ゲームサーバー..."]-->Worker_B1["Worker"];
    Node_B-->Worker_B2@{ shape: processes, label: "Worker..."};
    end
    Worker_A1-->Backend["Backend<br/>(Redis Cluster/Replica)"];
    Worker_A2-->Backend;
    Worker_B1-->Backend;
    Worker_B2-->Backend;
  

言語とさらなる性能への配慮

実際のところ、Lua は現代の言語と比べてそれほど便利ではなく、C++11 の多くの機能の方が Lua よりも快適だ。さらに Lua はライブラリが少なく、メンテナンスも弱いため、ゲームサーバーから Lua を排除することはもはや必須事項だ。

Rust は良い選択肢の一つだ:安全性、性能、モダンさのすべてが揃っている。しかし、私は柔軟性を考慮して動的言語を好む。人間と AI の両方にとって書きやすいものが望ましい。そう、答えはもう出ている:Python だ。

実際、分散をサポートさえすれば、ボトルネックは完全に Redis にあり、言語の性能はそれほど重要ではなくなる。今や CPU は人件費よりも安い。もしサーバーのメンテナンスも容易なら、スポットサーバーを利用することでさらに 50% 安くできる。

Python には多くのライブラリがある。例えば、テーブルの読み書きは完全に NumPy array でラップでき、配列の処理とフィルタリングが非常に手軽になる。クロスインデックスの例を挙げよう:

array = money_table.range("last_update", left=now - 3600, right=now)
poor = array[array.money < 999]
poor.money += poor.money.mean()

ローカルで二次フィルタリングを行い、すべての行の中で money < 999 のデータを選択し、最後にベクトル化処理を行う。

この Fortran 式のブロードキャスト/ベクトル化は、ゲーム内のデータ処理パターンに非常によく適合する。ベクトル化は自動的に SIMD を有効化し、C での単純な for loop 計算よりも何倍も速い。このようなラッピングは、前述した Redis のデータメンテナンスの不便さも解決する。結局のところ、データ処理と表示は Python の得意分野だ。

サーバー NPCAI は、これまで我々はビヘイビアツリーやステートマシンなどを手書きで使っていた。Python を使えば可能性はさらに広がり、真の AI モデル方式、例えば強化学習の Q-learning などを採用できる:将来の報酬から過去の様々な行動タイミングの価値を逆算し、力技で NPC の最適な行動ロジックを導き出す。もちろん、ゲーム性が高い Boss にはさらなる調整が必要だ。行動が合理的だからといって面白いとは限らないからだ。

Redis の性能ボトルネックとデータ構造設計

Redis の性能がゲームサーバーの処理能力を制約する。

まず、読み書きを分離し、クライアント(サブスクリプション)とサーバー(トランザクション)の負荷を切り離すべきだ。クライアントのすべてのサブスクリプションは読み取り専用の Redis レプリカを通じて行い、トランザクションを処理する master Redis に影響を与えない。サーバーのすべてのトランザクション書き込みコードは master 上で実行される。このスキームは大多数のゲームには十分だ。

さらに大規模なゲーム(例えば往年の単一サーバー MMO のようなもの)を考慮するなら、master の拡張性を考慮する必要がある。master は複数の Redis で構成される Cluster で拡張できるが、重要な制限がある:トランザクションは Redis サーバーをまたぐことができない。したがって、エンジンはテーブル間の関連性を計算し、関連するテーブルを同じ Redis 上に配置する必要がある。従来のリレーショナルデータベースのような大きなテーブルは互いに関連しやすく、分離が難しいため、ECS 構造を導入する必要がある。

簡単に言えば、属性を小さなテーブルに分割する。例えば、money 属性を money という名前のテーブル(コンポーネント Component と呼ぶ)にし、moneyowner フィールドのみを格納し、owner を通じて関連する player id に関連付ける(attach)。Unity のスクリプトコンポーネントに似ている。これにより大きなテーブルはなくなり、関連性をより細かく分散できる。また、player(単に id を持つだけで、実際には存在しない)は ECS における Entity 実体であり、ロジックコードは System である。

    ---
title: コンポーネントクラスタの例
---
graph TD;
    subgraph "クラスタ1"
    System_A-->Component1;
    System_B-->Component1;
    end
    subgraph "クラスタ2"
    System_D-->Component3;
    System_D-->Component2;
    System_C-->Component2;
    end
  

まとめ

さて、もう厄介なロックを書く必要もなく、データ競合を考慮する必要もなく、シングルスレッドを書くように効率的に集中できる。クライアントの MVC における C ももはや不要だ。

これらの設計は実際にはまだ軽量で、必要なコードはそれほど多くないはずだが、頭を使う。私は現在開発中で、すでにオープンソース化している:  Heerozh/hetu。次の SLG ゲームで使用する予定で、貢献も歓迎する。

最終編集
hugo-builder
hugo-builder · · 自动翻译 2024-05-08... · 7d577a3
他の貢献者
...