
完全公開!ソーシャルゲーム設計事例:前編
はじめに
こんにちは。樽八です。
この記事は、前記事「完全公開!ソーシャルゲーム設計事例:プロローグ編」の続きになります。
おさらいとして、今回要求されたシステム要件(機能要件および非機能要件)
- 2000万PV/日のアクセスに耐えるシステムを作ること。
- 会員数の増減に合わせて速やかにシステム全体の構成を変更出来ること。
- 予期される障害発生に関しては、たとえスループットが落ちた状態状態であったとしても通常サービスの継続が可能であること。
- 特定の機器が障害によって復旧不可能な状態に陥っても、サービス継続に必要なデータが復旧可能であること。
- 負荷のピーク時においても、各ページの応答時間が5秒を超えないこと。
- サービスを動かしながらの頻繁なアップデートおよびデータ更新が可能であること。
どうやって要求を達成したか:前編(前記事の続き)
各種コンテンツ生成サーバの最適化について
- XHTMLコンテンツ生成Webサーバ
- 画像合成Webサーバ
- Flash合成Webサーバ
それぞれの役割と設計の説明
開発効率の問題もあり、今回はそれぞれのWebサーバの基本設計を同じとしました。
PythonもDjangoも初めてというエンジニアも多かったのですが、比較的スムーズに導入できました。
各Webサーバでの共通設計
Apache | mod_wsgiでPythonを動かしています。 |
PythonDjangoフレームワーク | ソーシャルアプリプラットフォームに対応するために、出力するXHTML内のURLの書き換えを自動的に行うなどのカスタマイズをいれています。 次回の記事で触れる予定のデータベースへのアクセスも、それぞれ抽象化しています。 |
XHTMLのコンテンツを吐き出すWebサーバ
携帯に返すコンテンツ本文をXHTML形式にて生成するサーバであり、今回の開発の中心です。
mod_ktai_info | i接続してきた携帯端末のキャリア、端末情報などを取得する弊社作成のApache moduleです。 |
画像動的合成Webサーバ
カード収集ゲームだったのですが、カード画像に攻撃力や防御力をオーバーレイ表示するために画像のリアルタイム合成を利用しました。
また、S-in後の追加リリースにおいて導入された、5vs5のグループバトルでの戦況表示用の画像表示にても利用しました。
PIL(PythonImageLibrary) | Python上で画像の動的合成を行う事ができるライブラリです。 正直、インタプリンタ言語ということもあり、実行速度に若干の不安はあったのですが、他のプロセスを起動しながら合成するよりはるかにマシだろうということでの選択。 |
Flashの動的合成Webサーバ
カードバトルの経過を表示する部分(ユーザーの入力のフィードバック無し)、ボス戦(ユーザーの入力のフィードバックあり)などで、あらかじめ用意されたFlashの一部の画像や、パラメータ、敵の名前などを置き換えるために利用しました。
※フィーチャーフォンで扱えるFlashLiteでは、外部から動的にコンテンツにパラメータを渡したり置き換えたりする機能が貧弱で、ゲームで利用するにはこういった動的なFlashの書き換え技術が必要となります。
SWF Tools | Cで書かれたSWF変換用のライブラリ群。 一部カスタマイズを行った上でPythonにバインディングして利用しました。 |
今回利用したシステムにては画像を直接置き換えることができなかったため、png画像を予めswfのmovie clipに変換しておき(これもSWF Toolsを利用)、そのmovie clipをテンプレート中で指定したmovie clipと置き換えるという手法を用いました。
キャッシュの利用ポリシーについて
高速レスポンスのためにはキャッシュの利用は必須です。当然、本システムにおいても積極的に利用しました。
- 生成されたコンテンツのキャッシュ
- リロード対策用ページキャッシュ
- 関数キャッシュ
- DBへのアクセスのキャッシュ
- SNSプラットフォームへのAPI発行
等のキャッシュポリシーについて簡単な説明をします 。
キャッシュの基本的な考え方について、今回のプロジェクトでは以下のように利用ポリシーを定めました。
- 利用することで高速化が期待できる部分では積極的に使う。
- データが消えても、通常の処理で再生成可能なデータをキャッシュ対象とする。
- 冗長化されたキャッシュサーバそのものが何らかの理由で使えなくなったときは潔く諦める。
3番目の項目ですが、本来はキャッシュサーバが使えなくなった時であっても2番目の項目による処理が走るので問題ないはずなのですが、実際にはキャッシュが使えない状態では実用にならないスループットしかでないという問題もあり、いっそのこと諦めることとしました。
生成されたコンテンツのキャッシュ
基本的にゲームコンテンツであるため、静的なニュースページなどと異なり、XHMLのコンテンツに関しては全てリクエスト毎に異なるコンテンツを生成する必要があります。
そのため、キャッシュ可能なコンテンツは動的に生成された画像と、Flashに限られます。
上記の画像やFlashに関して、キャッシュを効かせるために以下の処理を行いました。(注1)
この処理を行うことにより、偶然同じ画像を表示することになったユーザーに関してはリバースプロクシ(注2)上のキャッシュが返答されることとなり、コンテンツ生成サーバへの負荷が軽減されます。
- コンテンツを要求する側のリクエストURLとして、同じURLでアクセスされたときに同じコンテンツを生成できるようにURLに画像やFlashの生成パラメータのハッシュ値を組み込む
- ハッシュ値からコンテンツを逆生成出来るように、ハッシュ値をkeyとするmemcacheに必要なパラメータを追記しておく(注3)
- 画像/Flash合成サーバではmemcacheから合成用パラメータを取得し、実際のコンテンツを合成して返信する(注4)
- 画像およびFlashの場合にのみリバースプロクシを経由してアクセスするようにネットワークの設定をする
注1:画像に関してはSNSプラットフォーム側が強力なキャッシュを持つようで、実際にはこの処理が必要なのはFlash生成だけでした。
注2:今回はsquidを利用しました。nginxの利用も検討したのですが、squidは弊社での実績が多かっただけでなく、実際に負荷試験を行った結果、キャッシュのミスヒット時のオーバーヘッドがほとんど無かったことが採用の決め手でした。
注3:この方法ではmemcache上にゴミが残りますが、そもそもmemcacheはゴミが残ることを許容するデータ処理方式と認識しています。
注4:URLから一意にコンテンツを生成する別の方法として、コンテンツ生成に必要なパラメータを可逆圧縮して、URLに直接埋め込む方法も考えられます。しかしこの方法では埋め込むことの出来る情報量に限界があることから、将来の拡張等の妨げになるとの判断により不採用としました。
リロード対策用ページキャッシュ
これはサービスインの時点で実装されていたキャッシュではないのですが、サービスイン後しばらくしてから、以下のようなクレームが多く寄せられるようになりました。
今日のお祓いをまだ行っていないのに、行ったことになっている。 今日の分の○○を最初にやったはずなのに「既に終わっています。」の表示がされる。 etc...
調査をしてみると、高負荷な時間帯において上流の回線の遅さにより端末のタイムアウトが発生している場合の症状だということがわかりました。
基本的に何らかのパラメータの増減を伴う更新系のページに関しては、全てのページで以下の流れの処理を行っています。
そのアクションを行う事ができる残り回数があるかの判定 →残り回数0の場合、「本日はもう出来ません。」等の表示。 残り回数がある場合、1回減らして、目的のアクションを行う。 →「○○を行いました。残り回数は○回です。」等の結果を表示する。
一方、回線の問題等でユーザーのリクエストがタイムアウトして、結果を取得できなかったとき、ユーザーは通常リロード作業を行います。
この時、サーバでは既に1回目の処理を追え、レスポンスを返して居るわけですので、プログラム的には2回目のリクエストが処理され、「本日はもう出来ません。」の方だけ表示される。という事になります。
プログラム的には不具合、バグでは無いのですが、ユーザー様から見て、バグのように見える面倒くさいケースでした。
本来は、これらの部分を全て手作業で洗いだして個別の対策コードを仕込む必要があったのですが、ページ別にあるべき挙動を再定義する必要があり、困難が想定されました。
そこで取った対策が、対象ページに関しては出力を全てキャッシュするという「リロード対策用ページキャッシュ」となります。
この機構を用いると、Pythonプログラム中で指定されたページに関して、出力を全てmemcacheにバッファしてからユーザーに返信します。
予め指定した制限時間以内に、同一リクエストパラメータによるリクエストが発生した場合(※注)に、ユーザーがリロード作業を行ったものと判定し、前回生成したキャッシュ上のコンテンツをそのまま返信します。
このことにより、通信断によるリロード等において、ユーザーの複数回のページリクエストがあった場合にもユーザーの求める結果を表示することが出来るようになりました。
また、この対策はほぼ全ての更新系ページにおいて一律に導入可能な対策であり、ページ別の挙動の定義が必要なくなりました。
注:ページ中に記載されたURL中には全てシステムで自動発番したランダムな文字列を埋め込んでいたため、新たに生成されたページ中のリンクを踏んだ上で同一ページにアクセスした場合にはこの機構は発動しません。
関数キャッシュ
Python上でmemcacheを用いた関数キャッシュを実装しました。
関数の実行結果をmemcache上に保存し、他のリクエストからの関数実行の際に、関数の引数が同じ場合は関数を実行する前にmemcache上のデータを返答します。
Pythonではdecoratorという機能を利用可能であるため、非常にスマートに関数キャッシュ機能を利用出来るようになりました。
decoratorを利用した関数キャッシュ利用例
from prjlib.django.cache.function_cache import fcache class クラス名(): -中略- @classmethod @fcache(10, 30) def 関数キャッシュを効かせたいクラスメソッド名(cls, opt1, opt2, opt3): 何か実行コストの高い処理 return 結果とすると、上記クラスメソッドを呼び出す際に自動的に関数キャッシュが利用できます。
通常のクラスメソッドとの違いは@fcache(opt1, opt2)の記載だけです。
この関数キャッシュにて指定可能なオプションは、以下の2つです。
関数の再実行を試みる期限 | 前回の関数実行時間から比較して、この時間を超えていた場合、memcache上に保存された関数の実行中フラグを確認します。 もしまだ誰もこの関数を実行中でない場合、関数実行中フラグを上げたうえで、関数の再実行を試みます。 関数の実行が完了後、関数の実行結果をキャッシュしてから関数実行中フラグを落とします。その後、関数の実行結果を返答します。 もし既に関数実行中フラグが上がっていた場合、関数の再実行は行わずにmemcache上に残された以前の実行結果あればそれを返答、無かった場合はmemcache上に実行結果が生成されるまで待ち、それを返答します。 |
関数実行結果を破棄する期限 | この時間を超えると、以前の関数実行結果を取得することは許されませんので、キャッシュが無かった場合と同じ場合の動作を行います。 |
注:負荷試験を実施するまで、この期間オプションは「関数実行結果を破棄する期間」の1つしか無かったのですが、この場合、関数実行結果を破棄するタイミングが来た瞬間に複数のプロセスから同時に関数の再実行が呼び出されてしまいます。
これが通常の実行コストの低い処理であれば問題とならないのですが、そもそも実行コストの高い処理に対して関数キャッシュを効かせるため、ある程度の実行時間がかかってしまいます。
すると、それらの処理が完了するまでは関数実行結果は更新されないため、高負荷時においては関数実行用のプロセスがどんどん溜まっていくという負のスパイラルが発生してしまいました。
例:ピーク時に秒間100回コールされるページであると仮定した場合において、一つの関数の実行に10秒かかったとします。(巨大なテーブルのソート処理など。)すると、関数実行結果の破棄タイミング以降の10秒間で1000回のクエリを発行しようとすることになります。
負荷試験後のこの改修により、上記パターンにおいてもDBへのクエリ実行は1回で済み、他のプロセスは前回に取得したデータを一瞬で参照可能となりました。
この関数キャッシュの仕組みは非常に有効で、後述のDBへのアクセスキャッシュおよびSNSプラットフォームAPIのキャッシュに大活躍しました。
DBへのアクセスのキャッシュ
そもそもDBアクセスは少ないにこしたことはありませんので、DBアクセスのキャッシュは積極的に行いました。
この結果のキャッシュは前述の関数キャッシュを用いました。
キャッシュ対象のデータは、主に以下の二つです。
マスタ系データ | 管理者によってのみ更新されるマスタ系データ。 そもそも変更される頻度があまりない。 |
ユーザーに紐づいたテーブルからのリスト抽出 | ユーザー数に比例したテーブルの検索となるため、非常に重いクエリとなるのですが、ユーザーの条件によっては同じ抽出結果を用いることが出来るため、キャッシュさせました。 具体的には、1ページ中に10件の結果が必要なページであっても、1000件分の抽出を行いその結果を関数キャッシュで共有、その結果から再度ランダムに10件取得という処理を行いました。 この処理により利用ユーザーにはサーバ負荷が高い処理と思われていた対戦相手一覧の抽出は実は内部的な負荷がほとんどかからないページとなっていました。 |
SNSプラットフォームへのAPI発行
上流のSNSプラットフォーム上のデータを参照、更新するAPIの利用はネットワーク的に外部であることもあり、全体として非常に実行時間のかかる処理となります。
APIの種別によっては実行結果をキャッシュすることを許可されていないのですが、その他のAPIは積極的に結果のキャッシュさせる必要があります。
この結果のキャッシュも前述の関数キャッシュを用いました。
例:ユーザーニックネーム、画像URL取得APIなど。
余談ですが、各APIの実行を待つ時間の調整はS-in後も最後まで難航しました。長いと全体のタイムアウトにつながる。短いと個別のAPIの実行結果が担保できない。。。。
長くなることが予想されるので、記事を分けます。
どうやって要求を達成したか:後編(以下、次号)
データ保存レイヤの最適化について
- サービスで扱う各種のデータをどういうデータとして分類したか。
- 上記のそれぞれに分類されたデータをどう扱ったのか。
各サーバの冗長化について
今回のサービスにおいて利用したサーバそれぞれにおける冗長化についての簡単な説明