この記事は 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 を開発しました。
これを使えば、アプリケーションをほとんど書き換えることなく 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 パターンなどのテクニックが何かの参考になれば幸いです。