はじめに
この記事はサイバーエージェント24卒内定者 Advent Calendarの5日目です。
こんにちは、都内在住の大学生をしているDainaです。普段はゲームの開発や研究をしています。
今回はゲームのサーバーサイド開発に焦点を当てて、golangを用いたゲームサーバーのメモリキャッシュとDBシャーディングに関する内容を中心に取り扱います!
プロジェクト構成
- 言語:Golang
- フレームワーク: Echo
- DB: MySQL
- ORM: gorm
- キャッシュ:go-cache
※重要なディレクトリのみ記載
.
├─ api
| ├─ di: wire
| └─ presentation
| ├─ controller: ハンドリングを記述する
| ├─ middleware
| ├─ request: 自動生成
| ├─ response: 自動生成
| └─ router: ルーティングを記述する
├─ docs
| ├─ api: yamlを記述するとrequest, responseが自動生成される
| ├─ entity: yamlを記述するとentity, repository, daoが自動生成される
│ └─ enum: yamlを記述するとenumが自動生成される
├─ domain
| ├─ entity: 自動生成
| ├─ enum: 自動生成
| ├─ repository: 自動生成
│ └─ service: ここにロジックを記述する
└─ infra
└─ dao: 自動生成
- 本記事で扱うコード
ゲームサーバーの特徴
ゲームサーバーの特徴には下記のようなものがあります。今回はこれらの要素に直結する対策や実装方法を解説していきます。
- 予測可能なサーバー負荷
- ユーザーデータの肥大化
- マスターデータ
予測可能なサーバー負荷
ゲームはリースやイベント、アプデ等のようにアクティブユーザーが増加するタイミングが概ね決まっています。また、リリース前には事前登録や広告などでユーザーを集めるようなケースであればリリース後の規模感もつかみやすいのが特徴です。(事前登録500万達成!!みたいなやつ)
ユーザーデータの肥大化
ゲームの場合、ユーザーのアイテムボックスやスキル、経験値、所持キャラクターなどのようにユーザーアカウントに組み付くデータが膨大になりがちです。特にRPGのような概念や要素の多いタイプのゲームは肥大化しやすい傾向にあります。
マスターデータ
ゲームにはマスターデータという概念があります。マスターデータを簡単に説明すること、キャラクターやアイテムのようなゲーム自体の構成要素に関する情報のことを指します。逆にアカウント情報やユーザーが取得したアイテムの情報などはユーザーデータと呼びます。
1. マスターデータとは?
マスターデータの多くは、サーバー側で情報を管理しており、クライアントはAPIを叩いて取得したマスターデータをもとにしてスキルの開放やイベント関連の処理を行います。
- キャラクター情報
- アイテム情報
- イベントスケジュール
- 報酬設定
マスターデータの例(キャラクター)
ID | Name | Race | Type | Attribute |
---|---|---|---|---|
1 | 勇者 | 人間 | 物理 | 味方 |
2 | 姫 | 人間 | 治癒 | 味方 |
3 | 魔王 | 魔族 | 魔法 | 敵 |
マスターデータの情報は参照されるだけで原則更新されることはありません。そのためサーバー側でキャッシュの設定を行いDBとのやり取りを減らす工夫ができます。
具体的なアプローチ
- サーバー起動時または初回アクセス時にDBから取得したデータをキャッシュして2回目以降はサーバー内のキャッシュを取得する。
- CSVやJsonなどの物理ファイルをキャッシュして取得する。
どちらもサーバー内のメモリで完結させ、外部との通信を減らすことが目的です。こうすることで低負荷な通信と運用コストを実現できます。
2. キャッシュの仕組み
マスターデータのDaoメソッドを例に解説していきます。キャッシュはgo-cacheを用いて初回に取得した情報をキャッシュキーと合わせてサーバーのメモリに保持します。2回目以降はDBではなくキャッシュを参照してデータを取得します。
// github.com/game-core/gocrafter/blob/main/infra/dao/master/item/item_dao.gen.go
// FindByName アイテム名でItemを取得する
func (d *itemDao) FindByName(Name string) (*item.Item, error) {
// キャッシュを参照する
cachedResult, found := d.Cache.Get(dbChashe.CreateCacheKey("item", "FindByName", fmt.Sprintf("%s_", Name)))
if found {
if cachedEntity, ok := cachedResult.(*item.Item); ok {
return cachedEntity, nil
}
}
// 存在しない場合はDBを参照する
entity := &item.Item{}
res := d.Read.Where("name = ?", Name).Find(entity)
if err := res.Error; err != nil {
return nil, err
}
// キャッシュキーをセットする
d.Cache.Set(dbChashe.CreateCacheKey("item", "FindByName", fmt.Sprintf("%s_", Name)), entity, cache.DefaultExpiration)
return entity, nil
}
3. シャーディングとは?
ゲームのサーバーにおける負荷特性としてリリース直後の負荷(スパイク)や、膨大なユーザーデータの更新を必要とするケースが挙げられます。こうしたケースではマスターデータデータのキャッシュだけでは更新系によるDBの負荷やサーバーがアクセス負荷に耐え切れず遅延やサーバーダウンの原因となります。
これらに対する負荷対策としてユーザーDBのシャーディングを行うことが一般的です。シャーディングとは複数のDBに格納するデータを振り分けることで、データの参照速度を速めることができます。
具体的なアプローチ
- アカウントIDや専用の振り分けIDを起点にしてシャーディングする。
- Cloud Spanner等のフルマネージドサービスを利用する。(シャーディングからの解放!!)
AWSのRDSやAuroraのようなRDBを使用している場合はアプリケーション側でシャーディングのロジックを追加して対応することが一般的だと思います。
または、Cloud Spannerのようなフルマネージドサービスを利用することで、アプリケーション側ではシャーディングを意識せずに開発・運用を行うことができます。
4. シャーディングの仕組み
ユーザーアカウント作成時に下記のServiceメソッドを呼び出すことでシャードキーを発行しています。以降、発行したシャードキーをリクエストに含めることで自動的に参照・保存先のDBを振り分けてくれます。
// github.com/game-core/gocrafter/blob/main/domain/service/api/shard/shard_service.go
// GetShard シャード設定を取得する
func (s *shardService) GetShard() (*response.GetShard, error) {
// transaction
tx, err := s.transactionRepository.Begin()
if err != nil {
return nil, err
}
defer func() {
if err := s.transactionRepository.CommitOrRollback(tx, err); err != nil {
log.Panicln(err)
}
}()
ss, err := s.shardRepository.List(64)
if err != nil {
return nil, err
}
if len(*ss) == 0 {
return nil, errors.New("failed to shardRepository.List")
}
shards := make(response.Shards, len(*ss))
minShard := s.getMinShard(ss, shards)
minShard.Count++
if _, err := s.shardRepository.Save(minShard, tx); err != nil {
return nil, err
}
return response.ToGetShard(200, minShard.ShardKey, &shards), nil
}
DBの設定では環境変数を参照してDBの数だけコネクションを生成しています。
// github.com/game-core/gocrafter/blob/main/config/database/gorm.go
func shardUserDB() *ShardConn {
shardCountStr := os.Getenv("SHARD_COUNT")
shardCount, err := strconv.Atoi(shardCountStr)
if err != nil {
panic(err.Error())
}
shards := make(map[string]*Conn)
for i := 1; i <= shardCount; i++ {
shards[os.Getenv(fmt.Sprintf("USER_MYSQL_SHARD_KEY_%v", i))] = userDB(fmt.Sprintf("_%v", i))
}
return &ShardConn{
Shards: shards,
}
}
実際にDaoメソッドでユーザーデータを参照・更新する際はシャードキーを用いてコネクションを振り分けています。
// github.com/game-core/gocrafter/blob/main/infra/dao/user/item/itemBox_dao.gen.go
// Create 作成する
func (d *itemBoxDao) Create(entity *item.ItemBox, shardKey string, tx *gorm.DB) (*item.ItemBox, error) {
var conn *gorm.DB
if tx != nil {
conn = tx
} else {
// シャードキーをコネクションにセット
conn = d.ShardConn.Shards[shardKey].WriteConn
}
res := conn.Model(&item.ItemBox{}).Create(entity)
if err := res.Error; err != nil {
return nil, err
}
return entity, nil
}
// FindByItemName アイテム名で参照する
func (d *itemBoxDao) FindByItemName(ItemName string, shardKey string) (*item.ItemBox, error) {
entity := &item.ItemBox{}
// シャードキーを使用してコネクションを選択する
res := d.ShardConn.Shards[shardKey].ReadConn.Where("item_name = ?", ItemName).Find(entity)
if err := res.Error; err != nil {
return nil, err
}
return entity, nil
}
まとめ
ゲームのサーバーサイド開発では、今回紹介した内容以外にもサーバー負荷と戦う工夫が多くあります。こうした工夫を盛り込んで大勢のユーザーが快適にプレイできる環境を目指すのがゲーム開発におけるサーバーサイドの役割であり醍醐味だと思います。
ゲームのサーバーサイドというとインディーズや個人でゲーム開発をしている場合は、ほとんど意識しない分野だと思います...
しかし!!ソーシャルゲームではゲームの品質を担保して開発・運用を行う点で、ゲームの核となる分野です。そういった意味では自分が関わったゲームがヒットした時の幸福感を最も感じられる職種だと思います!!