新しいタイプのオンラインゲームサーバーエンジンの設計アイデア
分散型でありながら、シングルスレッドプログラムを書くように簡単で、データは自動的にプッシュされ、気にする必要のない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 の性能を大幅に低下させ、柔軟性も低下させる。例えば、Cluster や Redis プロキシ下では使用できない。
トレードオフの結果、選択されたのは「バージョン楽観ロック + 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 の得意分野だ。
サーバー NPC の AI は、これまで我々はビヘイビアツリーやステートマシンなどを手書きで使っていた。Python を使えば可能性はさらに広がり、真の AI モデル方式、例えば強化学習の Q-learning などを採用できる:将来の報酬から過去の様々な行動タイミングの価値を逆算し、力技で NPC の最適な行動ロジックを導き出す。もちろん、ゲーム性が高い Boss にはさらなる調整が必要だ。行動が合理的だからといって面白いとは限らないからだ。
Redis の性能ボトルネックとデータ構造設計
Redis の性能がゲームサーバーの処理能力を制約する。
まず、読み書きを分離し、クライアント(サブスクリプション)とサーバー(トランザクション)の負荷を切り離すべきだ。クライアントのすべてのサブスクリプションは読み取り専用の Redis レプリカを通じて行い、トランザクションを処理する master Redis に影響を与えない。サーバーのすべてのトランザクション書き込みコードは master 上で実行される。このスキームは大多数のゲームには十分だ。
さらに大規模なゲーム(例えば往年の単一サーバー MMO のようなもの)を考慮するなら、master の拡張性を考慮する必要がある。master は複数の Redis で構成される Cluster で拡張できるが、重要な制限がある:トランザクションは Redis サーバーをまたぐことができない。したがって、エンジンはテーブル間の関連性を計算し、関連するテーブルを同じ Redis 上に配置する必要がある。従来のリレーショナルデータベースのような大きなテーブルは互いに関連しやすく、分離が難しいため、ECS 構造を導入する必要がある。
簡単に言えば、属性を小さなテーブルに分割する。例えば、money 属性を money という名前のテーブル(コンポーネント Component と呼ぶ)にし、money と owner フィールドのみを格納し、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 ゲームで使用する予定で、貢献も歓迎する。
7d577a3