Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

Ruby on Rails 6.1 の水平シャーディング対応 & Octopusからの移行事例

この記事は Akatsuki Advent Calendar 2020 の20日目の記事です。

はじめに

ゲームサーバでは大量のユーザーデータなどを取り扱うため、データベースの負荷分散のために水平シャーディング(水平分割)が行われることがあります。 アカツキでも、これまで Ruby on Rails や Elixir 等でゲームサーバを開発する中で、それぞれの方法で水平分割を行ってきています。

さて、先日リリースされた Ruby on Rails 6.1 では、待望の水平シャーディング機能が標準でサポートされました。

早速使っていきたいところですが、これまで別の方法で水平シャーディングを実現していたアプリケーションを移行するにあたってはいくつか課題があるため、 それをどう解決するかの一例をご紹介したいと思います。

また、その解決の一環で利用した Ruby の BasicObject クラスを用いた Proxy パターンについても説明します。

Ruby on Rails 6.1 の水平シャーディング機能

Ruby on Rails 6.1 の水平シャーディング機能を利用するには以下のようにします。

まず、 database.yml に複数のDB接続を定義します。 *1

development:
  user01:
    <<: *default
    database: user01
  user02:
    <<: *default
    database: user02
  user01_readonly:
    <<: *default
    host: user01-ro....
    database: user01
    replica: true
  ...

レプリカへの接続には replica: true という設定をすることで、マイグレーションや db:create などDB操作の対象外となります。

次に、shard・role (シャード名とwrite/read等の役割)とDB接続のマッピングを、 ApplicationRecord 等モデルの親となるクラスにて connects_to メソッドで設定します。

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to shards: {
    user01: { writing: :user01, reading: :user01_readonly },
    user02: { writing: :user02, reading: :user02_readonly },
    ...
  }
end

class User < ApplicationRecord
end

あとは、レコードを取得するときに、 ActiveRecord::Base.connected_to メソッドを使って shard, role を指定します。

ActiveRecord::Base.connected_to(role: :reading, shard: :user01) do
  User.find(user_id)
end

これによって、指定したDB接続に対してクエリが発行されるようになります。

取得したレコードを書き換えて save するときなど、クエリが発行される際には必ずこのブロック内で行う必要があります。そうしないと別のシャードに対してクエリが発行されてしまいかねません。

ActiveRecord::Base.connected_to(role: :writing, shard: :user01) do
  user.save!
end

※ なお上記はあくまでメソッドの使用例であり、トランザクションを全く考慮していません。 多くの場合、API単位などで一貫して connected_to ブロック内で処理することを想定しているものと思われます。

Octopusからの移行

さて、あるアプリケーションでは、これまで Octopus という外部gemを使って水平シャーディングを実現していました。 Octopusでは、上記の User の例だと以下のようにアプリケーションを記述します。 *2

user = User.using(:user01).first
...
user.save!

Octopus では各レコードがどのシャードに属すかを記憶しており、 save! などの各種メソッドを呼び出した時には、自動的にそのシャードに対してクエリを発行してくれるようになっています。

リレーションも同様で、例えば user が has_many リレーション items を持っているとき、単に user.items と書けば自動的に user の属するシャードから items の検索を行ってくれます。

user.items.where.not(expired: true)... といったようにリレーションに対して where 等をメソッドチェインでつなげた場合も、最終的にクエリが発行されるところまでシャード指定が伝わります。 これをシャードトラッキングと呼んでいます。

これにより、間違ったシャードにクエリを発行してしまう心配をしなくてすみますし、特に異なるシャードから取得した複数のレコードをまとめて処理するような時には記述が簡単になるという利点があります。

しかし、 Octopus は Rails 6.1 に対応しておらず、シャードの記述や管理の方法も異なるために、このままでは Rails 6.1 に移行するためにはアプリケーションを大幅に書き直す必要が生じてしまいます。

そこで、Octopus ライクなシャードの指定方法はそのままで、水平シャーディングのDB接続ハンドリングには Rails 6.1 のネイティヴ機能を使用する Octoball という gem を開発しました。

github.com

これを使えば、アプリケーションをほとんど書き換えることなく Rails 6.1 に移行することが可能となっています。

ただし、DB接続の管理は Rails 6.1 に任せるため、接続の定義は Octopus 独自の設定ファイルから上記の database.yml に移す必要があります。 using() の引数に指定するのは connected_to に渡す shard に相当するシンボルになります。

Proxy パターンによるシャードトラッキングの実現方法

Octoball でシャードトラッキングを実現するにあたっては、いわゆる Proxy パターンを利用しています。 *3

user.items.where ... といった記述をした場合、各メソッドは ActiveRecord::Relation の様々な子クラス等を返しており、最終的にこの結果のレコード取得が必要になった段階でクエリが構築・発行されます。

各レコードや ActiveRecord::Relation は多様なメソッドを提供しているので、これらに逐一シャードトラッキング機能を入れていくのは現実的ではありません。

そこで、最初にこれらをラップする Octoball::RelationProxy というクラスのインスタンスを生成して返すようにします。

これに対して何かメソッドが呼ばれたときには、必要に応じてシャードを ActiveRecord::Base.connected_to で切り替えてから、ラップしているオブジェクトのメソッドを呼び出します。 そして、返り値が ActiveRecord::Relation などシャードトラッキングの対象であれば、再びそれをラップして呼び出し元に返します。

これが各メソッドチェインごとに繰り返されることで、最終的にクエリが発行されるところまでシャード指定が伝搬されてきいます。

Ruby には、まさにこうした Proxy パターンを実現するために、 BasicObject というクラスが用意されています。以下ではこれについて少し紹介します。

BasicObject による Proxy パターンの実装

BasicObject クラスは、通常の Object の親である Kernel のさらに親クラスにあたり、ほとんど何もメソッドを持っていないクラスです。 従って、これに対して何かしらのメソッドを呼び出そうとすると、基本的には method_missing が呼ばれることになります。 Proxy パターンでは、 method_missing で何かしらの処理をしてからラップしているオブジェクトのメソッドを呼び出すことで、そのオブジェクトの機能を拡張します。

例えば、ナイーブな Octoball::RelationProxy の実装だと、以下のように method_missing で元のオブジェクトのメソッド呼び出し前にシャードを切り替える動作を入れます:

class Octoball::RelationProxy < BasicObject
  def initialize(rel, shard)
    @rel = rel  # ラップしているオブジェクト
    @current_shard = shard
  end

  def respond_to?(method, include_all = false)
    @rel.respond_to?(method, include_all)
  end

  def method_missing(method, *args, &block)
    ret = nil

    # シャードを切り替えてから @rel に対してメソッドを呼び出す
    ::ActiveRecord::Base.connected_to(
        role: ::Octoball.current_role, shard: @current_shard) do
      ret = @rel.public_send(method, *args, &block)

      # connected_to のブロックから ActiveRecord::Relation を
      # 返すと自動的にその場でクエリを発行してしまうので、
      # nilを返してこれを抑制する
      nil  
    end

    # retがラップする必要のあるオブジェクトでなければそのまま返却
    return ret unless ret.is_a?(::ActiveRecord::Relation) || ret.is_a?...

    # retをRelationProxyでラップして返却
    ::Octoball::RelationProxy.new(ret, @current_shard)
  end

  # Ruby3でのキーワード引数デリゲーション対応
  ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
end

BasicObject を使わない場合、例えば ActiveRecord のコード中で

if x.is_a?(ActiveRecord::Relation)
  ...

などとクラスの判別をしている箇所に、 ActiveRecord::Relation をラップしている RelationProxy が渡った場合、 RelationProxy#is_a? が呼ばれることになり、ラップしたことで結果が変わってしまいます。 一方、 BasicObject なら is_a? メソッドすら持っていませんので、 method_missing 経由で @rel.is_a? に転送され、ラップされたオブジェクトのクラスに基づいた動作になってくれるというわけです。

ちなみに最後の、ruby2_keywords の件は、Ruby2.7以前でもRuby3以降でも **kwargs をつけずにキーワード引数のデリゲーションを可能にするために付加してあります。

なお、実際の Octoball では、メソッドに渡されたブロック内でのデフォルトシャードの振る舞いを Octopus に合わせたりするためにもっと複雑なことになっています。 また、 method_missing を毎回呼ぶのは遅そうなので、最初の呼び出し時に同名の Proxy メソッドを動的に定義してしまうという方法でいくらか高速化を狙っていたりもします。

まとめ

本記事では、 Ruby on Rails 6.1 で対応した水平シャーディング機能と、Octopus を使ったアプリケーションをそこに乗せるために開発した Octoball gem について紹介しました。 また、Octoball でシャードトラッキングを実現するのに用いた Proxy パターンと、その実装のための BasicObject についても説明しました。

Octoball 自体は割とニッチな gem だと思いますが、Proxy パターンなどのテクニックが何かの参考になれば幸いです。

*1:複数DB接続の定義自体はrails6から可能になっていた機能です。

*2: この using は ActiveRecord::Base に追加されるメソッドで、 Ruby の Refinement で使われる Module#using とは関係ありません。

*3: 元になった Octopus においても Proxy パターンが用いられています。