blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2011.02.16 技術記事

ソーシャルゲームのためのMySQL入門その2

by iwanaga

#mysql

こんにちはこんにちは。11インチMacBook Airが欲しくてたまらないiwanagaです。 前回の記事 が幸いにもご好評を頂けた様で非常にうれしいです。嬉しくなって、ついがんばって第2弾を書いてしまいました。引き続き、ソーシャルゲームでよく使われるテーブルタイプ毎にちょっとしたテクニックを紹介していきます。

今回はちょっとライトな感じ&読み物になってしまっていますが「ユーザID単位で1つだけ持つデータ」と「パラメータなどのマスターデータ」についてご説明したいと思います。ちなみに次回はInnoDBのデータ構造の簡単な説明と複合プライマリーキーのデータについて、その次で紹介し損ねたちょっとマニアックなテクニックや性能管理のための手法を紹介することを予定しています。

その前に。。。

先日行われた JAPAN INNOVATION LEADERS SUMMIT で弊社松信が「ソーシャルゲームのためのデータベース設計」という発表を行いました。

このエントリの最後にslideshareの資料を貼っておきましたが、僕のエントリなんかよりずっとずっと勉強になるのでぜひぜひ御覧ください。データベースの教科書的な話とのことですが、僕みたいに現場叩き上げの人間は教科書的な勉強をしていないので、知識を体系的に整理するのにはうってつけの資料です。

また、31枚目の「インフラエンジニアのキャリア」という図が秀逸です。インフラエンジニアに興味のある方はぜひとも目を通して頂いてイメージをふくらませて頂きたいと思います。

ユーザID毎に1レコードしか持たないデータ

さて、それでは本題に入りましょう。前回はinsert中心のログ系のデータということでしたが、今回前半はユーザID毎に1レコードになるようなデータ、いわゆる「ユーザデータ系」のご紹介です。

レコード数はそこまで増えない

このタイプのデータでは、どう頑張ってもレコード数はゲームのユーザ数にしかなりません。さすがに巨大SNSとかになってくると何千万や何億というユーザ数になってしまうのでバカになりませんが、ソーシャルゲームではそこまでいくことは稀ですし、もしユーザが猛烈に増えたとしてもユーザ数に比例してしか大きくなりませんのでサイズについてそこまで恐れることはありません。

次回以降説明する予定の「ユーザID1つで複数レコード持つデータ」や「ソーシャルグラフ的データ」はユーザ数が増えると爆発的にレコード数が増える可能性があるので注意が必要ですが、ユーザID毎に1つしかレコードがなければそこまで注意する必要はありません。

Primary Keyはやっぱりuser_id

ユーザデータ系のデータをレンジで検索することは少ないと思います。ほとんどの場合、user_idを指定して更新・参照するパターンばかりだと思いますので素直にuser_idをPKにすると良いでしょう。次回説明予定ですが、InnoDBを利用する場合クラスターインデックスという形になるのでPK指定でのルックアップは非常に効率がよいです。

カラム毎にテーブルを分けることもアリ

user_idをキーにしたデータというのはソーシャルゲームではものすごい沢山あると思います。ニックネーム、レベル、攻撃力、経験値、あげればキリがないと思います。これらを全て1つのテーブルのカラムとしてもつのが素朴な実装ではあるのですが、それぞれのカラムが更新・参照される頻度には相当バラツキがあると思います。

例えばニックネームはそんなに頻繁に更新されることは無いですが、ほぼ全てのページでニックネームを出す設計なら参照回数は非常に多くなります。反対に経験値などは更新は頻繁に行われますが、参照される回数はそこまでではないことがあります。

そういう場合に、アクセスパターンや用途に応じてテーブルを分けておくと便利です。例えばこんな感じ。

CREATE TABLE `user_data` (
    `user_id` int(10) unsigned NOT NULL,
    `nickname` varchar(40) NOT NULL,
    `type` tinyint(10) NOT NULL,
...
    PRIMARY KEY (`user_id`)
) ENGINE=InnoDB
CREATE TABLE `user_param` (
    `user_id` int(10) unsigned NOT NULL,
    `exp` int(10) NOT NULL,
    `hp` int(10) NOT NULL,
...
    PRIMARY KEY (`user_id`)
) ENGINE=InnoDB
CREATE TABLE `event_user_param` (
    `user_id` int(10) unsigned NOT NULL,
    `event_point` int(10) NOT NULL,
    `event_status` int(10) NOT NULL,
...
    PRIMARY KEY (`user_id`)
) ENGINE=InnoDB

こうしておくとよい点は以下の様なものがあげられます。

  • 更新は低いが参照の多いuser_dataテーブルはmemcached等にどんどんキャッシュする
    • 参照の割合が非常に高いのでmemcachedの効果が最大限発揮される
  • 更新の多いuser_paramテーブルはカラムを絞ってサイズを小さく保つ
    • なるべくメモリ(InnoDBのbuffer pool)に乗るように
  • イベント用等、時限付きで使うevent_user_paramテーブルは別で持つ
    • イベント終了後にはdrop tableすることでサイズを減らせる

そして、このようなuser_idをキーでひっぱってくる様なデータには弊社樋口が開発したHandlerSocket pluginを利用すると非常に効果的です。memcachedなどのキャッシュを挟むとどうしてもRDBとキャッシュ層でのデータのズレが発生する可能性があります。2相コミットでもしない限りどちらか片方だけ更新に失敗してしまうということは原理的に避けられません。

そこで特に参照にHandlerSocketを用いることで、SQLを使ってselectするよりもはるかに高速に参照しつつ、データソースはInnoDBを直接叩いているのでデータが不正なものかどうかの心配をしなくてもよいといいことづくめになります。

DeNAではすでにHandlerSocketを本格導入しており、今までmemcachedを利用してuser_idをキーにした参照をしていた部分を数台のslave DBに向けることで高速かつサーバの集約をすることができました。memcachedを利用していた時にはDBの更新を検知してmemcachedのデータも更新するという仕組みが必要でしたが、HandlerSocketの更新はMySQLのレプリケーションに任せておけばよいのでそれも気にする必要がなく非常に運用が楽になります。もちろん、参照をslaveに向けている場合にはレプリケーションの遅延があり得ますのでその点は注意する必要があります。

パラメータなどのマスターデータ

さて、後半はゲームのパラメータなどのマスターデータについてです。マスターデータとは、例えばアイテムの名前やモンスターの出現確率など、運営側が更新する以外には更新されることのないデータを指します。

ユーザからの更新はあり得ないので、これも先のuser_data同様に基本はキャッシュさせることが重要です。いくらMySQLが速いからと言っても1リクエストの中で何度も何度も通信を行うことは非効率です。どれだけ高速になったところで最悪TCPのラウンドトリップの時間はかかってしまいますので通信する必要のないものは通信を控えるのが肝要です。

キャッシュの考え方

というわけで、ここではちょっと話を横にそれてキャッシュの簡単な考え方を紹介します。一般的なwebのシステムの場合、キャッシュをさせる層としては簡単には以下の様に分類できます。

  1. アプリケーションのプロセス単位で
  2. アプリケーションサーバ単位で
  3. アプリケーション全体で共通

一般的なwebアプリケーションではmod_perlやFastCGIと言ったアプリケーションのプロセスを永続的に起 動させておく構成にしておくと思います。その際にプロセスの持っているメモリ空間を利用して一度利用した情報をキャッシュさせておくのが1.になります。あるキャッシュが効果を発揮するのが1プロセスに限られるので効果の範囲は一番狭いですが最も高速です。あるユーザのリクエストが続けて同じプロセスにやってくることは普通は稀だと思いますのでキャッシュ効果の薄いものを沢山乗せ過ぎてプロセスのメモリが肥大化しないように注意する必要があります。

1台のサーバ上ではアプリケーションのプロセスが複数立ち上がっているのが普通だと思いますがそれらのプロセス間でキャッシュを共有するのが2.になります。実現方法としてはいくつか考えられて、共有メモリを利用する方法や、ファイルキャッシュを利用する方法があります。または、アプリケーションサーバ上にローカルなmemcachedやKyotoTycoonなどのデーモンを立ててしまうという方法もありますね。いずれのやり方でもネットワーク越しの通信は発生しませんし、キャッシュが効果を発揮する範囲がサーバ上のプロセス全体になるため、プロセス単位でキャッシュするよりヒット率は高まります。

そして3.が一般に良く使われるmemcachedなどを使ったキャッシュの仕組みになります。1度キャッシュされてしまえば、全てのリクエストでキャッシュの効果が発揮されますが、キャッシュサーバとの通信が発生してしまうのがネックになります。Consistent Hashingなどを利用して複数サーバでのキャッシュプールを形成している場合にキーの命名を間違えて単一キーへアクセスが集中すると、あるサーバにだけトラフィックが集中してしまいそのサーバの限界(トラフィックや同接数など)を超えてしまうことがあるのでキーの命名には注意が必要です。

どういう風に使い分けるか

基本的には更新頻度が低くアクセス頻度の高いものほど1.に向いていると思います。その意味でマスターデータはうってつけです。それぐらいだったら、アプリケーションのコードの中にベタッと書いてしまってもいいんじゃないかという意見もあると思いますが、あくまでもマスターデータはMySQLに持っておくことで、ゲーム内の確率などをバックエンドツールから簡単に変更することができて便利だと思います。永続化したプロセスはおそらく諸々の理由で適当なタイミングで再起動させていることが多いと思いますので再起動によってキャッシュはクリアされて最新の情報が反映されます。

プロセス単位ではそこまでキャッシュヒットしなさそうなデータや、キャッシュサイズが大きくてメモリの肥大化が気になる様なデータは2.のサーバ単位でのキャッシュをしておくと1サーバの中で1度でも使われればキャッシュされますし、メモリを節約しつつ利用することができます。また、プロセスの再起動後も継続してキャッシュを利用することもできます。ただし、キャッシュライブラリを使う場合、実装によっては思わぬバグがあったりするので安易に過信はしないことが重要です。

3.については主にリアルタイムな更新反映が必要なキャッシュに向いています。1.や2.では各プロセスや各サーバ毎でキャッシュの破棄のタイミングがバラバラになってしまいます。そんな時はmemcachedの様に一元管理してしまえば、そこを更新すればすぐに全てのリクエストに対して反映される様になります。

HandlerSocketは?

もちろんマスターデータをHandlerSocketで取るというのも一つの手だとは思いますが、マスターデータはjoinして取りたい場合などもあると思いますので意外と単純ではないことが多いと思います。なにより先述の通り無駄な通信は減らすに越したことはないですので、なるべくローカルにキャッシュすることが大規模を目指す場合には大事だと思います。

まとめ

今回はMySQL特有の話はほとんど無くなってしまい、タイトルは完全に釣りでした。すみませんすみません>< キャッシュの考え方はかなり単純化した話になっていますので適時脳内補完して皆様の管理されているシステムにご活用頂ければと思います。

次回は皆さんお待ちかねのInnoDBのお話です。あの本とあの本読んどけって説もありますがそんな時間ねぇよって人の為に3分で分かる様にまとめてみたいと思います(多分無理)。その次の性能管理やトラブルシュートのTIPSなどが本当は一番書くネタが豊富なんですがそこに辿りつくまでが長いですね!

というわけで、MySQLと楽しくソーシャルゲームを作っていきましょう!

God bless your MySQL!

参考資料

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。