跳至内容

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

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

発端

ゲームサーバーは初期にはC言語で開発され、開発サイクルが遅くデバッグも大変でした。その後、luajitの使用が始まりました。私たちは比較的早く、2009年に切り替えました。オンラインゲームは性能を重視しますが、実際には動的言語による開発の容易さと柔軟性の方が性能よりも重要です。

機能の改良とリファクタリングを重ねる中で、コードのタスクは依然として重く、ますます速くなるイテレーションの要求に追いつけず、多くの機能をすぐに実験してテストできないことに気づきました。改善するには言語を変更し、概念的にもゲームサーバーを再設計する必要がありました。

サーバー即データベース

従来の構造では、ゲームサーバーがクライアントからのコマンドを受け取り、ロジックを処理し、データベースを操作し、最後に更新データをクライアントに返していました。このプロセス自体が複雑さをもたらしていたので、データベースを直接隠蔽する、あるいは一体化してしまう方が良いと考えました。つまり、ゲームサーバー自体をデータベースにするのです。

このような考えは自然なものでした。これまでオンラインゲームはリレーショナルデータベースを嫌ってきました。単純なメモリマッピングを直接使うか、RedisなどのNoSQLを使うかのどちらかでした。次に、最良のデータベースは常にカスタムデータベースです。なぜなら、異なるビジネスではトレードオフが異なるからです。それならば、サーバーをゲームクライアント専用のデータベースインターフェースとして設計してしまいましょう。もちろんバックエンドは実際のデータベースが永続化を担当しますが、データベース操作は完全に隠蔽し、内部で自動的に処理します。

以下、ゲームサーバーをdb、バックエンドの実際のデータベースをbackendと呼びます。

  • まず、ゲームクライアントはdbに対して直接クエリ(select … where …)を購読できます。購読内のデータ変更は、on_insert/on_update/on_deleteなどのイベントを含め、dbが自動的にプッシュします。
  • テーブルにはシンプルな権限を設定し、クライアントが自分に関連するデータのみをクエリできるようにします。ただし、サーバーのオンライン人数ステータスなど、一部のテーブルはゲスト権限に設定できるようにし、未ログインのクライアントでもクエリ可能にします。
  • クライアントは購読のみ可能で、書き込み権限はありません。書き込みはdbのロジックコードを通じて行います。これは従来のゲームサーバーロジックであり、dbであるため、ストアドプロシージャと呼ぶこともできます。クライアントは名前を直接callし、dbは対応する関数を実行します。ほとんどの場合、実行結果を返す必要はありません。なぜなら、プッシュによって自動的に反映されるからです。
  • dbロジックコード内の読み取り/書き込み操作はすべてトランザクションでラップされ、自動的にbackendにコミットされます。トランザクションが競合した場合は自動的に再試行されます。これにより、書く際に競合状態を考慮する必要がなくなり、デプロイもより柔軟になります。
    graph TD;
    subgraph "ゲームサーバー(DB)"
        DB1["購読"];
        DB2["ロジック"];
    end
    Client["ゲームクライアント"]<--購読データストリーム-->DB1;
    Client--Call-->DB2;
    subgraph "Backend"
        DB1<--読み取り専用購読-->レプリカ;
        DB2--読み書きトランザクション-->マスター;
    end
  

この形式では、クライアント側もサーバー側も、非常に書きやすくなります。 クライアントはデータを購読するだけで、データイベントを通じてUIやシーン内のオブジェクトを自動的に処理でき、サーバーロジックから大幅に分離されます。 サーバー側は真のロジックのみを書けばよく、ゲームクライアントに更新データを送信することを一切気にする必要がなくなります。

特性のトレードオフ

これは主に、変種の不可能な三角形、つまり性能、柔軟性、データ一貫性のバランスを取ることです。どれかを向上させると、他の何かが低下します。

まず性能です。Webサーバーと同様の負荷分散分散型構造を採用できるため、性能はbackendデータベースにのみ制約されます。したがって、性能の高いredisを使用します。さらにredisはMQを内蔵しており、別の層を重ねる必要はありません。redisは水平スケールで性能を向上できますが、データ一貫性のトレードオフが伴います。最後に、redisのメンテナンスとデータ表示は不便です。通常、redisはキャッシュとしてのみ使用し、その上にmysqlを重ねて永続化を行いますが、これにより複雑さが大幅に増し、柔軟性が大幅に低下します。redisは永続化を担当でき、インデックスも実装できます。redisのみを使用し、データのメンテナンスと表示は後でデータサイエンス関連のライブラリに任せることを考えています。

柔軟性には、障害とメンテナンスの側面が含まれます。この点では高可用性を放棄し、エラーが発生した場合、クライアントは直接切断してもう一台のサーバーに接続します。ゲームはシームレスな再接続を実装することも、直接再ログインさせることもできます。メンテナンスでは、ワンクリックでのサーバー起動を実現し、動的なリソースの増減を容易にし、コストを大幅に削減できるようにすることが望ましいです。障害時の自動復旧は、自動再起動を設定することで実現できます。これには優れたデータ一貫性が必要であり、障害時にデータのロールバックや修復などの操作が不要になります。

最後に、ゲームはデータ一貫性に高い要求があります。まず、分散環境でのデータ競合をどう解決するか、そしてプログラムがクラッシュ/ダウンした際に書き込み途中のデータをどうするかです。プレイヤーからお金を引き落としてアイテムを配布しない、ということは絶対に許されません。これは必須条件です。最良の方法はbackendのトランザクションを通じて実現することですが、トランザクションはbackendの性能を大幅に低下させます。redisであっても、2桁の性能低下があります。トランザクションを使わずに実現する場合、インデックスが関わるため、ロックや他の方法を使う必要があり、これらは非常に面倒で、メンテナンス上の柔軟性もすべて犠牲になります。したがって、ここではトランザクションを選択し、backendの性能を犠牲にします。その後、backendはトランザクションを分割することで水平スケールをサポートし、一貫性を保つことができます。これについては後述します。

    ---
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は優れた選択肢です。安全性、性能、モダンさ、すべての面で満足できます。しかし、私は動的言語を好みます。できればpythonのような書きやすい動的言語が良いです。実際、分散をサポートさえすれば、ボトルネックは完全にredisで決まり、言語の性能は関係ありません。今やCPUは人件費よりも安く、サーバーのメンテナンスが容易であれば、スポットサーバーを利用することでさらに50%安くできます。みんな、dockerによる30%の性能低下さえも気にしないほど、便利さが重要です。

pythonにはライブラリが豊富です。例えば、テーブルの読み書きはnumpy arrayでラップでき、配列の処理とフィルタリングが非常に簡単になります。例として、クロスインデックスを示します:

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

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

このFortran式のブロードキャスト/ベクトル化は、ゲーム内のデータ処理パターンに非常に適しています。ベクトル化は自動的にSIMDを有効にし、C言語のforループ計算よりも数十倍高速です。mysqlの複合インデックスでさえ、CPUによる二次フィルタリングを行い、SIMDさえ使用しません。このようなラッピングは、前述したredisでのデータメンテナンスの不便さも解決します。メンテナンスだけでなく、レポート作成や収益分析などもより簡単になります。結局のところ、データ処理と表示はpythonの得意分野です。

サーバーNPCのAIは、以前はビヘイビアツリーやステートマシンなどを手動で書いていましたが、pythonを使えば可能性は無限です。強化学習のQ学習など、本当のAIモデル方式で完全に実装できます。これらは将来の報酬から過去の様々な行動タイミングの価値を導き出し、NPCの最適な行動ロジックを力ずくで導き出します。もちろん、ゲーム性の高いボスにはより多くの調整が必要です。行動が合理的であっても、面白いとは限らないからです。

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

redisの性能がゲームサーバーの処理能力を制約します。幸いなことに、現在では大規模なMMOは多くなく、1万人以内のオンライン数ではredisのトランザクション上限には達しません。redisの単一インデックス+トランザクション能力は、読み取り+書き込みで毎秒約3万回です。

まず、クライアント/サーバー分離モードがあるべきです。つまり、クライアントのすべての購読は、読み取り専用のredisレプリカを通じて行われ、トランザクションを処理するマスターredisには影響を与えません。サーバーのすべてのロジックコードはマスター上で実行されます。これにより、処理能力はかなり向上します。

次に、設計上、将来のマスターの拡張性を考慮する必要があります。マスターは複数のredisサーバーで構成されるクラスタによって拡張できますが、重要な制限があります。それは、トランザクションがredisサーバーをまたがることができないことです。したがって、エンジンはテーブル間の関連性を計算し、関連するテーブルをすべて固定された1台のredis上に配置する必要があります。しかし、リレーショナルデータベースのような大きなテーブルは最終的にはすべて関連し、分離できません。これにはECS構造が必要です。

簡単に言えば、属性を小さなテーブルに分割します。例えば、money属性をmoneyという名前のテーブルにし、これをコンポーネントと呼びます。moneyとowner属性のみを格納し、owner属性を通じて関連するplayer idに関連付け(アタッチ)ます。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
  

まとめ

このような構造では、コードを書く際に厄介なロックは一切必要なく、データ競合を考慮せず、シングルスレッドを書くように効率的かつ集中でき、バグも少なくなります。プロジェクトは最終的に、コンポーネントの依存関係の分解を考慮し、分散と性能を制御するだけで済みます。

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