Unityには内部の開発者が一箇所に集まり1週間かけて様々な新しい機能を開発するハックウィークという活動があるのですが、Unity Game Performanceは1年前に、このハックウィークのプロジェクトとしてスタートしました。ハックウィークの時の目標はシンプルで「何か新しいことをしよう」ということだったのですが、複数の異なるバックグラウンドを持つ人たちでチームを組み、以下の事柄をそれぞれ担当しました:
UIはおそらく一番わかりやすいものでしょう。 すでに developer.cloud.unity3d.com が立ち上がっていることに気づいている方々もいるかと思いますが、このサイトは増え続けるサービスに対する一元的なアクセスを提供することを目標に実装されています。
クラッシュレポートサービスの3つの要素について、過去12ヶ月でもっとも変わったものはクラッシュレポートの取り込みパイプラインです。こうした取り込みの部分の変更は(できれば)あまり目立たない方がよいのですが、私たちはUnityで作られたすべてのゲームで利用出来るようにしたいので、重要な要素ではありました。
もともとの取り込みパイプラインは下記のような形で実装されていました:
Editor Plugin -> Node -> SQS + DynamoDB -> Rails -> MySQL
エディターのプラグインが例外をモニターして、バッチ処理を行ってからNodeに送信します。Nodeはイベントを受け付けたらDynamoDBにこれを保存し、SQSメッセージをRailsに送ってDynamoのどこからイベントを検索するかを指示します。RailsはこれをうけてDynamoに戻り、処理をを行ってデータをMySQLに保存します。このワークフローはセットアップが簡単だったのですが、あまりエレガントなやり方とは呼べませんでした。
その時点では、SQSは結構小さなメッセージ容量の制限(256KB)を持っていました。すべてのサイズの例外を保持できるわけではなかったのです。これがSQSメッセージが単にDynamoのどこにイベントが記録されているかだけを指示する様な構造になっていた理由です。SQSはその後メッセージ容量の制限を2GBまで拡大したので、例外を保存する容量が足りないという私たちの問題は解消されました。当初、私たちはすべてのイベントをDynamoに保存し、もしなにか巨大なポカをやらかしてしまってもイベントをリプレイすればすべてのデータを再インポートして戻せるようにしていました。
GDC2015で私たちはこのハックウィークプロジェクトをサービスとして立ち上げたのですが、全く予想を上回る状況になってしまいました。私たちはだいたい1日数千程度の例外を受け取る前提でいたのですが、実際には数百万件が送られてきました。わたしたちは毎秒数千の例外を送ってくる幾つかのプロジェクトに対してリミットを設けなければなりませんでした。
運用面での問題を別にしても、私たちのシステムには一つ、大きなボトルネックが存在していました。SQSとDynamoに記録し、Railsで取得し、処理し、データベースに記録する時間です。Railsだけでこのシステムは例外1つあたり75ミリ秒もかかっていたのです!
ただ、このもともとのセッティングにはボジティブな側面もあって、イベントの受け入れと処理が完全に切り分けられている点はよくできていました。このデザインのおかげで一切のイベントの記録漏れを発生させることなく、プログラムの修正やプロセスの再起動といったことを行うことができました。
ざっくり言うと、クラッシュレポートの処理は下記のようなステップで成り立っています:
私はNodeがあまり好きになれなかったので、まだ学んでいない別のもの(Golang)でのリプレースに着手しました。しかし試してみたものの、改善にはまったくならないということがわかりました。Golang向けのAWSライブラリがまだ未熟だったからです。なので、取り込みパイプライン全体をリプレースして簡単にすることにしました。
わたしのゴールは大体こんな感じのことを書くことでした:
Editor Plugin -> Go -> MySQL
わたしはシンプルで高速に動作するものを求めていました。冗長なログによるディスクスペースアラートとか、酷使されたRubyのプロセスからのメモリーアラートとか、そういうのは要らないのです。最終的にプロセスはこんな感じになりました:
最初の実装はRailsからの単純な移植でした。新しい処理でも以前と同じMySQL select文を使い、同じように行を作成してカウンターをアップデートしました。
最初の最適化はレポート間で重複していたSQL文を削除することでした。これらの重複はSELECT文で、例えば ‘SELECT id FROM operating_systems where name = "Windows 7"’ といった感じのものでした。このような文はアプリケーション側で安全にキャッシュできるので、Hashicorp社の go LRU hashを活用して実装しました。さらにクラッシュのフィンガープリントに対しても同じくキャッシュによる最適化を行い、同じ例外に対して一々データベースに問い合わせなくてよいようにしました。
実現にあたって、LRUハッシュに対してそこそこ多くのロックを実装しなければならなかったので、Go的に好ましい実装とはあまり思えませんでしたが、まぁ動いたのでよしとしました。一つ行ったとことしては finer grain locksを作ったということがあり、これで異なるキーを同時にアップデートできるようにしました。
次に当たったボトルネックは書き込みに関するものでした。カウンターをアップデートする際の個々の書き込みイベントです。私のデータベースは1から100,000,000まで、それは律儀に1回ずつ書き込んでいたのです。
書き込みをバッチ処理したいということはわかっていましたが、同時に効率的にやりたいと考えていました。そこでHashicorp社の LRU ハッシュを生かして、このハッシュ機能の持つ evictフックを使うことにしました。この方法なら、クラッシュレポートが(新規のエントリーが増えることで古くなって)メモリーから落とされる時にデータベースに書き込みに行くということができます。しかしそこで「もしキャッシュ落ちを引き落とすほどの個別のクラッシュレポートが来なかったらどうしよう?」という問題があることに気がつき、LRUハッシュに生存時間(TTL)を加える別のメソッドを実装しました。
TTLは個別のエントリーに対して設定されています。この方法ならTTLによるキャッシュ落ちは個別に発生するため、いきなり何千ものキャッシュ落ちが発生して大量のデータベース書き込みが発生するという、いわゆる Thundering herd 問題を回避できます。
上記のすべての要素を取り入れることで、AWS t2.medium インスタンスのプロセスでおよそ 10,000 リクエスト/秒 を処理できるようになりました。悪くない結果ではないでしょうか。
さらに、私たちはあなたのゲームが地理的に一番近いサーバーにレポートを送れるよう、異なるリージョンにエッジサーバーを配置することも検討しています。これらのサーバーは同じバッチング処理を行い、データベースが存在するエリアに内容を転送します。どれも同じキャッシュ落ちフックを使い、データベースの呼び出しの代わりにHTTPSリクエストを使用します。
Is this article helpful for you?
Thank you for your feedback!