この記事はRuby on Rails Advent Calendar 2016の19日目の記事です。
概要
いまゲームのAPIサーバとしてRailsを使っているのですが、dbを水平分割する必要があってgemを探していました。
最終的に自前でgemを作ったのですが、その調査内容と作成した経緯についてお話します。
要件
(なるべく)ノーメンテでスケールアウトしたい
ゲームでシャードを追加するような状況で一番想定されるのが、初期導入によるユーザ数の爆発的な増加が上げられます。
このときにメンテをするのはビジネス的な損失が非常に大きいので、なるべくスケールアウトはノーメンテでやりたいですね。
(なので、スケールインはメンテありでも問題ありません)
自動で振り分けるのでなくある程度自前でハンドリングしたい
ゲームではアイテムやスキル、フレンドなど「キャラクター*N個」のデータになるものがほとんどです。
そのため、「キャラクター*N個」の方のテーブルのレコード数が先に性能限界を迎えやすいのですが、なるべくキャラクターデータの保存されているシャード番号とキャラクターの関連しているデータのシャード番号は揃えておく方が都合が良いことが多いです(調査のときや、スケールインのときなど)。
そのため、自前のルールで振り分けるシャードを決定出来るというのが要件に上げられました。
調査
いろいろ調べて見つけたのが、クックパッドさんのmixed_gaugeというgemです。
詳しくはクックパッド開発者ブログのシンプルで移行しやすいデータベースシャーディングの記事を参照していただいたほうが早いでしょう。
mixed_gauge
mixed_gaugeは、
- 水平分割対応
- レプリケーション対応
- rails5対応
- モンキーパッチがほとんどない
- コードベースが小さい
と機能も多く、バージョンアップなども容易であるように思えたのでこれを使いたかったのですが、「シャードへの振り分けをこちらでコントロールしたい」という要件だったのでそのまま採用といきませんでした。
activerecord-sharding
もう一つ候補に上がったのが、activerecord-shardingです。
こちらは採番テーブルによるシャーディングが採用されています。
CEDEC2016のモンスターストライクを支える負荷分散手法でも紹介されており、モンストのバックエンドでも使われているため信頼性は高いでしょう。
mixed_gaugeと比べた時のメリット/デメリットとしては、
- レプリケーション機能がない
- 振り分け方式はプラグイン形式になっており採番テーブル以外でも使えるが、対応が中途半端(モンキーパッチが必要)
といった感じですね。
ただこちらも mixed_gauge
と同じくシャードへの振り分けをこちらでコントロールしたいという要件を満たせなかったため、採用にはならずでした。
activerecord-shard_for
いろいろ調べはしましたが、結局要件を満たせるgemが見つからなかったので自前で作成することにしました。
今回作成した activerecord-shard_for
は(「作成した」と言うのがおこがましいぐらいに)、前者2つのgemのコードベースを 丸パクリ 流用して作成しました。
2つのgemの良いところをピックアップして、
- 水平分割対応
- レプリケーション対応
- 振り分け方式をプラグイン形式に
- 分割方法をキー指定と範囲指定と両方を可能に
というgemになっております。
また、octopusのような using
シンタックスでシャードを明示的に指定して呼ぶことも可能です。
最も特徴的なところは「振り分けをプラグイン方式にした」ところで、要件に合わせてシャードへの振り分けルールだけを実装することが出来るようになっています。
詳しくはwikiを見てもらうのが良いですが、簡単に例を挙げるとこんな感じです。
# database.yml
production_user_001:
adapter: mysql2
username: user_writable
host: db-user-001
production_user_002:
adapter: mysql2
username: user_writable
host: db-user-002
production_user_003:
adapter: mysql2
username: user_writable
host: db-user-003
production_user_004:
adapter: mysql2
username: user_writable
host: db-user-004
# router plugin
class SimpleModuloRouter < ActiveRecord::ShardFor::ConnectionRouter
# keyには、def_distkeyに設定したカラムの値が渡される
def route(key)
key.to_i % connection_count
end
end
# initializer
ActiveRecord::ShardFor.configure do |config|
# SimpleModuloRouterを登録する
config.register_connection_router(:modulo, SimpleModuloRouter)
# シャーディングクラスタの設定
config.define_cluster(:user) do |cluster|
# unique identifier, connection name
cluster.register(0, :production_user_001)
cluster.register(1, :production_user_002)
cluster.register(2, :production_user_003)
cluster.register(3, :production_user_004)
end
end
# AR model
class User < ActiveRecord::Base
include ActiveRecord::ShardFor::Model
use_cluster :user, :modulo # 登録したシャーディングクラスタとSimpleModuloRouterを使う宣言
# idをルーティングキーとして設定
# Routerのrouteメソッドにレコードのidが渡される
def_distkey :id
def self.generate_unique_id
# Implement to generate unique id
end
before_put do |attributes|
attributes[:id] = generate_unique_id unless attributes[:id]
end
end
この例だと、 SimpleModuloRouter#route
の結果が0なら :production_user_001
に、 1なら :production_user_002
に振り分けられるようになります。
Routerクラスの route
メソッドをclusterに登録したshardのkeyに振り分けるようにするだけなので、要件にあわせて柔軟にシャードへの振り分けルールが実装可能になっているはずです。
DBを水平分割することがないのが一番ですが、水平分割する必要が出てきた際は選択肢の一つにどうでしょうか。