オンラインゲームのRails複数DB戦略
自己紹介
植森 康友
所属:株式会社Aiming
役職:エンジニア
管理ツール開発
WebAPI開発
Dockerおじさん
好きなgem: rubocop
好きなメソッド: each
SNS
github: yuemori
twitter: wakaba260yen
Aiming
オンラインゲーム・ソーシャルゲームを企画・開発・運営
開発実績
剣と魔法のログレス(ブラウザ版)
剣と魔法のログレス ~いにしえの女神~(スマホ版)
ルナプリfrom 天使帝國(New!!)
etc...
今日する話
オンラインゲームの特徴
複数DBの実装
シャーディングの実装
シャーディング用のgem
オンラインゲームの特徴
数万~数十万規模の同時接続数に耐えられるシステムが要求される
トラフィックは時間や時期でかなり偏りがち
時間限定イベント
CM・広告
ユーザ数・同時接続数は突然跳ね上がることがある
大きく当たったときの負荷に耐えられないといけない
ある程度予想は可能だが予想を上回ることも
しかし、まったく当たらないこともある
オンラインゲームの要件
スモールスタート可能で
急激なユーザの流入に柔軟に対応できて
サービスをなるべく止めずに運用できて
困ったときにサーバ追加で解決したい
つまり、スケールアウト可能なシステム
オンラインゲームのデータの傾向
キャラクターを軸にしたデータが非常に多い
キャラクター数×N個
所持アイテム、フレンド、クエストの進行度など
仕様次第で1つのテーブルが膨大なデータを持つことがある
例)1人のキャラクターは同じアイテムを複数持つことが出来
る
普通のWebアプリに比べて書き込み頻度が非常に多い
オンラインゲームのデータの傾向
ダウンロード数◯000万人達成!
多くのタイトルはリセマラ数を含んでない数字
リセマラ数を含んだ場合のキャラクター数は(゚A゚;)ゴクリ
サービスが当たった場合、キャラクター数1億以上も想定
「1キャラあたり500個までアイテムを持てるようにしたい」
→ アイテムDBのレコード数が大変なことになる
こうなるとDB分割やR/W splittingだけでは対応不能
Railsの複数DB実装
DB分割
単にテーブルを別DBに存在している
巨大なテーブルを切り出すことが多い
ディスク容量不足対策など
DB分割
establish_connectionを宣言したクラスに対してconnection_pool
が作成される
継承ツリーを遡って最初に見つけたconnection_poolを使う
→ モデルごとに別々のconnectionを張るのを避けるために抽象クラスを
導入する
class DB2Base < ApplicationRecord
  self.abstract_class = true
  establish_connection :db2
end
class Db2ModelA < DB2Base
end
class Db2ModelB < DB2Base
end
R/W splitting
establish_connectionの中でremove_connectionしている
接続先を切り替えたいときにestablish_connectionすると、切り替
えのたびに再接続が発生する
→ connectionを維持したまま切り替えたい場合はR/Wそれぞれ専用の
Modelを宣言して切り替えるなどの実装が必要
class User < ApplicationRecord
  establish_connection :user_readonly
end
class User::Writable < ApplicationRecord
  establish_connection :user_writable
end
R/Wだけで良いならswitch_pointというgemを使うのが一番
Rails5以降の動き
Rails5.0からconnection_specification_nameというのが追加
同じconnection_specification_nameをもつモデル同士で
connectionを共有できる
Rails5.1で3‑level database.ymlというformatが提案されていた
諸事情により5.1には入らなかったが一部の機能がmergeされ
ている
詳しくはrailsのPRを参考に
connection_specification_name:
#24844
3‑level database.yml:
#27611
#28095
#28896
Railsのシャーディング実装
シャーディングとは
shard:破片、かけら
水平分割とも呼ばれる
master/slaveのセットを増やす
1つのテーブルを特定のルールに従って複数のDBに分散する
シャーディングの際に考えること
データ分散のアルゴリズム
shardにどういったルールでデータを振り分けるのか
いくつか方法がありそれぞれメリット、デメリットがある
IDの生成方法
当然、全シャード合わせてユニークなIDを振りたい
auto_incrementは使えない
データ分散のアルゴリズム
shard count modulo
id % shard数+ 1 = 振り分け先のshard番号
shard数が変わると計算結果が変わってしまう
マッピングテーブル
user_to_shardのようなテーブルを作る
マッピングテーブル自体のレコード数が多くなると辛い
hash modulo / mapping
ハッシュ関数などを噛ませて値の範囲を絞ってからmoduloや
マッピングを行う
explicit
instagramやpinterestが採用している方法。
idの中にシャード番号も織り込む
IDの生成戦略
採番テーブルを使う
auto_incrementを使って採番だけ行うテーブルを作る
MySQLならLAST_INSERT_IDを使う方法が公式で紹介されて
いる
Flickrやモンストなどで採用されている
UUIDを使う
 SecureRandom.uuid 
オリジナルUIDのルールを作る
twitter: snowflake
Instagram: timestamp, shard ID, auto_incrementのmodから
64bitのIDを生成
実装検討:DB分割と同じ実装パターン
class Shard1Base < ApplicationRecord
  establish_connection :shard1
  self.abstract_class = true
end
class Shard2Base < ApplicationRecord
  establish_connection :shard2
  self.abstract_class = true
end
class User::Shard1 < Shard1Base
end
class User::Shard2 < Shard2Base
end
例えばItemがshard1, shard2を見る場合、コネクションは共有でき
る
しかし同じモデルなのに継承先が違うため実装を共有できなくなる
concernを使うという手もあるがやりたくない
実装検討:R/W splittingと同じ実装パターン
class User < ApplicationRecord
  self.abstract_class = true
end
class User::Shard1 < User
  establish_connection :shard1
end
class User::Shard2 < User
  establish_connection :shard2
end
継承先が同じモデルになるため、実装は共有できる
しかし同じDBを見る違うモデル同士でコネクションは共有できなく
なる
実装検討:connectionをオーバーライドする
class Shard1Base < ApplicationRecord
  establish_connection :shard1
  self.abstract_class = true
end
class Item < ApplicationRecord
  self.abstract_class = true
end
class Item::Shard1 < Item
  def self.connection
    Shard1Base.connection
  end
end
Model.connectionをオーバーライドして向き先を変更することで
解決
ActiveRecordの挙動に手を入れないといけない
シャーディング用のgem
gemの選択肢
octopus
mixed_gauge
activerecord‑sharding
activerecord‑shard_for
octopus
シャーディング用gemの中ではスター数最大(約2k)
機能はかなり多い
シャーディング、R/W splitting、DB分割、etc..
ActiveRecordの中にかなり手を入れているためあまり使いたくない
 Octopus.rails51? のようなメソッドを見るのが辛い
associationなどにも手を入れている
mixed_gauge
cookpad製。
シンプルで移行しやすいデータベースシャーディングという公式ブログ
記事で紹介されている。
データ分散アルゴリズム:hash modulo
ID生成:ユーザが選択
多機能
R/W splitting対応
並列串刺し検索
Hash関数の指定
mixed_gauge
production_user_001:
  <<: *default
  host: db‐user‐001
production_user_002:
  <<: *default
  host: db‐user‐002
MixedGauge.configure do |config|
  config.define_cluster(:user) do |cluster|
    cluster.define_slot_size(1024)
    cluster.register(0..511, :production_user_001)
    cluster.register(512..1023, :production_user_002)
  end
end
 Zlib.crc32(key) % 1024 の結果で振り分け先を決める
最大スロット数、範囲内での振り分けなどは自由に決められる
シャード数が変わると振り分け先が変わるので、シャード追加時は
DB側の対応が必要
mixed_gauge
class User < ActiveRecord::Base
  include MixedGauge::Model
  use_cluster :user
  def_distkey :email
end
User.put!(email: 'alice@example.com', name: 'alice')
alice = User.get('alice@example.com')
alice.age = 1
alice.save!
User.all_shards
  .flat_map {|m| m.find_by(name: 'alice') }.compact
シャーディングキーを指定する
どのクラスターを使うか指定する
 put!  get! などのメソッドを使って抽象化
activerecord‑sharding
XFLAG(mixi)製。mixed_gaugeと同じほぼおなじ内部実装。
CEDECのモンスターストライクを支える負荷分散手法も参考
データ分散アルゴリズム:shard count modulo
ID生成:採番テーブル
mixed_gaugeよりちょっと機能が少ない
R/W splittingなし
activerecord‑sharding
user_sequencer: # 採番テーブル
  <<: *default
  host: user_sequencer
user_001:
  <<: *default
  host: user‐db‐001
user_002:
  <<: *default
  host: user‐db‐002
ActiveRecord::Sharding.configure do |config|
  config.define_sequencer(:user) do |sequencer|
    sequencer.register_connection(:user_sequencer)
    sequencer.register_table_name('user_id')
  end
  config.define_cluster(:user) do |cluster|
    cluster.register_connection(:user_001)
    cluster.register_connection(:user_002)
  end
end
activerecord‑sharding
class User < ActiveRecord::Base
  include ActiveRecord::Sharding::Model
  use_sharding :user, :modulo
  define_sharding_key :id
  include ActiveRecord::Sharding::Sequencer
  use_sequencer :user
  before_put do |attributes|
    unless attributes[:id]
      attributes[:id] = next_sequence_id 
    end
  end
end
シャーディングキーを指定
クラスターとアルゴリズムを指定
アルゴリズムはmoduloのみ
next_sequence_idで採番テーブルからIDを取得
activerecord‑shard_for
mixed_gauge, activerecord‑shardingを参考に実装。
内部実装は2つのgemと大部分が同じ
データ分散アルゴリズム:ユーザが選択
ID生成:ユーザが選択
拡張を追加
シャードへのルーティング方法をユーザが実装可能にした
octopusライクな using syntaxでシャードを指定してモデル
をfetch可能にした
コネクション周りはModel.connectionのオーバーライド実装
に変更
クラス名を無名クラスから動的定義(  User::ShardFor001 な
ど)にしてassociation対応可能に
activerecord‑shard_for
production_user_001:
  <<: *default
  host: db‐user‐001
production_user_002:
  <<: *default
  host: db‐user‐002
ActiveRecord::ShardFor.configure do |config|
  config.define_cluster(:user) do |cluster|
    cluster.register(0, :production_user_001)
    cluster.register(1, :production_user_002)
  end
end
設定方法はほぼmixed_gaugeと同じ
activerecord‑shard_for
class User < ActiveRecord::Base
  include ActiveRecord::ShardFor::Model
  use_cluster :user, :hash_modulo
  def_distkey :email
end
クラスターとアルゴリズムを指定
activerecord‑shard_for
ActiveRecord::ShardFor.configure do |config|
  config.register_connection_router(
    :modulo, SimpleModuloRouter) # initializerなどで登録
  config.register_connection_router(
    :mapping, TableMappingRouter)
end
ルーティングをユーザが実装して登録できる
class SimpleModuloRouter < ActiveRecord::ShardFor::ConnectionRouter
  def route(key)
    key.to_i % connection_count # shard count modulo
  end
end
class TableMappingRouter < ActiveRecord::ShardFor::ConnectionRouter
  def route(key)
    MappingTable.find_by!(key).shard_number # mapping
  end
end
現在の実装
分散アルゴリズム:マッピングテーブル
シャード追加時にデプロイ以外のオペレーション不要を重視
各データの軸となるキャラクターIDをキーにマッピング
ID生成方法:オリジナルUUID
twitterのsnowflakeを参考に独自実装
Webサーバ以外でもIDの生成が必要なためアルゴリズムを統一
まとめ
ActiveRecordの複数DB実装パターンはユースケースによって選択
自前実装を使うか、gemを使うかもユースケースによって選択
オンラインゲームでは勝機を逃さないための負荷対策が重要となる
→ 勝機を逃さないために、負荷に耐えられる設計・実装を早めに検討し
ておく
We are hiring!
AimingではRailsエンジニアを募集しています。
基盤開発
ゲーム管理ツール開発
ゲームWebAPI開発
などなど、興味があれば是非お声がけください!
ご静聴ありがとうございました
質疑応答

オンラインゲームのRails複数db戦略