ActiveRecordでDBの水平分割をする話

この記事は[Ruby on Rails Advent Calendar 2016](http://qiita.com/advent-calendar/2016/ruby_on_rails)の19日目の記事です。

## 概要

いまゲームのAPIサーバとしてRailsを使っているのですが、dbを水平分割する必要があってgemを探していました。

最終的に自前でgemを作ったのですが、その調査内容と作成した経緯についてお話します。

## 要件

### （なるべく）ノーメンテでスケールアウトしたい

ゲームでシャードを追加するような状況で一番想定されるのが、初期導入によるユーザ数の爆発的な増加が上げられます。

このときにメンテをするのはビジネス的な損失が非常に大きいので、なるべくスケールアウトはノーメンテでやりたいですね。

（なので、スケールインはメンテありでも問題ありません）

### 自動で振り分けるのでなくある程度自前でハンドリングしたい

ゲームではアイテムやスキル、フレンドなど「キャラクター*N個」のデータになるものがほとんどです。

そのため、「キャラクター*N個」の方のテーブルのレコード数が先に性能限界を迎えやすいのですが、なるべくキャラクターデータの保存されているシャード番号とキャラクターの関連しているデータのシャード番号は揃えておく方が都合が良いことが多いです（調査のときや、スケールインのときなど）。

そのため、自前のルールで振り分けるシャードを決定出来るというのが要件に上げられました。


## 調査

いろいろ調べて見つけたのが、クックパッドさんの[mixed_gauge](https://github.com/taiki45/mixed_gauge)というgemです。

詳しくはクックパッド開発者ブログの[シンプルで移行しやすいデータベースシャーディング](http://techlife.cookpad.com/entry/2015/06/22/134108)の記事を参照していただいたほうが早いでしょう。

### mixed_gauge

mixed_gaugeは、

- 水平分割対応
- レプリケーション対応
- rails5対応
- モンキーパッチがほとんどない
- コードベースが小さい

と機能も多く、バージョンアップなども容易であるように思えたのでこれを使いたかったのですが、「シャードへの振り分けをこちらでコントロールしたい」という要件だったのでそのまま採用といきませんでした。

### activerecord-sharding

もう一つ候補に上がったのが、[activerecord-sharding](https://github.com/hirocaster/activerecord-sharding)です。

こちらは採番テーブルによるシャーディングが採用されています。

CEDEC2016の[モンスターストライクを支える負荷分散手法](http://cedec.cesa.or.jp/2016/session/ENG/1607.html)でも紹介されており、モンストのバックエンドでも使われているため信頼性は高いでしょう。

mixed_gaugeと比べた時のメリット／デメリットとしては、

- レプリケーション機能がない
- 振り分け方式はプラグイン形式になっており採番テーブル以外でも使えるが、対応が中途半端（モンキーパッチが必要）

といった感じですね。

ただこちらも `mixed_gauge` と同じくシャードへの振り分けをこちらでコントロールしたいという要件を満たせなかったため、採用にはならずでした。

## activerecord-shard_for

いろいろ調べはしましたが、結局要件を満たせるgemが見つからなかったので自前で作成することにしました。

今回作成した `activerecord-shard_for` は（「作成した」と言うのがおこがましいぐらいに）、前者2つのgemのコードベースを 
丸パクリ
 流用して作成しました。

2つのgemの良いところをピックアップして、

- 水平分割対応
- レプリケーション対応
- 振り分け方式をプラグイン形式に
- 分割方法をキー指定と範囲指定と両方を可能に

というgemになっております。

また、[octopus](https://github.com/thiagopradi/octopus)のような `using` シンタックスでシャードを明示的に指定して呼ぶことも可能です。

最も特徴的なところは「振り分けをプラグイン方式にした」ところで、要件に合わせてシャードへの振り分けルールだけを実装することが出来るようになっています。

詳しくは[wiki](https://github.com/yuemori/activerecord-shard_for/wiki/Connection%20Router)を見てもらうのが良いですが、簡単に例を挙げるとこんな感じです。

```yaml
# 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
```

```ruby
# 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を水平分割することがないのが一番ですが、水平分割する必要が出てきた際は選択肢の一つにどうでしょうか。