完全公開!ソーシャルゲーム設計事例:後編

はじめに

こんにちは。樽八です。
この記事は、前記事「完全公開!ソーシャルゲーム設計事例:前編」の続きになります。

まだの方は是非合わせてお読みください。
完全公開!ソーシャルゲーム設計事例:プロローグ編
完全公開!ソーシャルゲーム設計事例:前編

前編では、弊社構築ソーシャルゲームにおける。コンテンツ生成Webサーバの構成とキャッシュまわりについて紹介いたしました。
いよいよ後編では、データベース構成とシステム全体の冗長化に関して記述させていただきます。
本丸であり、少々長くなりますがお付き合い下さい。

どうやって要求を達成したか:後編

データ保存レイヤの最適化について

  • サービスで扱う各種のデータをどういうデータとして分類したか。
  • 上記のそれぞれに分類されたデータをどう扱ったのか。 

はじめに:目標スループットからの逆算

以前の記事で、2000万PV/日を目標にという要求があったと記述しましたが、これを、単純計算で一日の秒数で割ると、231.5PV/秒という秒間アクセス数が算出されます。
ピーク時のアクセスを出す必要があることから、この数値を3倍くらいしたものが適当だろう(ざっくり計算)と仮定したときに、およそ700PV/秒くらいは処理をこなせる能力が必要となります。
ここで、本コンテンツはゲームコンテンツだということを考えると、ほぼ全てのページで何らかのデータ更新処理は発生しうると仮定されるため、DBへの秒間の更新トランザクション量は700commit/secが必要となります。
ここまでが大前提です。

ここで、CPU2コア(HT換算で4コア)で、HDDが10000rpmの能力をもったMySQLサーバがあったと仮定します。この時、InnoDBの秒間更新トランザクション量の最大は理論上およそ166commit/秒です。
今回弊社で用意したDBはCPU8コア(HT換算16コア)でHDDの速度は同じでした。この時の理論上の秒間更新トランザクション量は、、、実は2コアのCPUを使ったときと同じで、166commit/秒を超える事ができないんです。(注1)
シャーディング(ユーザー区分毎に別のDBを利用する方式)を利用したとしても、5セットのマスタサーバ/スレーブサーバのペアとして、大量のサーバを準備する必要があります。
この時点で、単純にMySQLに全部突っ込めばいいじゃん!方式は取れない(注2)ことが決定しました。
弊社では初めての採用となるのですが、いわゆるNoSQLにも手を出して、データ属性ごとに格納するサーバを分けるという大方針が出来上がりました。

注1:1分間に1万回転するHDDにデータの記載をできる回数の上限は、1分間に1万回(=秒間166回)までという制約に基づいています。
データ保存ストレージとして、SSDを採用した場合や、ストレージへの遅延書き込みを有効にした場合はこの制約から逃れて、より高速の書き込みを行う事ができるようになります。
注2:現在であれば、DeNA様の提供されている、HandlerSocket plugin for MySQLなどを利用するという選択も可能かと思われます。

いろいろ検討した結果、最終的に、以下の構成としました。

MySQL5.1(7/28訂正)
  • シングルマスタマルチスレイブ構成
  • HDDでは心もとないので、マスタサーバのみSSDを採用(注2)
  • MySQL5.1.x(7/28訂正)環境では、マルチコア時の同時コミット性能の担保の為、InnoDB Pluginは必須
  • 利用エンジンを選ぶことにより、トランザクション処理をさせることが可能

一つのキーから複数のレコードを検索する必要のある場合や、データの横断検索をする必要がある場合に利用しました。
また、データの信頼性が高いことから、信頼性が必要とされるデータの格納に利用しました。
KVSと比較するとどうしても書き込み性能が劣ることから、更新の多いデータに関してはできるだけ利用しないようにして全体のスループットを稼ぎました。

KumoFS(注1)
  • 数あるKVS(注3)の一種。
  • 主キーを特定可能なデータに関しては参照、更新共にMySQLに対してオーダーが1桁から2桁上の回数のアクセスが可能。
  • データのインクリメント処理を行うことができないので、同時に複数スレッドから同一のデータにアクセスされたときのデータ担保はできない。
  • トランザクション処理はできない。
  • サーバを複数台用意することで、データを分散してもつ機能を備える。
  • サーバを複数台用意することで、データを多重化してもつ機能も備える。
  • 運用中のサーバの追加をサポート

高速な読み書きができることから、横断検索が必要ないデータの格納に多用しました。

TokyoTyrant(注1)
  • 数あるKVSの一種。
  • トランザクション処理はできない。
  • データのインクリメント処理が可能。
  • サーバを複数台用意することで、データを多重化してもつ機能を備える。
  • サーバを複数台用意することで、参照負荷を下げる事ができるが、データは全てのサーバで同一データのコピーとなるため、スケールアウト要件を満たすことができない。
  • 運用中のサーバの追加をサポート

スケールアウト要件を満たせないという、今回の要求仕様に対する致命的な欠陥があったため、後に述べるカウンタ専用のシステムとして利用しました。

注1:2010年6月調査時における比較です。KVSは進化が速く1年たった今は別物である可能性があります。
注2:シングルマスタマルチスレイブ構成の時に、マスタサーバのみHW性能を強化するというのは禁じ手ではあると思っています(更新負荷が高い時にレプリケーション遅延で身動き取れなくなるだけ)。しかし、今回に関しては過去にSSDの採用実績が無かったため、データバックアップ目的を兼ねるスレイブサーバまでSSD化してしまうことの抵抗がありました。
また、本システムにおいてはスレイブサーバ側では遅延書き込みオプションを使い、上記の166commit/秒という制約から逃れています。
注3:KVSとは、Key Value Storeの略で、他のプログラム言語で言う、mapや、hashのようなものです。keyを指定することで該当データに一瞬でアクセス出来るのですが、keyがわからないときにはデータへのアクセスルートが絶たれてしまいます。

 

扱うデータの特性を分類する

扱う必要のあるデータを以下の10種類のデータのどれにあたるのかを分類します。

  • 1 キャッシュ系データ
  • 2 管理者作成マスタ系データ
  • 3 ユーザー作成マスタデータ
  • 4 ユーザーの状態データ
  • 5 ユーザー所有物データ
  • 6 ユーザー所有物状態データ
  • 7 ログ系データ1(上限付きログモデル)
  • 8 ログ系データ2(上限無し+参照せず)
  • 9 ランキング処理用データ
  • 10 グループバトル処理用データ

一般的なサイトであった場合には、このデータ種別には分類しきれないものもありますが、(会員の購入履歴を無制限に参照したい場合など。)今回は、ゲームであるという制約のもと、ここに分類しきれないデータはそもそも持たないという強い決意で臨みます。

注:とはいえ、アーキテクトをシンプルに保つためのこういったポリシーは運用を続けるうちにだんだんと崩れていくものです。
今回は幸いにも社内案件であることもあり、エンジニアの設定したポリシーに沿った運用がやりやすい背景がありました。(代替案が受け入れやすい土壌でした。)


1 キャッシュ系データ

SNSプラットフォーム側に所属するデータのキャッシュや一時的なセッションデータをここに分類しました。
参照頻度や、挿入頻度は比較的多いデータではあるのですが、重要な特性として、「時間経過とともに自動的に失われても構わない。」(むしろ、短期間しか意味のあるデータではない。)という特性を持つため、memcache上に保存するデータとしました。

2 管理者作成マスタ系データ

管理者が作成する、ゲームの設定データや、アイテムデータ、カードマスタデータ、ショップ商品データなどが該当します。
これらのデータはユーザーから横断検索をされるデータとなる(ショップ商品一覧の検索)上、管理者の管理が行い易い必要もあります。
さらにデータ更新時にはトランザクション処理を行い、安全に更新をさせたいデータです。
これらの要件を満たすため、このデータはMySQL上で扱うこととしました。

3 ユーザー作成マスタデータ

ユーザーが作成するデータの中でも特に、○○マスタと言われる系統のデータです。
具体的には、会員マスタとなりますが、これはマスタという名前をもつトランザクションデータであり、どちらかと言えばイベントデータに分類されても良いデータになります。
会員管理用に横断検索をする必要もあり、MySQL上で扱うこととしました。
このデータには会員の基本属性のうち、「容易に変動しない属性」のみを扱います。この制約をつけることにより、データの更新頻度を低く抑えることができます。

4 ユーザーの状態データ

例として、会員のログイン状態、保有ポイントや、残り行動力、HP、攻撃力、防御力、各種の一時的なボーナスパラメータなどが含まれます。
DB設計を進める際に、ユーザーと1:1で対応する状態系のデータは全て会員マスタテーブルに格納したくなってきます。しかし、ここではぐっと我慢して別のテーブルに切りだしてしまいます。
これらのデータは入会時に一度作成された後は新規に挿入されることは少ないのですが、ユーザーのアクション毎に更新されますし、参照頻度も非常に多いデータになります。そのため参照・更新性能の高いKVS(KumoFS)への保存を行いました。
KVSへの保存Keyは、データ種別と会員IDを組み合わせた形で生成することができます。

5 ユーザー所有物データ

ここでは会員の保有するカードやアイテム、称号など、会員マスタと管理者作成マスタデータの間の対照表となる系統のデータ全般を指します。
上記の例を具体例にしますと、

  • 会員マスタとカードマスタをつなぐ「会員保有カード」
  • 会員マスタとアイテムマスタをつなぐ「会員保有アイテム」
  • 会員マスタと称号マスタをつなぐ「会員保有称号」

などになります。
これらのデータ会員IDをキーにごっそり関連データを引っ張ってくるという使い方がメインなのですが、会員一人当たりのデータ数の上限を規定できないデータとなります。
このことにより、保存場所としては出来ればMySQLに置きたいデータとなります。(KVSだと、主キーではない会員IDをキーにして複数のデータを同時に取得することができない。)
ここでデータの種類によっては更新頻度が多いデータもあるのですが、ここではMySQLに保存させるため、データ中の更新が多い部分を次に述べる「ユーザー所有物状態データ」として分離することとしました。

6 ユーザー所有物状態データ

先の例における、会員保有アイテムのうち、更新頻度が大きいデータを切り出したデータとなります。
具体的には、残りアイテム保有数などになります。
このデータの主キーは上位のテーブルの発行した主キーとし、KVS上に保存することとしました。

7 ログ系データ1(上限付きログモデル)

○他の会員から挨拶されたログを期間無制限で参照したい。

という要件があったとします。
この要件に答える解の一つは、「MySQLに突っ込む」かと思われます。
しかしながらこの解はデータの一方的な肥大化および、挿入頻度が高いという2つの問題を含んでいます。
そこで、エンジニアとしてはなんらかの制約をつけさせてもらうという提案をしたいところです。

○他の会員から挨拶されたログを過去2週間分参照したい。

この提案を了承された場合は、「MySQLに突っ込んでおいて、バッチで定期削除する」という解も考えられますが、バッチ削除時の負荷など面倒がまだまだ残ります。
この場合は、「MySQLのパーティショニングテーブルに突っ込む」が最適解と思われます。
パーティショニングテーブルに突っ込むことにより、不必要な期間のバッチでのデータ移動、削除コストが上がります。また、検索性能も該当パーティションのみの検索で済むというメリットがあります。
しかし、削除バッチが必要であることは同じですし、パーティション作成バッチまで必要になってきます。つまりまだまだ面倒ではあります。
また、MySQLに対するデータ挿入量は変わらない。という問題が残っていますし、過去2週間分のデータ量が人にってまちまちになるという問題もあります。

やはり、ゲーム的に一番スマートな制約をつけるとすると、

○他の会員から挨拶されたログを直近100件分参照したい。

という要件になるかと思います。
全てのユーザーに対して同じ量のログを見せることができるし、機能要件としてはいい落とし所ではないでしょうか。
結果として、参照される可能性のあるログは全てこちらの形式での処理としました。

この要件に対して、MySQLで解決しようとすると、以下の2つの方法が考えられます。
1. 一旦MySQL上に残しておいたログから、ユーザー毎に100件を超えた古いログをバッチで定期削除する。
2. ユーザーのアクション発生時に新たなログを追加する、その上で自分のログの件数をチェックして、100件を超えていた場合には古い方のログから削除する。
1. に関してはバッチの実行コストを考えたときに恐ろしいことになりそうです。(会員数100万人オーダとしたとき。)
MySQLで解決するという前提では2.が正解ではあるのですが、1度のアクションで2回の更新が走ってしまうし、検索コストも掛かってきますので、正直悩ましい処理です。

そこで、弊社ではこの要件をTokyoTyrantとKumoFSの組み合わせで対応することにしました。
具体的な手法を例示します。

TokyoTyrantとKumoFSの組み合わせで○他の会員から挨拶されたログを直近100件分参照したい。 という要件に答えた方法
■準備
 対象会員へのN回目の挨拶を格納したデータというものが存在するとして、そのKeyを対象会員IDとNを組み合わせて作成する。(例:会員ID=9999 1000回目の挨拶だと、aisatsu_9999_1000など)
■ある会員が挨拶されたときの処理
 1. 対象会員が挨拶された回数をTokyoTyrantでカウントアップし、その回数を取得する。(注1)
 2. 1で取得した回数でKeyを作り、そのキーでKumoFSに挨拶ログを残す。
 3. 1で取得した回数から100回前のデータは削除対象のデータなので、100少ないキーを作成し、そのキーでKumoFS上の挨拶ログを消す。
■ある会員の直近10件の挨拶を取得する処理
 1. 対象会員が挨拶された回数をTokyoTyrantから取得する。
 2. 1で取得した回数をNとすると、(N-9, N-8, N-7, N-6,,, N-1, N)に対応する10個のキーを生成する。
 3. 2で生成したキーに対してKumoFS上のデータをマルチゲット(注2)で参照する。

この手法をモデル化したことにより、「直近○○件のログを参照前提で残す」という要件に対しては、KVS上で非常に高速に処理することができました。
参照、更新共に高速に処理でき、バッチ処理も必要としません。

注1:複数のユーザーが同時にカウントする可能性があるので、TokyoTyrantでのインクリメント処理を用いて、カウント漏れが発生しないようにしています。
KumoFSをカウンタに利用してしまうと、インクリメント処理ができず、データ取得→データ変更→データ更新の流れをとるため、同時アクセス時にカウント漏れが発生する可能性があります。
注2:複数のキーを同時に問い合わせし、結果を同時に取得することができます。この場合、1回の問い合わせの実行コストはほとんど増大せずに同時に複数の結果を取得することが可能です。

8 ログ系データ2(上限無し+参照せず)

ここでは課金系のログやポイントの増減ログなどの重要データを扱います。
重要データであるため、記入頻度は少ないのですが、できるだけ信頼出来る形で、そして複数箇所に取得しておきたいデータです。
また、先程の要件に対して今度はデータ保有個数の上限がありません。
上限がないということは逆に、いつどのタイミングで消すかが規定されていないだけで、わからないということでもあります。
ただし、今度のデータは「ユーザーから参照されることはない」という強力な制約を付けています。
そのため、これらのデータをMySQLとApacheから吐き出すアプリケーションログの両方に吐き出させることにしました。
MySQL上のデータであってもユーザーからの参照を考える必要がありませんので肥大化を強く気にする必要がありませんし、インデックスを付ける必要もありません。

9 ランキング処理用データ

全ユーザーを対象としたランキングの処理は、非常に面倒なデータ処理の一つです。
リアルタイムで計算させるためには、全員のスコアを横断検索、ソートさせる必要がありますし、バッチで処理させた場合にはリアルタイムな結果を見せることができなくなりますし、そもそもそのバッチ処理させる為の仕組みを準備する必要があります。
この面倒な処理に対して、下記の2つの制約を付けることによりリアルタイムでの処理が可能なようにしました。

  • ランキングを表示する人数を限定する。
    (上位100件など)
  • ランキング対象となるスコアは増加するが減少しない
    (ユーザーのアクションにより、ランクインすることはあるが、ランクアウトはしない)

○上位100人のランキングをリアルタイムで参照したい という要件に答えた方法
■準備
 スコアの高い人のみを格納するランキング者テーブルを一つ準備する。
 ランキング者テーブルにはスコアは記載されているが順位は記載されていない。
■ある人がスコアを獲得した場合
 対象者がそもそも上記のランキング者テーブルに含まれていた場合
  →対象者のスコアを書き換える
 対象者がランキング者テーブルに含まれていなかった場合
  →ランキング者テーブルのレコードが100件以下だった場合は無条件で追加する。
  →ランキング者テーブル中の最低スコアを超えた場合は入れ替えを行う
■ランキングを表示したい場合
 上記、ランキング者テーブルをスコアでソートして表示する。

この方法を用いると、参照頻度こそ高いものの更新頻度に関しては上位の100人付近のアクションによってしか更新されないという制約がつくため、かなり低くなりMySQLで簡単に扱うことのできるデータとなります。
また、データ総量も非常に少なく抑えられますので、参照にソート作業を伴うとはいえ、システム的な負荷が問題になることはありません。

10 グループバトル処理用データ

これは、S-in後しばらくして導入された5vs5のグループバトル専用のデータ種別となりました。
今回導入したグループバトルは1回10分で敵味方入り乱れて戦うゲームです。
グループバトルでは、データの信頼性は低くても構わないものの、ユーザーを短期間のバトルに合わせて誘導させたことと、グループの戦況全てを同時に扱う必要があったため、更新頻度、参照頻度共に高く、かつ複雑な形式のデータを扱わなければなりませんでした。
そのため、それまでの9つの分類とは別に、最悪、飛んでしまってもごめんなさい。というスタンスでMySQLのmemoryストレージエンジンを利用したテーブルを利用し、バトルイベント毎に破棄するという手法を取りました。

全てオンメモリで動作するために、ストレージの性能に左右されず、また複数の検索キーを貼った上での横断検索も可能という特性を持ちます。

memoryストレージエンジンを利用する場合、max_heap_table_size システム変数を規模に応じて大きくさせなければならないのですが、マスタサーバ側で行った変更はスレイブサーバにはレプリケーションされないことに注意する必要があります。

各サーバの冗長化(+負荷分散)について

最後になりますが、今回のサービスにおいて利用したサーバそれぞれにおける冗長化についての簡単な説明をして終わりたいと思います。
各サーバに関して、全ての場所を冗長化しており、偶発的に発生したサーバの単体トラブルがサービス停止や以降のサービス提供不能な事態を引き起こさないようにされていました。

  • ロードバランサ
    商用ロードバランサをHAホットスタンバイ構成(注1)で2台並べました。
  • リバースプロクシ
    squidサーバを並列(注2)に2台並べました。
    ロードバランサから死活監視されており、死亡時は切り離されます。
  • XHTML生成Webサーバ。
    S-in時、Webサーバを並列に6台並べました。
    ロードバランサから死活監視されており、死亡時は切り離されます。
    また、サービス運用中のバージョンアップにおいてはロードバランサから順次切り離す事により、安全なプログラムの更新を行う事ができました。
  • 画像合成Webサーバ
    Webサーバを並列に2台並べました。
    ロードバランサから死活監視されており、死亡時は切り離されます。
  • Flash合成Webサーバ
    物理的に2台の画像合成Webサーバに相乗りしています。
    並列構成ですので、サーバ追加により負荷分散が可能です。
  • MySQLマスタサーバ
    2台HAホットスタンバイ構成
    シングルマスタ構成であるため、将来のボトルネックになる可能性はありますが、負荷試験においては想定しうる最大限の負荷に対して何の問題もなくさばき、ボトルネックとはなりませんでした。さらに高負荷となった場合にどこまで行けるかは負荷をかけきれずに調査できなかったのですが、そこのラインを超えた際にはより高機能のサーバを準備する、スケールアウトをするか、会員毎に格納DBを分割するシャーディングのどちらかを選択する必要はありました。
    ※ただ、サービスがそこまで大ブレイクしたときには別途予算がつく筈ですので、今回は想定外としました。
  • MySQLスレイブサーバ
    並列に4台並べました。
    うち、1台は管理者による集計作業用に利用していました。
    想定外の高負荷において、ここに負荷が集中した場合であってもサーバを追加することで負荷を分散させることが可能です。
  • memcacheサーバ
    物理的に2台の画像合成Webサーバに相乗りしています。
    データはハッシュ値を元に分散して取得していますが、該当のサーバが落ちていた場合にはハッシュ値を再計算して、有効なサーバにあたるまでリトライする機構を導入しています。
    memcacheの性質上、データの保護機構は存在しません。
    性能には十分なマージがあったのですが、万が一ここに負荷が集中した場合であってもmemcacheサーバを追加することで負荷を分散させることが可能です。
  • KumoFS
    3台並べました。KumoFSはデータの冗長度を設定でき、どれかのサーバが一つ落ちてもデータの欠損が出ないようになっています。
    性能には十分なマージがあったのですが、万が一ここに負荷が集中した場合であってもKumoFSサーバを追加することで負荷を分散させることが可能です。
  • TokyoTyrant
    物理的に3台のKumoFSサーバに相乗りしました。TokyoTyrantは全てのサーバに同一のデータコピーを持つため、3台全てが同時に破壊されない限りデータの欠損が出ないようになっています。
    サーバを追加しても書き込み性能は改善されないのですが、TokyoTyrantに関してはカウンタに限定して利用するというポリシーを持っていたことと、もともと十分に数値に余裕があることからこの部分が問題になることは無いという判断を行いました。

注1:ここではHAホットスタンバイ構成とは、High Availability(高可用)のホットスタンバイ構成を指す。
バックアップ機に常に通電されているが、サービスを提供しているサーバはフロントのサーバ1台。フロントのサーバにトラブルがあった場合はバックアップサーバが速やかにサービスの提供を継続する仕組み。
注2:ここでは負荷分散構成を指す。通電中の全てのサーバが並列でサービスを提供しており、どれかのサーバがダウンした時も他のサーバがその業務をそのまま受け継ぐ。
負荷分散構成においては、高負荷時には単純にサーバを追加することにより、負荷を分散させ、スループットを上昇させることが可能です。

最後に

完全公開!といいつつ、構成の都合上省略してしまったネタもあるのですが、いかがだったでしょうか?今回の記事に関して、自分でもここまで書いちゃっても良かったかな?と思った原稿に対して、掲載が許可されたので、もし好評なようであれば負荷試験の話なども公開してもいいかなと考えていますので、コメントいただければ幸いです。
※7/27 青字部分を頂いたコメントを受けて追記しました。冗長構成の説明ばかりで、負荷分散構成としての役割の説明が抜けておりましたので補足ささせて致しました。



Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 16275217 bytes) in /home/yumeco/www/prod/wp-includes/wp-db.php on line 1171