Scala
Redis
MongoDB
Akka
ポエム

【ポエム】Scala製ゲームサーバーのあれやこれやを大チューニングした

私は現在、Scalaで書かれているわりと古めのゲーム用サーバーに、いろいろ機能追加したり不具合修正したりする仕事をしています(オマケとして、Nodeで管理ツール(イベントやお知らせの編集、プレイヤーの個別情報の閲覧や設定)やKPIツール(各種統計をとってビジュアライズする、ゲーム運用の生命線となるツール)を書いたり、PythonやGoでテストクライアントを書いたりもしています)。
このサーバーシステム、これまでのいくつかのゲームタイトルでおおむね順調に稼働していた実績を買われ、差分開発ということで別のゲームへの流用を打診いただき、ふたつ返事でOKさせていただいて機能追加をしてきたものです。
で、簡易的な負荷試験で見つかった問題を改善し、臨んだクローズドβテストでは何も問題なく左うちわ。
しかし、大量のプレイヤーが参入したオープンβテストで、これがもう、壮絶過ぎる大破産を迎えてしまいました。
あわてて抜本的改修の計画を立て、シビアーな負荷テストによりさらなる改良を加え、そして迎えたオープンβテストSeason2、ロジックや負荷懸念に関しては一点の曇りもない大成功\(^o^)/

ということで、この記事を書きました。ただし、どのくらいのトラフィックを捌けたのか、コードをどう直したのか、などの具体的情報は、あからさまに守秘義務対象どころか機密事項です。
(ですが、 最終値は、いわゆる「20k」よりはそこそこ大きなレベルですよ)
ですので今回もまた、やむなく、ポエムとして、書けるところのみ書かせていただきます。

元ネタのサーバーシステム

  • Scala製
  • Akka Actor, FSM, Remoteを利用
  • データベースとしてMongoDB、揮発KVSとしてRedisを利用
  • REST、JSON、DBなどのラッパーとしてLiftを多用
  • 以下の5種のサーバーがメッシュを組み、全体として動作する
    • RESTで動く認証サーバー(複数台、ロードバランサーで分散処理)
    • クライアントとTCPで暗号化された通信を行うチャットサーバー(複数プロセス・複数台)
    • クライアントとTCPで暗号化された通信を行うロビーサーバー(複数プロセス・複数台)
    • ロビーに居るキャラクターを3次元座標で管理する位置情報サーバー(複数プロセス・複数台)
    • ランキングを管理するランキングサーバー(1プロセス・1台)
  • 認証サーバーは以下の動作を行う
    • アプリアカウントでの認証
    • ロビー情報、アイテムリスト、イベント情報などをクライアントに提供
    • ロビー入りを求められたら、位置情報サーバー・ロビーサーバー・チャットサーバーにアカウント情報を送り、後ろ2者からは暗号化の共通鍵を受け取り、クライアントにホスト・ポートとともに返す
  • クライアントは認証サーバーから返されたホスト・ポートにTCP接続をし、受け取った共通鍵で暗号化した通信を以後行う
  • ロビーサーバーはクライアントと秒間5回、座標のやりとりを行う
    • クライアントが自らの座標を送信
    • それを位置情報サーバーにリレー
    • 位置情報サーバーから返される周囲のキャラクターの座標をクライアントにリレー返信
  • チャットサーバーはクライアントに以下の機能を提供する
    • チャット
    • パーティー
    • マッチング
    • 他人の検索
    • ランキング情報の登録・取得(ランキングサーバーにリレーする)
    • その他いろいろ

クローズドβテスト前の簡易負荷テストで問題が発覚

この簡易負荷試験は、クライアント開発会社からご提供いただいた、Windows用バイナリーを起動して行いました。
本物のクライアントはUnreal Engineで書かれていますが、このバイナリーはそれをWindows用にビルドしただけのもので、サイズが2G近くあり、クラウドで借りたWindowsインスタンス1つあたり十数プロセスしか起動できませんでした。
しかも、クラウドでレンタルするWindowsインスタンス、これがマグマが噴火するくらいど真ん中の目ん玉飛び出るような1高値です。
じっさい、かなりのコストをかけても、プアー過ぎる同時接続数しか再現できませんでした。
にもかかわらず、わずかな同接数で、すぐにシステムがパンクしてしまったと。

問題1: チャットの頻度が高すぎてトラフィックを捌けなくなった

現象

チャットサーバーからの応答が極度に遅延し、C/S通信が全体に不成立状態に陥ってしまいました。
この現象の内訳は、サーバーアプリのログですぐに判明しました。ロビー内で自分の周囲に送信するチャットが、徐々にタイムアウトしはじめ、途中からすべてのチャットが飽和してしまった、というものです。

分析

もともとクライアント側の実装として、チャットは、フリーテキストでNGワードを連呼されてUXが悪化することのないよう、文言選択制になっています。よって、1つ1つのチャットメッセージはわずかなバイト数であり、そうそうネットワークが飽和するようなことはない、と考えていました。
しかし実際には、このテストクライアント、全員が座標(0, 0, 0)からまったく動かず、その状態で「ロビー内で自分の周囲に居る全員に向けて」のチャットを、毎秒複数回、送信し続け合う、という実装になっていたのです。
自分の周囲に誰が居るかを知っているのは位置情報サーバーです。よって、チャットサーバーは位置情報サーバーに、その問い合わせを毎秒数回、ログインユーザーの数だけ送りつけます。
その結果、位置情報サーバーが悲鳴をあげ、リモートアクターが処理待ちになり、チャットサーバー側でタイムアウトしてしまっていた、というものでした。

ソリューション

本件、チャットの頻度を制限することで、簡単に解決できました。
前回のチャット送信時間から、設定ファイルから読み込んだ秒数を経ないと、チャットサーバーが要求を黙って捨てるように改修しました。
(そして、簡易に改修したため、チャットサーバーのアクターにvarが1つ増えました…orz)

クローズドβテストの成功後、そのまま実施したオープンβテストで大破産

クローズドβテストは、ゲームタイトル名を告知し、興味を持った限定メンバーに、総数限定でアカウントを発行し、プレイしてもらう、というものでした。
そしてこちら、それなりの数をぶじ捌くことができ、テストは大成功。負荷も想定より低く、プレイヤーはそうそうチャットをしないものだ、ということはわかりました(笑)。

しかし。
やっぱりこのシステム、安定してますね、といい気になって、ほぼ無改修で迎えたオープンβテスト。事前申し込みもなく、ゲームタイトルをダウンロードした全ユーザーがプレイできる、というものです。

果たして、当初から、過去実績のある別タイトルで捌いた最大値を超える同時接続が押し寄せました。
そして、チャットサーバーが、すぐに応答を返さなくなりました…ダウン早過ぎだろ(´;ω;`)

問題2: 周囲のキャラクター情報の取得頻度が高すぎてトラフィックを捌けなくなった

現象

これはログを見てもわからず、クライアント開発者に「どのコマンドをどのくらいの頻度で呼び出していますか?」と質問し、回答をいただいたことでわかりました。

分析

大量に押し寄せるプレイヤーが作成したキャラクターが、ロビーにわらわらと現れます。
クライアントは、それら、自分の周囲に居る別プレイヤーのキャラクターを描画すべく、全キャラクターの情報をチャットサーバーに問い合わせ、取得しようとします。そしてチャットサーバーは、重複するキャラクターの情報をすべてMongoDBから取得しようとクエリーを出します。
ロビーにいる周囲のキャラクターの位置情報は、最大で40人までクライアントに返しており、それらのキャラクター情報を一度にチャットサーバーに要求する、という絨毯爆撃に、DBサーバーの防御はあっけなく崩壊を迎えたのでした。

ソリューション

本件、βテスト実施中の真夜中の3時間での突貫工事で、キャラクター情報を各チャットサーバーがオンメモリーにキャッシュする、という実装を行いました。その結果、DBサーバーの負荷は少し下がりましたが、輻輳をすべて解決するには至りませんでした。
それはそうです。1つのチャットサーバーインスタンスの中に居るプレイヤーが、全員同じロビーに居るわけではありません。認証サーバーが複数あるチャットサーバーの中のどれを選ぶのかは、純粋にロードバランシングで決めています。が、どのロビーを選ぶかは、クライアントがまったく異なるアルゴリズムで行っており、一致のさせようがないのです(というか、一致しないことを前提で複数サーバーのメッシュ構造が設計されています)。さらに、遅延でプレイを断念し強制切断するプレイヤーに代わって別のプレイヤーが押し寄せてくる状態であり、まさに焼け石に民生ホース放水のレベルに留まりました。

けっきょくこのときは、βテスト中に、ロビーから返す周囲の位置情報を最大10人とする設定変更を行い、とりあえずその場はしのげました。

もちろんこれでは、オンラインゲームに必要な「ロビーのにぎわい感」がまったく出せなくなるため、次のテストに向け、クライアント側で「キャラクター情報の問い合わせ頻度を大きく制限する」&「じゅうぶんに大きなキャッシュを持つ」という改修を行ってもらい、解決しました。

なお、プレイヤーの操作がトリガーとなるチャットは、サーバー側で1秒未満での到来を捨てましたが、こちらはプレイヤー操作とは関係ない部分であるため、クライアントを信用し、サーバー側では制限をしていません。つまりサーバー側は何も改修していません。

[メインテーマ]問題3: マッチング希望者が多いとトラフィックを捌けなくなった

現象

問題2を設定変更でなんとかしのぎ、これでどうにかなるかな、と思いきや、次は、「ログイン後、多対多でのバトルを行うためのマッチング希望者が増えてくると、チャットサーバーが応答を返さなくなる」という問題が発生しました。

前述の2つの問題については、位置情報サーバーやDBサーバーとともに、チャットサーバーの負荷も100%に張り付くような状況になっていましたが、この問題ではそれらとは異なり、チャットサーバーの負荷が70%程度のままに発生してしまったのです。
ただし、マッチング用のDBサーバーは、前述のDBサーバーとは別インスタンスの、ほぼ専用のものを使う構成になっておりまして、そのDBサーバーは100%で張り付いていました。

スローログ調査

明らかにこれまでとは現象が異なります。
そこでさっそく、問題のマッチング用DBサーバーのスローログを取得してみました。
その結果、恐ろしい現実が判明。

command: findAndModify { findandmodify: "characters", query: { matchingRequest: 4, matchingRequestKind: 0, matchingRequestParam: 0, matchingRoom: { $exists: false }, semaphore: { $gt: 0 }, region: "us" }, sort: { matchingRequestTime: 1 }, update: { $inc: { semaphore: -1 } }, new: true } planSummary: IXSCAN { region: 1, matchingRequestTime: 1 } update: { $inc: { semaphore: -1 } } keysExamined:8414 docsExamined:8414 hasSortStage:1 nMatched:0 nModified:0 writeConflicts:4 numYields:198 reslen:91 locks:{ Global: { acquireCount: { r: 201, w: 201 } }, Database: { acquireCount: { w: 201 } }, Collection: { acquireCount: { w: 201 } } } protocol:op_query 104ms

「マッチング希望者を、希望を出した時刻が古い人から、findAndModifyでセマフォー操作しつつ取得する」という処理で、ロック獲得が数百回という、「そりゃスローだわさ」事象が、多数、発生していました。

問題発生のシナリオ

要はこういうことです。

  1. マッチングを希望したキャラクター(プレイヤー)の、希望するマッチングのパラメーターおよび現在時刻を、自らのDBドキュメントに登録する
  2. その数秒後(乱数で散らしています)に、空のマッチングルームが作成される
  3. これら、他ユーザー起因を含む複数のマッチングルームが、いっせいに、条件に合うキャラクターを獲得しようとする
  4. 数百のマッチング希望者が作成したマッチングルームからのリクエストが、最も古くマッチングを希望したキャラクターに集中する
  5. MongoDBでのfindAndModifyの失敗は、C++としてはコストが高い、C++例外の捕捉として実装されている
  6. よって、1つのキャラクターを獲得する処理が、MongoDB内でスロークエリーになっている
  7. MongoDBクライアントとしてLiftの同期実装を用いていたため、その間、チャットサーバーのアクターがブロックする
  8. コンテキストを得ながらブロック=実質スリープしてしまうアクターが多数出たため、チャットサーバーは「過負荷になっていないのに応答が遅い」という現象に陥る

そして過去に実績があるゲームタイトルで、この現象が発生していなかった理由も、推察できました。

  • 別ゲームタイトルでは、マッチング参加はパーティー単位でしかできない
  • 別ゲームタイトルにはオフラインモードなどがあり、全員がロビーに入って多対多バトルを行うという行動を採らない
  • 対するに今回はオープンβテストで、バトルしか機能がないため、全員が個人でマッチングに殺到した
  • その分、findAndModifyのリトライがふくれあがり、限界値を越えてしまった

なお、「なぜ、マッチング希望者全員が空のルームを作るの?」という素朴な疑問をお持ちの、ゲーム開発にお詳しい方がおられるかもしれません。
実は、そうお思いの方が想定するアルゴリズムは、現在のゲーム業界的には、「古典的マッチング」とされています。これは、ルームのオーナーがまず存在して、その人が他プレイヤーに参加募集をかける、というスタイルです。この場合、ルームの絶対数を減らせるため、今回のような問題は低減されます。
しかしこのアルゴリズム場合、ルームのオーナーになるのか、いち参加者になるのかを、プレイヤーが決めなければなりません。そのことがその後のバトルにおいて意味を持つのであればよいのですが、そうでなく、バトル中は全員が対等であるような場合は、プレイヤー=お客様に「オーナーになるかどうか」を考えてもらうことには意味がまったくありません。そしてもちろん、「お客様に意味のないこと」をさせるような仕様は、NGです。

ソリューション

んもう、もともとの設計に、いろいろ言いたいことが浮かんでくるこの状況。

  • 同時接続が多い場合にロックの取り合いになることを、なぜ想定していないのか
  • なぜコンテキストを握ったまま、他サーバーを同期で呼び出しているのか(DBやRedisの話:本システム内の別サーバーへの呼び出しにはちゃんとaskが使われている)
  • なぜ、オンライン専用であり、再起動時(=メンテ明け後)は消えていてよい、つまりディスクに保存する必要がまったくないデータを、MongoDBに書き込んでいるのか
  • そもそも論として、varや意味のないforを使い過ぎ、ReadWriteLockを乱用し過ぎ、その割にはびっくりするところで排他漏れ、コンストラクター内でオーバーライド可能なメソッドを呼んでいる、等々、クォリティーが低過ぎるコードが部分的に散見される

…って最後のは今回の問題には直接関係しない、私的な愚痴ですね。m(_ _)m

ともかく、さすがにこれではゲームが成立しない、発売のしようもない、ということで、時間的猶予はいただけましたが、問題点のすべてを解決するには圧倒的に時間が足りません。

  • DB呼び出しの同期を非同期に変更することは時間的にぜったいに不可能
  • MongoDBのデータをオンメモリーKVSにすべて移すことも、LiftのMongoDBライブラリーに全体が依存し切っているため、時間的に困難
  • そもそも、findAndModifyで1つの資源を多数で奪い合うのはあたまわるすぎ

というわけで、最終的には、findAndModify、すなわちマッチングの待ち行列から取り出す処理だけ、Redisに移行するという決断をしました。

  • RedisのZSETを使う
  • キー値は個人またはパーティーのObjectId、値はマッチング希望時のエポックタイムとする
  • マッチングの条件によっては、またパーティーのマッチングでは必ず、複雑な処理が必要となる(例えば、2人パーティーは、2~4人の募集のいずれにも応えられなければならない)が、それはLuaスクリプトで書き、アトミック性を保証する
  • Redisの冗長性を確保するため、システム全体で1台のRedisサーバーにしか接続できない実装を変更し、初期化時に指定するラベルで複数のRedisサーバーに接続し分けられるようにし、マッチングの種類で接続先を分けられるようにした(開発環境などでは単一のRedisサーバーとつなげることも可能)
  • とはいえ、マッチングルームやキャラクターのデータは依然としてMongoDB上にあるため、RedisとDB操作をアトミックにできないが、ZSETの他、STRINGを使ったロックを別途併用して楽観的ロックを実現し、ZSETから引き出した後のDB操作に失敗した場合や、DBの中身とRedisにあるマッチング条件が異なるような場合、ZSETに値を戻せるようにした

これらの対策を実装した結果、後述する本格負荷テストにおいては、このβテストの10倍近い同時マッチング要求を楽に捌くことができるようになりました。
そしてそれは、DBのスロークエリーなどの「リモートの重い処理の待ち」さえコンテキスト内から排除すれば、アクターからの他サーバーへの同期アクセスもなんとかこなせるだろう、という目処がたった瞬間でもありました。

本格負荷テストでも新たな問題が発覚

さらに、Goでテストクライアントを書くことで、安価なLinux系インスタンスで、多量のプロセスを立て、その中で多量のgoroutineにより、莫大な数の同時接続を可能とし、満を持しての本格負荷テストの実行へと至りました。

そしてその結果、最大の問題であったマッチングでの問題は完全に解消されました。また、本物クライアント側で施された対策である「キャラクター情報の取得の制限&キャッシュ化」も、新仕様と同様のテストクライアントの動作を実装し(もちろん、各クライアントは乱数でロビー内をうろつきます)、負荷とならないことを確認できました。

じっさいには、

  • 最も低レベルな部分のみをRedisに差し替えたつもりが、資源管理に漏れが発生し、壮絶にバグる
  • 高負荷になってはじめて露呈するテストクライアント側のバグにより、負荷テスト時のみ結果がおかしくなる

などの問題への対処に、相応の時間を消費しております…

しかし、そもそもこれまでまったく実現できていなかった未曾有の同時接続数で動かすことができた結果、さらなる問題が発覚していくのでした。

問題4: パーティーを組んだクライアントを一定数ロビーでうろつかせると、トラフィックを捌けなくなった

現象

実績のある別ゲームタイトルと今回の差は、先述のとおり、マッチングの頻度の差も大きくありました。そのため、本格負荷テストでは、まず、処理が多くなる「全員個人参加」を検証し、その後にパーティーの検証に入りました。
ところがどっこい、じっさいにテストクライアントどうしにパーティーをランダムに組ませた結果、マッチングを何もせず、ロビーにいるだけで、なぜかパーティー用DBの負荷が100%に張り付いてしまいました。

分析

位置情報サーバーやそれをクライアントとの間でリレーするロビーサーバーは、原則として、毎秒5回、クライアントからのじぶんの座標を受け入れ、同頻度で、クライアントの周囲の最大40人の座標を返します。
実績のある別ゲームでは、これは25人でしたが、人数を増やしたことで負荷がめだって上がるわけではないことは、そもそもここまでで確認済みでした。

しかし、じっさいには両者の実装内容に、それ以外の差分がありました。というか、クライアント開発会社からのご要望で自分が追加した機能を、すっかり失念していました(泣)。
今回のゲームでは、単純に最大40人ぶんの座標を返すのではなく、自分がパーティーのメンバーになっていたら、同じパーティーのキャラクターは近傍でなくとも優先して座標を返す、というご要望を、そのまんま実装していたのでした。

その結果、パーティーを組んでいる全員が、毎秒5回、いっせいに、パーティーメンバー一覧をDBにクエリーしていたわけです。

ソリューション

本件、DBへのクエリー大杉が原因でしたので、チャットのときと似た感覚で「パーティーメンバーをいちど取得したら、それは2秒間キャッシュし問い合わせない」という制限を位置情報サーバーに加え、ぶじ解決できました。

問題5: 認証サーバーにいっせいの大量ログイン&ログアウトを何度もくりかえさせると、トラフィックを捌きにくくなった

現象

本件、Redisサーバーのうち、従来からサーバー全体で使っている共通インスタンスが過負荷になった、というものです。

分析

ここではじめて、Redisのスローログを見てみました。

127.0.0.1:16379> slowlog get 2
1) 1) (integer) 3397167
   2) (integer) 1529630374
   3) (integer) 11465
   4) 1) "KEYS"
      2) "area:*"
2) 1) (integer) 3397166
   2) (integer) 1529630374
   3) (integer) 10491
   4) 1) "KEYS"
      2) "area:*"

え゛、 KEYS呼んでるの…

Redisを使う上で、最も叩いてはいけないコマンド、それはKEYSです。
公式ドキュメントにも、「このコマンドはデバッグやデータベースのスキーマの変更を行うなどの特別な操作を除いて使うべきではありません。通常のコードでは使わないでください」って書いてある代物です。

そしてコードを調べたところ、これ、全サーバーインスタンス、冒頭の5種類のサーバーの各プロセスが、5秒ごとに叩いてるよ… (●`ε´●)

どういうことかというと、サーバー間でのやりとりのために、各サーバーはTCPでメッシュを張りますが、その接続情報をRedisから取得していたのです。しかも5秒おきに!
そしてその情報はHASHではなく、STRING型で、トップレベルのキー名を大量にばらまく形で登録されていました。

たしかに、「自らの設定ファイルに相手の存在を書いておく」という密結合過ぎる構成にしてしまうと、台数が多い今回のシステムでは、その管理が破綻するでしょう。負荷に応じてプロセス数を増減させることも容易ではありません。
ですので、Redis経由でやりとりすることじたいは、悪くない手法です。
でもそれを、KEYSで取る、というのが、決定的に悪い手法なのです。

さらに、認証サーバーは認証時のnonceを、Redisに保存しています。これがまた、HASHではなくSTRING型で格納してしまっています。
もちろんライフタイムは設定されていますが、テストクライアントがログインを何度も繰り返せば、nonceはそのライフタイムの間に常時存在し続けることになり、トップレベルのキー数がとても多い状態が保たれてしまうため、KEYSが重くなる一因となったわけです。

そしてまた。本件も、、過去の別タイトルでは、想定同時接続数が小さかった、すなわちサーバー総数が小さかったために、顕在化していなかった問題でありました。

ソリューション

しかし、いまさら KEYS を止める変更を全体で行うのはきびしいところです。
ですので、これもまたチャットと同じく、5秒ごとのポーリングを、60秒ごとに引き伸ばしました。
結果、同じ高負荷をかけても、Redisは過負荷状態に陥らなくなりました。

ただし、この変更には代償もありました。
それは起動直後、各サーバープロセスの起動のタイミングにより、全体が準備OKになったことの確認の粒度が落ちてしまった、ということです。
従前は5秒ごとだったので、1分たたないうちに安定して情報取得ができていましたが、1分ごとにした結果、安定には3分くらいはかかるようになってしまいました。

もっとも、このことは、実運用上は重大な問題にはなりません。
起動時=メンテナンス明け、ですので、その時刻より少し前に全サーバーを起動しておき、フロントエンドのhttpdで認証サーバーへの流入を停めたままにして、確認作業を行えばよい、からです。

問題6: ユーザーアカウントを実運用ではありえないくらい大量に生成させていくと、接続者が少ない状態でもトラフィックを捌きにくくなった

もうこれでほぼ完璧だろ、あとは座してオープンβテストseason2を迎えるだけだ、と意気揚々とひさびさの週末の休みを満喫しつつ、バックグラウンドでちまちまとプレイヤーを増やして、ありえない登録者数のもとでもきちんと動作するかどうか、週明けに確認すればいいや、とお気楽に明けた翌週頭。

しかしそこには冷酷な現実が待ち構えていたのであった!

現象

なんと、その週明けの状態から、小さな負荷をかけたところ、Redisが過負荷になりました。
なんやねん!?!?

分析

これまでの流れからして、これまたKEYS絡みじゃないか、という気はしていましたが、その勘が正解でした。
なんと、ユーザーがログインするごとに、Redisに、他から参照されていない謎のSTRING型の値が期限なしで書き込まれており、しかもそれはログアウトしても決して削除されることがない。
そこにアカウントを延々と、想定されるアカウント総数の数倍作った結果、トップレベルキーもその数以上となってしまい、60秒ごとのKEYSが恐怖レベルのスロークエリーになっていた、というオチでした。
この不条理な、意味のないコード。ここに何か、ログインして作成or利用するキャラクターをキャッシュしておこう、としたのかなぁ、とは考えました。
だがしかし。なんでその保持が、HASHではなくSTRING型なのか。
ゲームシステムでは、不正アクセスでない限り、多重ログインはそもそも発生しません。そして、そんな不正アクセス(チートが目的であれば、多重ログインの必要はまったくないので、おそらくはカジュアルな、意図の薄いアタック)を排除するためにSETEXによるアトミック操作が必須、というのは、そうとうに限定された局面でしか、あり得ません。
なぜこんなコードなのか、と、いちおうgitのログも追いましたが、バージョン管理をはじめたはるか前の時点で、既にこのコードがゴミとして残っていた、ということだけが判明しました。
いやはや、これまで気にしていなかったゴミも、積もれば崖となる。涙

ソリューション

これは簡単で、単に、そのキーを廃止しました。
リビルドでエラーが出ないし、明示的なリフレクション利用もないので、まぁだいじょうぶだろうということです。
そして、エラーが出なかったということは、すなわち、それ向けの単体テストも書かれていなかったということでもある、というね…(単体テストが一部で欠落しているのも、また、このシステムが抱える重大な問題のひとつ(涙))

今後への教訓まとめ

まともに動いたからこそえらそーに書ける反省。
個人的にもScalaでの高負荷検証は初体験で、いろいろと調査・検討をした結果、かなり知見を増やすことができました。
とはいえ、今回私が改修し、目標をクリアーできた部分は、みな、ScalaやAkkaならではの繊細なチューニング以前の問題が主、ではありました…。

明らかにボトルネックになりそうな部分のソリューション選択・設計が雑だった

  1. ディスク保存の必要がまったくないデータを、「複数サーバー間で型付きで共用できるから」というだけの理由(推察)で、MongoDBに置いてしまった
  2. マッチング処理=1件のリクエストをアトミック操作で多数のルームが奪い合う処理を、MongoDBのfindAndModifyで実装してしまった
  3. Redisの効率的なデータ型選択がまったくなされていなかった
  4. Redisで「禁句」のKEYSコマンドを頻繁に呼び出していた

いずれも、NoSQLとKVSを導入してはみたものの、その適切な利用法まではよく練られていなかった、ということです。
開発が始まったのはもうかなり昔なので、当時は情報が少なかったのは確かですし、また、その時点では今よりかなり規模が小さい運用を前提にしていたようです。それが、基本を変えないままに、使用するゲームとともに機能が増えていったものの、これらの点については放置されてきて、今回のゲームでついに破綻した、ということになってしまいました。

Scalaでの開発上、気をつけるべきところに気をつけていなかった

  1. 開発開始時点で、DBやKVSの非同期クライアントライブラリーがなかった
  2. それは仕方ないが、それならば時間がかかりそうな同期呼び出し箇所を別コンテキストにする、などのパフォーマンス上の工夫をすべきところ、全体的になかった
  3. Redisサーバーをシステム全体で1プロセスしか立てない前提でラッパークラスが実装されており、「過負荷時にRedisじたいを機能別に分散させて凌ぐ」という発想を欠いていた
  4. プロセスレベルでの排他が必要な箇所で、専用のアクターを作るのではなく、ReadWriteLockなどの古典を使っていた

って4.は今回の改修対象とはならなかった面ですが、敢えて挙げました。
あくまでも個人の感想ですが、その4.も含め、どうも、当時の開発者が、Akka Actorの本質を理解し切らないままに、漫然と実装を進めてしまったのではないか、と思えてしまいます。
まぁ、こんな素敵な本が出版された後にそう書くのも、完全に、後出しジャンケンでしかないんですが。

クライアント開発会社との連携の難しさ

  1. そもそも「多数のプレイヤーがいっせいにこのリクエスト叩いたらどう考えても通信破綻するだろ」という「見立て」「勘」は、サーバー側の経験がないと知る機会がない、特殊ノウハウでしかない
  2. 「そういうご経験をお持ちでない」からこそ、ご発注いただけております

私はゲームサーバーの開発・運用の経験は現職が2社目なのですが、1社目(サーバー側はLAMPの、スマホゲームでした)では、クライアントとサーバーを同時に(もちろんそれぞれ別のチームの人が)開発していました。そういう意味では通信量問題などは共有が容易で、私としても「クライアント開発者だってこのくらいは前提だろう」という感触を持てていました。
しかし、コンシューマーやアーケード機のプログラミングについては話が異なります。なにせ、ひとむかし前までは通信のつの字も必要ない世界でしたから、通信量ってなんですか? となるのも当然です。それはけっして責められるべきことではない。
やはり、通信量については、サーバー側の予断抜きで、ていねいに問題の共有を図っていかなければなりません。

[本質的な問い] そもそも、Scala/MongoDBという組み合わせが正しかったのか?

  1. Scalaを利用するきっかけ、MongoDBを利用するきっかけ、それぞれが、「Scalaすごいらしい、MongoDBすごいらしい」という風評に乗っかっただけではなかったのか(個人の推察です)
  2. だがしかし。独自の視点をもって独自の留意点にさえ気をつけることができれば、設計実装の労力と性能のバランスがここまでよいソリューションもなかなかない、はず
  3. という状況が理解されづらく、かつ、じっさいのプロダクツが往々にしてだめだめだったりして、防戦一方(涙)
  4. [最新] という現状をちゃぶ台返ししたOracleふざけんな(いわゆる地主制度2.0)
  5. そもそも、一定以上のScalaエンジニアーを一定のコストで確保できんの?? できないからこうなっちゃうんじゃないの???

今回のチューニングを経て、私のScala愛はいちだんとパワーアップしました。現職ではじめて取り組んだMongoDBは、正直、それまでは蔑視していたんですが、ようやく「嫌いではない」というレベルになりました(^^ゞ。(ついでに、Redisは昔も今も大好きです♪)
実は今、RESTのみで済まないレベルのゲームサーバー案件では、C++への揺り戻し傾向が起きています。
それは実は、クラウドでの運用を前提とした場合のコストが、JVMだと高いじゃん、C++だと安いじゃん、というところに起因しています。運用コストの差異は、意外とばかにできません。
私もそういう話を耳にして「そうかも」とは思っていました。が、今回チューニングして、また今回はチューニングせずに放置している膨大なコードを見て、「Scala+Akkaでぜんぜん行けるじゃん」と感じました。開発フェーズだって、GCに頼れるほうがリスクが小さいのは明らかです。

ただ、そうも言ってられないな、とも率直に思います。

まずは、Oracle問題。正直、今後、JVMで動かすものをクライアント開発会社やパブリッシャーにご提案するのは、かなり厳しくなる、と考えられます。ビジネス上は、じっさいに問題なく動くかどうかではなく、サポートがあるという形而上的安心感のほうが重要なのです。残念ながら。

そしてもう1つ、Scalaエンジニアーをどうやって確保するの問題です。
私は業務でScalaを用いるのは現職場が2社目ですが、1社目(Web系小規模ベンチャー)は基幹プロダクツにScalaを採用していた職場で、結果そのエンジニアー2人(うち1人が私)がほぼ同時期に退職することで、「メンテナンス不可能」という最悪の状況を迎えてしまいました2。さらに、その後私が受けた採用面接でも、複数社のCTOさんたちが「その規模でScala採用は自殺行為」とおっしゃってました。そういうもんです。
幸いにも現職場にはScalaを操れるエンジニアーが居るので、その種の逼迫感まではありません。が、現実に目の前にあるコードの悲惨な状況を見ると、プロのScala開発者って単に「書けます」ってだけじゃだめなんだ、とも感じざるを得ません。

…解決策がない話を書いてしまいました。m(_ _)m

[オマケ] 「エンドユーザーにdisられるマッチングサーバー」という定番

対戦ゲームのβテスト時にSNSにあふれかえるのが、「マッチングサーバーが糞」というメッセージです。
しかし、それはほんとうに「マッチングサーバーがおかしい」という話なのでしょうか?

実は、エンドユーザーが考える「マッチングサーバー」という概念は、

  1. マッチング希望コマンドを叩く
  2. 人数が揃ったと画面に表示される
  3. バトルの準備中と画面に表示される
  4. バトルが開始される

この過程のうちの、1.から4.の間に起こっていて、かつ直接操作したり内情を見たりすることができないこと、すべてを指しています。

しかし実際に、コンピューター目線で起こっていることは、以下のとおりです。

  1. サーバーがクライアントからのマッチングコマンドを受理し、マッチング待ちに入る
  2. サーバーがバトルに必要な人数をマッチングさせることに成功し、それを各クライアントに通知する
  3. 各クライアントどうしがUDPでメッシュを張る
    1. 張れない場合はUDPホールパンチングを試みる
    2. (オプション)各クライアントの中で、スターの中心に居ると最も通信が安定しそうなクライアントを、ホストに選ぶ
    3. ホストがUDPメッシュを使い、バトル開始のタイミングを揃える
  4. バトルが開始される

実はクライアント側でやっていることのほうが大変だし多いんです。
(そのため、クライアント開発エンジンには、あらかじめこれをやってくれるライブラリーがあります。ただしチューニングの余地も多数。)
しかしエンドユーザーには、2.と3.は一連のシーケンスにしか見えませんから、3.の問題もすべて「マッチングサーバーの糞が原因だ」と思われてしまうわけです。
(もちろん、今回のゲームのオープンβテスト1回目で生じたのは、まさに2.が死んだというもので、リアルガチ「糞」でしたorz)

もう1つ。
いわゆる「過疎問題」というやつで、そもそもマッチング希望者が一定の人数揃ってない限り、サーバーが何をがんばってもマッチングは成立しません。
もちろんさすがに、「過疎ってたらマッチングできない」ことは、エンドユーザーもご存じと思います。
しかし、じぶんが入ったロビーには多数のキャラクターがうごめいている。なのにマッチングしないのはなんでじゃー、「マッチングサーバーが糞」、となるわけです。

実はこれも、じっさいのクライアント間の通信品質を考えてのことなのです。
ロビーには、接続元地域を問わず、誰でも入ることができます。しかし、それらの人と無条件にマッチングさせてまうと、中には「隣のキャラクターはアメリカ、さらに隣はドイツ」ということも容易に起こります。そしてその場合、結局バトル時のUDPの帯域や遅延が許容を超え、バトルから落ちるプレイヤーが出てしまい、「(クライアントの)開発会社が糞」という批判になってしまいます。
それを考慮して、サーバーは、手法はさまざまですが、「この地域とこの地域以外はマッチングさせない」という区分を実装しています。
バトルから落ちるプレイヤーは、「せっかくバトルを開始したのに途中で強制終了させられる」という意味で実害を蒙ります。ので、マッチングをサーバーの段階で地域別で絞っておくことが、実は最大幸福なんですよね。そして、だから、「マッチングサーバーが糞」。そう言われることがゲームサーバー屋の宿命なんであります。

技術にまったく関係がない脚注


  1. カ、カテェ… 

  2. 当然のことながら、退職は労働者の権利です。メンテナンス不能かどうかを、退職を回避する理由にしてはなりません。…というのはタテマエで、じっさいには考えますよ。それを考えられない、やむなき状況だった、ということです。