💎

DB分割系 gem を撤去して ActiveRecord 7.2 にしました

に公開

モンスターストライク(以下、モンスト)のサーバーサイドアプリケーションでは Ruby を使っており、Webフレームワークには Padrino を使っています。

https://padrinorb.com

Padrino は DB とのやり取り部分に Rails の ActiveRecord を利用しています。この ActiveRecord のバージョンを 7.1 から 7.2 に上げるために、DB分割系 gem から Rails の DB分割機能ベースに置き換えて、ActiveRecord 7.2 へのアップデートを行ないました[1]。本記事ではその話を書いています。

モンストでのDB分割

モンストのバックエンドでは、DB に MariaDB を複数台で組み合わせて利用しています。垂直分割・レプリカ(read only)機能・水平分割を色々組み合わせています:

  • 約40種類ぐらいに垂直分割している
  • ほんの一部でレプリカ機能を使っている(レプリカ数は1~3台とあまり多くはない)
  • 垂直分割したうちの10種類ぐらいはさらに水平分割している(主にユーザーデータで、分割数は 2~8 とまちまち)

垂直分割には switch_point という gem を、水平分割には activerecord-turntable という gem をフォークして使っていました。どちらも、DB分割機能が登場した Rails 6 系からサポートを終了してしまったので、独自で修正をして ActiveRecord のアップデートを行なっていました。

しかし、AR7.2 でいよいよ限界が来たため、RailsのDB分割機能に置き換えて、足りない機能を可能な限り薄く独自で作ることにしました。

RailsのDB分割機能

今更解説するほどのものでもないのですが、前提知識として書いておきます。
Rails 6.0 で垂直分割、6.1 で水平分割を行うための仕組みが追加されました。

https://railsguides.jp/active_record_multiple_databases.html

垂直分割を行うには、ActiveRecord::Base を継承した「コネクションクラス」を作り、モデルクラスが継承する「コネクションクラス」を変えることで対応付けするようになってます:

class MasterDataBase < ActiveRecord::Base
  self.abstract_class = true
  connects_to database: { writing: :master_data }
end

class Item < MasterDataBase
  ...
end

レプリカ機能を使うには、database: {...} のところに :writing 以外の設定を追加します:

class FriendBase < ActiveRecord::Base
  self.abstract_class = true
  connects_to database: { writing: :friend, reading: :friend_replica }
end

この :writing:reading を Rails では role と呼んでいて、明示的に切り替えることができます:

# レプリカDBにクエリが飛ぶ
FriendBase.connected_to(role: :reading) { Friend.where(...) }

ちなみに :writing role だけ特殊で、Rails の中ではデフォルトとして扱われています。

水平分割を行う場合は、shard というのを使います:

class UserClusterBase < ActiveRecord::Base
  self.abstract_class = true
  connects_to shards: {
    shard_1: { writing: :user_shard_1 },
    shard_2: { writing: :user_shard_2 },
    ...
  }
end

shard も明示的に指定することができます:

  UserClusterBase.connected_to(shard: :shard_1, role: :writing) do
    User.where(...)
  end

現状、Rails の DB 分割機能で role や shard を自動的に切り替える仕組みはありますが、リクエスト単位(例えば、GET リクエストの場合は reading role を使うなど)でしかできません。

DB分割系gemの置き換え

まずは switch_point を Rails の機能へ置き換えました。

switch_point ではモデルの専用のメソッドを呼んで向き先を宣言するだけです:

class Item < ActiveRecord::Base
  use_switch_point :master_data

  ...
end

そのため、ひたすらこの部分をコネクションクラスへ置き換えていきます。
問題はレプリカ機能と水平分割の自動切り替えです。

レプリカ機能

モンストの場合、レプリカDBが設定されているモデルで SELECT をすると自動的にレプリカDBへ向き先が変わるような機能を作って使っていました[2]

Rails の DB分割機能には、このような仕組みはないため自作しました。

# このクラスをベースにして、コネクションクラスを宣言する
class MonstRecordBase < ActiveRecord::Base
  self.abstract_class = true

  ...

  # このメソッドを自動で切り替える時に呼ぶ
  def self.with_reading(&)
    ...
  end

  # SELECT だけど、レプリカじゃないDBに向きたい時に使う
  def self.with_writing(&)
    ...
  end

  # SELECT系で with_reading を強制するためにオーバーライド
  def self.find_by_sql(*, **, &)
    # force_writing? については後述
    # トランザクション内は、BEGIN がすでに writing role に飛んでるため自動切り替えしない
    if !force_writing? && connection.open_transactions.zero?
      with_reading { super }
    else
      super
    end
  end
end

find_by_sql の他にもいくつかのメソッドをオーバーライドしてますが、中身は同じなので割愛します(オーバーライドするメソッドが、少なくとも自分たちの用途的に十分かはテストを書いて動作確認してます)。

force_writing?​ は、with_reading​ と with_writing​ がネストした場合の挙動を制御するために使っています。ネストは、基本的に最後の with_xxx​ を優先して欲しいので、直前に with_writing​ を呼んでいるかどうかを判断する必要があります。しかし、Rails のメソッドの current_role​ をみるだけでは、デフォルトが :writing​ role のため判断ができません。仕方がないので、区別できるメソッドを用意しました:

class Base < ActiveRecord::Base
  ...
  def self.current_role_without_default
    connected_to_stack.reverse_each do |hash|
      return hash[:role] if hash[:role] && hash[:klasses].include?(Base)
      return hash[:role] if hash[:role] && hash[:klasses].include?(connection_class_for_self)
    end
    nil
  end

  def self.force_writing?
    self.current_role_without_default == ::ActiveRecord.writing_role
  end
end

connected_to_stack は Rails のメソッドで、.connected_to(role:, shard:) を呼ぶ度に role(と shard)を末尾から追加している配列を返します。最後に追加した role を探しているため、reverse_each をして探索します。ちなみにこの実装は、Rails の current_role を参考にしています。違いは、最後の nil の部分が default_role になってるかどうかだけです。つまりこれは、一度も connected_to を呼んでいなければ nil が返るため、デフォルトの :writing​ か connected_to で明示的に切り替えた :writing なのかを区別できるのです。

水平分割の置き換え

次に activerecord-turntable を置き換えました。前述した通り、Rails の DB分割機能は複数DBのコネクションを管理する仕組みと切り替えるインターフェースを用意するだけで、水平分割のアルゴリズムや(リクエスト単位以外での)自動の切り替えは用意してません。なので、その部分を自作する必要があります。

モンストの場合、水平分割は原則ユーザーデータで行なっており、その分割アルゴリズムはユーザーIDの剰余(mod)で決めるようにしてます。また、採番は専用の DB を用意して、そこに ID だけあるテーブルを用意して UPDATE table SET id=LAST_INSERT_ID(id+1) して行っていました。

自作するにあたって色々試行錯誤した結果、activerecord-turntable のように任意のフィールドの剰余をとって DB を切り替えるようにすると、関連付け(Rails の機能だと has_many​ とか belongs_to​ とか)の部分がめちゃくちゃ大変だとわかりました。
なんでもサポートする独自機能になってしまうと、activerecord-turntable のように ActiveRecord のアップデートが大変になるため、関連付けは自分たちのユースケースだけに対応するように、任意のフィールドではなくユーザーIDにだけ対応することにしました:

class UserIdClusterBase < MonstRecordBase
  self.abstract_class = true

  class_attribute :shard_key, instance_writer: false, default: :user_id

  # User だけ、user_id ではなく id を見る必要があるので
  # shard_key を任意に上書きできるようにしておく
  def self.set_shard_key(shard_key)
    self.shard_key = shard_key.to_sym
  end

  def self.connects_with_cluster_to(cluster_name)
    # connects_to shards: ... な設定をよしなに行う
  end
      
  # SequencerBase を継承したコネクションモデルを返すようにオーバーライドする
  def self.sequencer_connection_class
    nil 
  end
    
  # 採番用DB向けのコネクションクラスを定義するのに使う
  class SequencerBase < ActiveRecord::Base
    def connects_with_cluster_to(cluster_name, seq_name)
      # connects_to database: ... な設定をよしなに行う
    end
    ...
  end
end

向き先(shard)の決定は、find_shard with_shard のようなメソッドを色々用意して、ひたすらいろんなメソッドを上書きします。思いつく限りのテストコードを書いておいて、今後の AR アプデ時に対応しやすいようにしておきました。

採番処理を行うメソッドは、activerecord-turntable を参考にして with_transaction_returning_status で上書きしてます:

  def with_transaction_returning_status
    if self.new_record? && self.id.nil? && self.class.prefetch_primary_key?
      self.id = self.class.next_sequencer_value
    end
    self.class.with_shard(self.shard) { super }
  end

個々のクラスターのコネクションクラスは、こんな感じに定義します:

class UserClusterBase < UserIdClusterBase
  connects_with_cluster_to :user_cluster

  class Sequencer < SequencerBase
    connects_with_cluster_to :user_cluster, :user_seq
  end

  def self.sequencer_connection_class
    Sequencer
  end
end

class UserItemClusterBase < UserIdClusterBase
  connects_with_cluster_to :user_item_cluster

  class Sequencer < SequencerBase
    connects_with_cluster_to :user_item_cluster, :user_item_seq
  end

  def self.sequencer_connection_class
    Sequencer
  end
end

向き先が定まらない場合

実は、activerecord-turntable と大きな違いが1つあります。それは「向き先が1つに定まらない場合」の振る舞いです。例えば User.all​ とか User.count​ とか、User.where(id: [1,2,3])​ とかもです。

activerecord-turntable の場合は、全部 shard に対して同じクエリを叩いて結果をいい感じに合体します。しかし UserIdClusterBase​ では、ActiveRecord::ConnectionNotEstablished​ という例外を投げるようにしました。理由としては2つあって、実はこの「いい感じに合体」の実装が大変だったことと、過去に意図せず全 shard にクエリを投げて高負荷になったことがあった点です。例外を投げるようにすれば、(ちゃんとテストを書いていれば)意図しない高負荷を未然に防げるようになるかなと考えました。

ただ、テストなどでよく User.count​ とかを叩いてる場合があったので、意図的に叩けるメソッドとして User.count_with_all_shards​ みたいなのも用意しました。

マイグレーションの置き換え

地味に厄介だったのがマイグレーションの対応です。switch_point や activerecord-turntable のマイグレーションでは、モデルの時みたいにマイグレーションファイルに専用のメソッドを呼んで DB の向き先を切り替えます:

class CreateItems < MonstMigration
  use_switch_point :csv_master

  def self.up
    ...
  end
  
  def self.down
    ...
  end
end

MonstMigration​ActiveRecord::Migration​ を継承して、自分たち用に便利メソッドを生やしたクラスです。アプリケーション内でモデルに応じてコネクションを切り替えるように、マイグレーション中でマイグレーションファイルに応じて動的にコネクションを切り替えます。

対して Rails の DB 分割機能でのマイグレーションは、マイグレーション先の DB 毎にディレクトリを作成して、そのパスを database.yaml に設定し、大元でディレクトリと向き先のDBを切り替えてマイグレーションを実行していくのです。

Rails に乗った方が将来的には楽なんでしょうが、、、、モンストのマイグレーションファイルは2700ファイルぐらいあって。。。。このディレクトリを全部置き換えると、、、、git がパンクしちゃう😇

なので、switch_point や activerecord-turntable のコードを参考にしながら、同じような仕組み(マイグレーションファイルを読み込んでから都度向き先DBを切り替える)で動作するように実装し直しました。

AR7.2へのアップデート

最後はいよいよ ActiveRecord 本体のアップデートです。ActiveRecord のバージョンをあげてフルテストを通してみたところ、主に以下の3箇所をがっつり書き換える必要がありました:

  1. CachedSchema
  2. MonstSchemaCache
  3. Padrino のマイグレーションタスク

CachedSchema

これはモンストのイニシエよりある機能で、テーブルのスキーマ情報を Memcached へ短い期間(数分程度)だけキャッシュするというものです。そうすることで、デプロイ直後などで新しく起動した大量のアプリケーションワーカーから DB へのスキーマ情報を取得するリクエストを抑えようというものでした。

ActiveRecord::ConnectionAdapters::SchemaCache 配下のメソッドにモンキーパッチを仕込むため、AR をアプデすると修正をする必要が過去にも度々ありました。

しかし2024年の11月ごろに、スキーマ情報のキャッシュ SchemaCache をファイルに吐いておく Rails の仕組みを使うようになりました。なので、CachedSchema は不要になってました。軽く使われてるかどうかのログを本番環境で出してみて、撤去しても大丈夫そうだったので撤去しました。

MonstSchemaCache

前述した通り、2024年末からファイルへ dump した SchemaCache を利用するようになったのですが、switch_point や activerecord-turntable ではファイルに dump した SchemaCache に対応していません。その辺りをいい感じにしたのが MonstSchemaCache でした。

switch_point も activerecord-turntable も撤去したので MonstSchemaCache 自体が不要になりました。そのため、MonstSchemaCache も撤去しました。

マイグレーションタスク

マイグレーションにはモンストのバックエンドで利用してるWebフレームワークである Padrino にある Rake タスクを利用していました。しかし、これが AR7.2 に対応していませんでした :innocent:

しょうがないので、同じようなタスクを直接呼び出したのですが、、、、既存のマイグレーション方法ではだめということがわかりました・・

以下のような、一度コネクションを付け替えて、破棄して、戻す、みたいな書き方が AR7.2 からダメっぽいです:

    def with_spec_name(spec_name)
      old_config = ActiveRecord::Base.connection_db_config
      db_config = ActiveRecord::Base.configurations.configs_for(env_name: RackEnv.env.to_s, name: spec_name.to_s)
      ActiveRecord::Base.establish_connection(db_config)
      yield
    ensure
      ActiveRecord::Base.connection_handler.clear_active_connections!
      ActiveRecord::Base.establish_connection(old_config)
    end

ref https://github.com/rails/rails/issues/50120

仕方がないので、ちゃんとコネクションプールを使い回す方法(connected_to で切り替える方法)にしました(おかげで、マイグレーションが若干速くなった気がします)。

おしまい

これらを3ヶ月ぐらいかけて段階的にリリースしました。幸いにも、大きな障害は起こらず無事にアップデートが完了しました。ActiveRecord のアプデも格段に楽になりました。ただ、レプリカ機能の自動で切り替わる部分はちょっとメンテナンスがしんどそうなので、剥がして全部明示的にするでも良いかなぁっと薄々思っています。

脚注
  1. ActiveRecord 7.1 はすでに、2025年10月1日で EOL になっており、アップデート自体はそれ以前に終えています。 ↩︎

  2. これは switch-point にある機能ではなく、それ以前に使っていた db-charmer という垂直分割用gemにあった機能です。db-charmer は AR4 以降をサポートしなかったため switch_point へ移行し、足りない機能を自作していました。 ↩︎

MIXI DEVELOPERS Tech Blog

Discussion