MongoDBを用いたモバイルゲーム開発について | サイバーエージェント 公式エンジニアブログ
こんにちは。アメーバ事業本部のブログDivでエンジニアをしている@ygenkiと申します。

今回は、2010年12月末から2011年3月にかけて開発したモバイルソーシャルゲーム(以下、モバイルゲーム)で導入したMongoDBの話をさせていただきたいと思います。

MongoDBについては、すでに当エンジニアブログで津田氏によって紹介されております。
ドキュメント指向データベース「MongoDB」

■MongoDBを使った理由
今回のゲームは私にとって2つめのモバイルゲーム開発になりました。
前回の開発ではMySQLを使用しておりましたが、モバイルゲーム開発の以下の特徴からMongoDBを採用いたしました。

○開発効率の向上
 モバイルゲーム開発は短期間での開発が求められ、ゲームという特性上仕様変更が頻繁に行われます。スキーマレスであるMongoDBはデータ構造の変更に柔軟に対応する事ができると判断しました。

○新機能、変更リリース対応
 モバイルゲームでは週に3、4回リリースを行う事も多々あります。しかし、カラム追加やインデックス追加がオンラインで行えないため、メンテナンスが必要になります。Amebaでは月に1、2回のメンテナンスが行われているため、タイミングを合わせる必要がありました。MongoDBではメンテナンスを行わずに追加する事ができます。

○柔軟なクエリ、インデックス
 MongoDBは階層構造化したドキュメント内部にもインデックスを張ることができます。KVSとしての利用も可能で柔軟なクエリ検索ができる事が魅力的でした。

○初期コストを抑えて、スケーラビリティを確保
 MongoDBには、ReplicaSets(非同期レプリケーション)とSharding(自動データ分散)が提供されています。ユーザ数が増加したらShardingを行う事も可能でサービス規模に応じた構成を組む事ができるため。


■システム構成

$サイバーエージェント 公式エンジニアブログ


サーバ構成はWeb-Appサーバ2台、tokenや情報キャッシュ用のmemcached2台、MongoDB3台の計7台になります。
MongoDBはReplicaSets構成でprimaryに書き込みを行い、2台のsecondaryに読み込みにいきます。

Shardingは組まずにReplicaSetsのみになっております。

○MongoDB 1.6.5

○Java Driver 2.3

○Spring data document
MongoDB support 1.0.0 M1


■Shardingを行わなかった理由

Shardingを行わなかった理由はサーバの台数が必要になりコストがかかるためです。
ReplicaSetsを3セット(3×3台)、mongosサーバ、configサーバが必要になるため11台必要になります。
冗長化させる場合さらに台数が必要になります。
負荷試験の結果なども踏まえて、負荷分散が必要になったタイミングで導入するのが適切と判断しました。



■ドキュメント設計

MySQLでは正規化を行うことで、テーブル構造を決定する事ができます。
一方MongoDBでは、正規化が推奨されておりません。
データを分割せずに1つのドキュメントとして保持した方が、データが同じ場所に保存されサーバ間の通信回数も減らせるメリットがあるからです。
そのためドキュメント設計が重要になりますが、最も苦労したポイントでもあります。

今回はまずMySQLのテーブル設計を行い、MongoDBに適した設計に変更していきました。

ドキュメント設計では「modifierオペレーション」と「データの配列による保持」が有効に使用できる設計に変更しております。

また画像のバイナリデータのキャッシュをMongoDBで行うことでデータの取得とキャッシュの取得を一度に行う事ができます。

以下では実際に例を挙げてみたいと思います。



○modifierオペレーション

モバイルゲームでは、レベルアップや経験値を増やすなどのインクリメント操作が頻繁に行われます。
MongoDBではmodifierオペレーションを利用する事で容易に対応が可能です。



> db.userStatus.findOne({"userId" : 2013001})
{
"_id" : ObjectId("4d82e95a7a92571409f258dd"),
"battery" : 80,
"point" : 10000,
"smile" : 82,
"sumModel" : 41,
"sumPicture" : 22451,
"sumSales" : 1250,
"userId" : 2013001
}
>


バッテリーを10消費して、写真撮影を行った場合。

> db.userStatus.update({"userId" : 2013001},{$inc : {"battery" : -10,"sumPicture":1}})


> db.userStatus.findOne({"userId" : 2013001})
{
"_id" : ObjectId("4d82e95a7a92571409f258dd"),
"battery" : 70,
"point" : 10000,
"smile" : 82,
"sumModel" : 41,
"sumPicture" : 22452,
"sumSales" : 1250,
"userId" : 2013001
}
>



このmodifierオペレーションは、階層構造になっている場合でもアトミックな操作が行えます。
area2のクエストをクリアしてレベルが上がり、「questLevel」と「area2」の数字をインクリメントする場合が以下になります。


> db.user.quest.findOne()
{
"_id" : ObjectId("4e42208e97ed78c7a7f2f738"),
"userId" : 2013001,
"questLevel" : 4,
"quest" : {
"area1" : 3,
"area2" : 1,
"area3" : 2
}
}
>



> db.user.quest.update({userId:2013001},{$inc : {level:1,"quest.area2":1}})



> db.user.quest.findOne()
{
"_id" : ObjectId("4e42208e97ed78c7a7f2f738"),
"userId" : 2013001,
"questLevel" : 5,
"quest" : {
"area1" : 3,
"area2" : 2,
"area3" : 2
}
}
>




○データを配列で保持する

ユーザがモデルの撮影を行った情報を保持するコレクションになります。
一人のモデル(modelId)が複数のエリアに出現するため、モデルの状態(mcId)によってコンプリートしたか(compFlg)、振り向いてもらえたか(talkFlg)を配列で保持しています。
このデータは参照が多いため、userIdとmodelIdをkeyとして取得できる事がメリットになります。


{
"_id" : ObjectId("4d7dcbe97ccbb19ad8ac09b9"),
"modelId" : 180,
"modelDetails" : [
{
"compFlg" : 0,
"talkFlg" : 1,
"mcId" : 188
},
{
"compFlg" : 0,
"talkFlg" : 1,
"mcId" : 189
}
],
"userId" : 1022962,
"telFlg" : 0,
"status" : 0
}



ここで、新しいエリアでmodelId:180の人に出会えた場合、modelDetailsの配列にデータを追加します。


db.user.model.update({"userId" : 1022962,"modelId" : 180},{$push : {"modelDetails" : {"compFlg" : 0,"talkFlg" : 0,"mcId" : 200}}})



{
"_id" : ObjectId("4d7dcbe97ccbb19ad8ac09b9"),
"modelId" : 180,
"modelDetails" : [
{
"compFlg" : 0,
"talkFlg" : 1,
"mcId" : 188
},
{
"compFlg" : 0,
"talkFlg" : 1,
"mcId" : 189
},
{
"compFlg" : 0,
"talkFlg" : 0,
"mcId" : 200
}
],
"userId" : 1022962,
"telFlg" : 0,
"status" : 0
}



○画像のバイナリデータを保持する

ゲーム内で作成される雑誌は、ユーザが操作したタイトル画像と撮影したモデルの画像を合成する事で実現しています。
Flash内で操作を行うため、画像をバイナリデータとして出力する必要がありました。
画像の読み込みのオーバヘッドを減らすため、MongoDBに画像のバイナリデータを保持しております。

画像の読み込みも行わず、一度のリクエストで必要なデータが取得できるようになりました。



> db.user.edit.find({userId:3})
{
"_id" : ObjectId("4d8c77694ca814174e7e3854"),
"color" : "RED",
"companyDetailId" : NumberLong(0),
"companyId" : NumberLong(0),
"coverId" : "4d8c7ebd4ca85714cc7e3854",
"font" : 1,
"imgData" : BinData(2,"AAAAAA=="),
"mcId" : 184,
"nextVolImgData" : BinData(2,"YgYAAIlQTkc--省略--AAElFTkSuQmCC"),
"picId" : 7,
"scl" : 100,
"status" : 2,
"time" : "Thu Jan 01 1970 09:00:00 GMT+0900 (JST)",
"titleImg" : BinData(2,"CwoAAIlQTkcNChoKA--省略--AAAAAABJRU5ErkJggg=="),
"userEditDetailList" : [ ],
"userId" : 3,
"x" : 0,
"y" : 0
}
>




■最後に

MongoDBはReplicaSetsとShardingが魅力的です。
ただShardingを有効に活用するにはサーバ台数が必要になるため導入が難しいケースもあるかと思います。
しかしMongoDBはスキーマレス、ドキュメント・ドライバの充実、インデックスのサポートなどのメリットも多数存在します。

豊富なクエリ検索も行えるKVSとして利用する方法もあるかと思います。

実際にMongoDBとモバイルゲームの相性が非常に良く開発効率が上がり、新機能リリースも行いやすくなりました。

社内でもMongoDBを利用したプロダクトが増えてきており、私も微々たるものですがMongoDBに貢献する事ができればと思っております。

今回のモバイルゲーム開発では、Shardingを組みませんでしたが、次回の開発ではShardingを組んだサービスの開発を行いたいと思っております。


お付き合い頂きまして、ありがとうございました。