Bytro:レガシー・マルチプレイヤーゲームバックエンドの近代化
Bytro の PHP ゲームバックエンドを CQRS・イベント駆動マイクロサービスに移行。ライブプレイヤーをアクティブマッチに保ちながらレイテンシを約35%削減した。
Bytro はリアルタイムストラテジーゲームを作る。Supremacy 1914。Conflict of Nations: WW3。ブラウザベース、リアルタイム、大規模マルチプレイヤー。プレイヤーは何日も何週間もかけてグローバルな戦争作戦を調整する。マッチには数百人のプレイヤーがいる。状態—ユニットの位置、資源量、外交合意、戦闘結果—はすべての参加者に常に一貫して見えなければならない。
それが近代化を依頼されたシステムだ。
「レガシー」が実際に何を意味したか
元々のバックエンドは PHP だった。モダンな非同期処理とよく型付けされたコントラクトを持つ PHP 8 じゃなく—レガシー PHP、何年分もの、忠実にプレイヤーを支えてきたが新機能のたびに技術的負債との交渉が必要なポイントまで成長したコードベースの蓄積された決断を持つ。
問題は微妙じゃなかった。ゲームイベントの状態ミューテーション—ユニットが移動する、戦闘が解決する、外交メッセージが送られる—は不明確な所有権を持つ同期呼び出しのもつれを通して起きていた。複数のイベントが並行して到着したとき(数百人のプレイヤーがいるライブゲームでは常に、たまにではなく)、システムの整合性保証は負荷依存だった。静かなサーバーは対処できた。負荷がかかったサーバーには競合状態があった。
読み取り負荷も問題だった。ゲーム状態クエリ—すべてのプレイヤーがマップを更新する、すべてのクライアントが更新をポーリングする—は書き込みも吸収している同じデータベースパスを叩いていた。マッチのピークをクリーンにサバイブできるキャッシュ戦略はなかった。
p99 のレイテンシはプレイヤーエクスペリエンスに目に見えて影響していた。Bytro が運営するスケールでは、それはエンジニアリングの不便さではなくリテンションの問題だ。
アーキテクチャの賭け:CQRS + イベントソーシング + Kafka
コアの決断は、コードの組織だけでなくアーキテクチャレベルでコマンド処理とクエリ処理を分離することだった。
コマンド—「ユニットが州Aから州Bに移動する」「プレイヤーが派閥Xに宣戦布告する」「資源取引が実行される」—はドメインイベントを Kafka に発行するコマンドハンドラーを通る。イベントが真実の記録だ。コマンドハンドラーはアプリケーション状態を直接書き込まない。
状態はイベントから導出される。読み取りモデル—プレイヤーがマップを見るときにクエリするマテリアライズドプロジェクション—は PostgreSQL 読み取りレプリカと Redis キャッシュを更新するイベントコンシューマーによって構築される。読み取りリクエストはコマンドパスに触れない。書き込みは読み取りパスに触れない。それらは独立してスケールする。
イベントソーシングはゲーム状態がいつでもイベントログから再構築可能であることを意味した。それはアーキテクチャ上の洒落だけじゃない—決断が結果をもたらし、プレイヤーが注意深く見ているゲームで本物のプレイヤーサポートチケットのカテゴリである「私のユニットに何が起きたか」の議論への答えだ。
ライブプレイヤーをマイグレーション中も生かし続ける
これは振り返れば明白で、生きていると悲惨な部分だ。
Supremacy をマイグレーションのために週末に停止させることはできない。プレイヤーはマッチの途中だ。一部のマッチは何週間も続く。「カットオーバー前に開始したマッチは旧システムに移行される;後に開始されたものは新システムで動く」とは言えない—飛行中のゲーム状態の数が、存在しない専用マイグレーションチームなしに運用上不可能にする。
マイグレーション戦略はイベント駆動のストラングラーフィグだった:新機能は初日からイベント生成サービスとして実装された。レガシーコードパスはライブで権威を持ち続けた。移行期間中はデュアルライトを実行した—新しいイベントは Kafka に発行され、レガシー状態は依然として同期的に更新された—読み取りトラフィックをカットオーバーする前にイベント導出の読み取りモデルがレガシーの真実の源泉と一致していることを検証できた。
デュアルライト期間がコードにはなかったレガシーシステムのあらゆる前提を見つけるところだ。すべての暗黙的な順序保証。レガシーシステムのシングルスレッド実行が偶然防いでいたすべての競合状態。これらを見つけることは楽しくなかった。本番で見つけないことが目的だった。
約35%のレイテンシ削減
数値は読み取りモデルマイグレーション前後の p95 と p99 のゲーム状態クエリレイテンシから来ている。Redis マテリアライズドプロジェクションを叩く読み取りパスは、書き込みも吸収している競合する PostgreSQL テーブルを叩く読み取りパスと同じカテゴリの操作じゃない。これは驚きじゃない。驚きは改善しなかった場合だ。
より面白い数値は書き込みパスレイテンシで、こちらはあまり改善しなかった—Kafka パブリッシュレイテンシは本物で、ピーク時のイベントコンシューマーラグは本物で、コマンドパスは以前は同期だったのが今は非同期だ。クリックした後すぐにユニットが移動するのに慣れていたプレイヤーは、短い非同期遅延を見ることになった。それは慎重な処理が必要な UX のトレードオフだった—クライアント側のオプティミスティック更新パターンがほとんどをカバーしたが、イベントコンシューマーが一時的に遅れている場合のタイムアウトと調整の動作を調整するには反復が必要だった。
マルチスクワッド調整
Bytro のマイグレーションには複数のスクワッドが関与した:インフラを扱うプラットフォームスクワッド(Kafka、Kubernetes、デプロイパイプライン)、個別のゲームシステムを扱うドメインスクワッド(戦闘、外交、経済)、フロントエンドの状態同期変更を扱うクライアントスクワッド。
それらのスクワッドをまたいだリードデベロッパーは彼らの間のコントラクト管理を意味した。イベントスキーマがコントラクトだった。戦闘スクワッドが戦闘解決イベントにフィールドを追加する必要があったとき、それはクライアントスクワッドが処理する必要があるスキーママイグレーションで、アナリティクスパイプラインが処理する必要があり、読み取りモデルコンシューマーがすべてフラグデーなしで処理する必要があった。イベントをバージョン管理した。明白に聞こえる。それを以前やったことのないコードベースで実装するのは、誰もやりたくなくてみんなやってくれてよかったと思う3週間の作業だ。
Kubernetes:慎重に適用された正しいツール
マッチのピーク時—トーナメントイベント、メジャーアップデート、見ていないと必ず捕まる週末のスパイク—に Kubernetes 上のイベントコンシューマーをオートスケールするのは正しい呼びかけだった。このコードベースが Kubernetes で実行された最初の機会でもあり、Kubernetes が明示的にするよう要求するステートレス/ステートフルの区別を、その区別を暗黙的かつ一貫性なく行ってきたコードベースに後付けで適用する必要があった。
単一インスタンスのローカルメモリに保存された PHP セッションは Kubernetes ネイティブじゃない。これはすでに知っていた。レガシーコードがその前提を持っていたあらゆる場所を調べることが、他のすべてが機能するための地味な前提条件だった。
私が所有したもの
- CQRS/イベントソーシングモデルと Kafka イベントトポロジーのアーキテクチャ決断
- ライブゲーム状態のマイグレーション順序とデュアルライト戦略
- スクワッドをまたいだイベントスキーマ設計とバージョニングコントラクト
- 読み取りモデル設計(PostgreSQL プロジェクション、Redis キャッシュレイヤー)
- マルチスクワッド調整:プラットフォーム、ドメイン、クライアントエンジニアリング
- ステートレスイベントコンシューマーの Kubernetes デプロイ設計
ゲームは大抵のエンタープライズソフトウェアにはない方法で負荷変動がある。土曜日午後2時のトーナメントアナウンスはキャパシティプランニングスプレッドシートにない。そのスパイクをプレイヤーがスピナーを見つめて過ごさずに吸収できるバックエンドを作ることは、予測可能な B2B リクエストカーブを処理するとは違うクラスの問題だ。Bytro でイベントコンシューマーのバックプレッシャーとラグアラートについて学んだことは、今設計するすべての分散システムで使っている。
Conflict of Nations: WW3 は今も動いている。Supremacy 1914 は今も動いている。それらを支えるバックエンドは私が引き継いだものとは意味のある形で違う。それが成果だ。