Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Railsでクラスター間のアソシエーションをマスターする:disable_joinsによるマルチデータベース対応の完全ガイド

Railsフレームワークの魅力のひとつは、モデル間のアソシエーション(関連付け)を活用できる点です。Active Recordのアソシエーションを使えば、複雑なSQLクエリを意識することなく、美しいRubyの構文でレコードのコレクションにアクセスできます。しかし、この抽象化が機能するのは、すべてのデータが1つのデータベースに存在している場合だけです。テーブルが複数のデータベースクラスタに分散した瞬間、特定の種類のアソシエーションは動作しなくなります。

本記事では、その境界線がどこにあるのか、そしてRailsがその制約の中で何を提供してくれるのかを詳しく解説します。まず問題が発生する理由と影響を受けるアソシエーションの種類を確認し、次に複数クラスタと多対多リレーションを支えるデータベース設定とモデル階層について説明します。さらに、さまざまなデータアクセスパターンがこの構成とどう相互作用するかを見ていきます。

マルチデータベース環境に特化したRailsアソシエーションのチュートリアルをお探しなら、まさにこれを読めばOKです。それ以外にも役立つトピックを多数取り上げているので、ぜひ最後までお付き合いください。

なぜデータベースが複数クラスタに分かれるのか

Railsアプリケーションがすべてのデータを単一のデータベースに保存しているうちは、Active Recordのアソシエーションは透過的に処理され、背後のSQLを意識する必要はありません。ところがデータが複数のデータベースクラスタにまたがると、この透明性は崩れます。JOINは両方のテーブルが同じデータベースサーバ上に存在することを前提としているため、クラスタをまたぐJOINを実行すると、次のようなActiveRecord::StatementInvalidエラーが発生します。

ActiveRecord::StatementInvalid (Table 'people_cluster.humans' doesn't exist)

これは設定ミスではありません。物理的なハードウェア制約です。データベースサーバは、自分がホストしていないテーブルに対してJOINを実行できないのです。この問題が顕在化するのはhas_many :throughhas_one :throughといったアソシエーションです。これらは中間テーブルを介したJOINクエリを生成するためです。一方、直接のhas_manybelongs_toはJOINを必要としないため、修正なしでクラスタをまたいでも動作します。

いつこの境界線にぶつかるのかを理解することが第一歩です。例えばUseraccountsデータベースに、Postcontentデータベースに存在する場合、User has_many :postsは問題なく動きます。しかし、中間にSubscriptionモデル(billingデータベース)を挟んでUser has_many :posts, through: :subscriptionsと定義すると、Railsはsubscriptionspostsを1つのクエリで結合しようとします。ここでクラスタ境界が問題になるのです。

3層構造のデータベース設定

モデルコードを書き始める前に、データベース設定をマルチクラスタ構成に合わせて整える必要があります。Railsではconfig/database.ymlに3層構造を定義します。トップレベルの環境キーの下にデータベース名がネストされ、さらにその下に各クラスタへの接続情報を記述します。

# config/database.yml
default: &default
  adapter: postgresql
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  primary:
    <<: *default
    database: myapp_primary_dev

  accounts:
    <<: *default
    database: myapp_accounts_dev
    migrations_paths: db/accounts_migrate

  content:
    <<: *default
    database: myapp_content_dev
    migrations_paths: db/content_migrate

production:
  primary:
    <<: *default
    database: myapp_primary_prod
    username: <%= ENV['DB_USER'] %>
    password: <%= ENV['DB_PASSWORD'] %>

  accounts:
    <<: *default
    database: myapp_accounts_prod
    username: <%= ENV['DB_USER'] %>
    password: <%= ENV['DB_PASSWORD'] %>

  content:
    <<: *default
    database: myapp_content_prod
    username: <%= ENV['DB_USER'] %>
    password: <%= ENV['DB_PASSWORD'] %>

migrations_pathsキーは省略できません。これがないと、すべてのマイグレーションがデフォルトのdb/migrateに置かれ、プライマリデータベースに適用されてしまいます。また、セカンダリデータベースごとに、対応する抽象レコードクラスを用意し、そこからモデルを継承させる必要があります。ジェネレータに--databaseフラグを渡せば、これらは自動的に生成されます。

rails generate model Subscription plan:string --database accounts

このコマンドは、AccountsRecordクラスがまだ存在しなければ作成し、生成されたSubscriptionモデルをその子クラスとして定義します。

抽象レコードクラスと接続ルーティング

抽象レコードクラスは、クエリを正しいクラスタへ振り分けるための仕組みです。各クラスはconnects_toを呼び出し、書き込み・読み込み操作がどのデータベースにマッピングされるかを宣言します。典型的なアプリケーションでは、この階層は3層になります。

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: { writing: :primary, reading: :primary }
end
# app/models/accounts_record.rb
class AccountsRecord < ApplicationRecord
  self.abstract_class = true

  connects_to database: { writing: :accounts, reading: :accounts }
end
# app/models/content_record.rb
class ContentRecord < ApplicationRecord
  self.abstract_class = true

  connects_to database: { writing: :content, reading: :content }
end

この階層を理解するには、Userモデルが良い例になります。Userはaccountsクラスタに属し、AccountsRecordを継承します。contentクラスタのモデルはContentRecordを継承し、それ以外はApplicationRecordを継承してプライマリDBに接続します。Active Recordはクエリ実行時に、この継承チェーンを辿ってconnects_toを呼び出しているクラスを探し、使用すべき接続プールを決定します。 Railsでクラスター間のアソシエーションをマスターする:disable_joinsによるマルチデータベース対応の完全ガイド

よくある失敗は、抽象クラスを使わずに個々のモデルでestablish_connectionを呼んでしまうことです。establish_connectionを呼ぶたびに独立した接続プールが開かれます。accountsデータベースに50個のモデルがあり、それぞれがestablish_connectionを呼んでいれば、同じサーバを指す50個の接続プールが生まれます。抽象クラスを使えば、それを継承する全モデルで1つのプールを共有でき、この問題を回避できます。

クラスタ横断アソシエーションの実際の仕組み

disable_joins: trueオプションは、関連するテーブルが異なるクラスタに存在する場合にthroughアソシエーションを動作させるための直接的な仕組みです。Railsのhas_manyは最もよく使われるアソシエーションであり、クラスタ境界の影響を最も直接受けるものでもあります。このオプションが指定されると、Railsは単一のJOINクエリ戦略を放棄し、代わりに2つ以上の連続したSELECT文を発行します。最初のクエリで取得したIDを、2番目のクエリのWHERE ... IN (...)句に流し込むイメージです。

以下は3つのクラスタにまたがる具体的なモデル構成です。UserがSubscriptionを経由してPostsにつながる多対多リレーションで、クラスタ横断の問題が最も露呈しやすいパターンです。

# app/models/user.rb - accountsデータベースに存在
class User < AccountsRecord
  has_many :subscriptions
  has_many :posts, through: :subscriptions, disable_joins: true
end
# app/models/subscription.rb - accountsデータベースに存在
class Subscription < AccountsRecord
  belongs_to :user
  has_many :posts
end
# app/models/post.rb - contentデータベースに存在
class Post < ContentRecord
  belongs_to :subscription
end

user.postsを呼び出すと、Railsは単一のJOINではなく、次のような2つのクエリを生成します。

-- クエリ1: accountsクラスタからサブスクリプションIDを取得
SELECT "subscriptions"."id"
FROM "subscriptions"
WHERE "subscriptions"."user_id" = 1

-- クエリ2: 取得したIDを使ってcontentクラスタから投稿を取得
SELECT "posts".*
FROM "posts"
WHERE "posts"."subscription_id" IN (4, 7, 12)

最初のクエリはaccountsデータベースに対して主キーを収集し、2番目のクエリはcontentデータベースに対して実行されます。Railsはsubscriptions側の外部キーuser_idとposts側のsubscription_idという外部キーを辿り、2つのクラスタにまたがるリレーションを解決します。どちらのクエリもクラスタをまたぐJOINを試みることはなく、最終的な結果セットはアプリケーションのメモリ上で組み立てられます。 Railsでクラスター間のアソシエーションをマスターする:disable_joinsによるマルチデータベース対応の完全ガイド Railsでクラスター間のアソシエーションをマスターする:disable_joinsによるマルチデータベース対応の完全ガイド 同じオプションはhas_one :throughでも同様に機能します。

# app/models/user.rb
class User < AccountsRecord
  has_one :profile
  has_one :avatar, through: :profile, disable_joins: true
end
# app/models/profile.rb - accountsデータベース
class Profile < AccountsRecord
  belongs_to :user
  has_one :avatar
end
# app/models/avatar.rb - contentデータベース
class Avatar < ContentRecord
  belongs_to :profile
end

user.avatarは2つのクエリを実行します。1つ目でprofile_idを取得し、2つ目でcontentクラスタからavatarレコードを取得します。

disable_joinsを明示的に指定すべきケース

Railsはクラスタ境界を自動検出してdisable_joinsを挿入してはくれません。Active Recordのアソシエーションロードは遅延評価ですが、アソシエーションのSQL戦略はモデル上でアソシエーションが定義された時点で決定されます。実際にuser.postsが実行される頃には、JOINを使うか個別クエリにするかは、アソシエーションの宣言内容によってすでに確定しています。

つまり、クラスタ境界をまたぐすべてのthroughアソシエーションには、宣言時にdisable_joins: trueが必要です。

モデルを監査する実践的な方法は、「sourceモデルとtargetモデルが異なる抽象レコードクラスを継承しているthrough:アソシエーション」を探すことです。User < AccountsRecordかつPost < ContentRecordであれば、has_many :posts, through: :subscriptionsには、Subscriptionがどこにあろうとdisable_joins: trueが必要です。

クラスタ横断のEager Loading

disable_joinsオプションはアソシエーションのロード方法に影響しますが、Eager Loading(一括読み込み)戦略とクラスタ横断データの相互作用までは変えません。マルチデータベース環境でN+1問題を避けるには、この違いを理解しておくことが重要です。

eager_loadはクラスタ横断アソシエーションでは使えません。LEFT OUTER JOINを生成するため、通常のJOINと同じ物理的制約(両テーブルが同一サーバ上にあること)を受けます。postsが別クラスタにある状態でUser.eager_load(:posts)を実行すると、同じStatementInvalidエラーが発生します。

正しい戦略はpreloadです。アソシエーションごとに個別のクエリを発行し、リレーションはRuby側で組み立てます。これは、単一レコードに対してdisable_joinsが行うことと構造的に同一です。違いはスケールで、preloadは読み込んだすべての親レコードに対して2番目のクエリをバッチ化します。

# クラスタ横断で動作する
# クエリ1: SELECT "users".* FROM "users"
# クエリ2: SELECT "posts".* FROM "posts" WHERE "posts"."subscription_id" IN (...)
users = User.preload(:posts).all

users.each do |user|
  user.posts.each { |post| puts post.title } # 追加クエリは発生しない
end

includesは、内部でpreloadに委譲される場合には動作します。関連テーブルを参照する条件がない限り、デフォルトでそうなります。しかし、関連テーブルのカラムに触れる.where句を追加すると、includeseager_loadの挙動に切り替わり、クラスタ横断では失敗します。どちらの戦略が選ばれるか迷ったら、曖昧さを避けて明示的にpreloadを使いましょう。

# includesはpreloadに委譲されるため、クラスタ横断で動作する
User.includes(:posts).all

# where句があるためincludesはeager_loadに切り替わり、クラスタ横断では失敗する
User.includes(:posts).where("posts.published = ?", true)

# クラスタ横断でのフィルタリングはpreload + アプリケーション側の絞り込みで行う
User.preload(:posts).all.select { |u| u.posts.any?(&:published?) }
# またはロード後にアプリケーションコードでフィルタリングする

スコープ付きアソシエーションとクラスタ横断フィルタリング

マルチデータベース環境で見落としがちなのが、スコープ付きアソシエーションの挙動です。クラスタをまたぐhas_manyにスコープを定義すると、そのスコープのSQLはソース側ではなくターゲット側のデータベースに対して実行されます。

class User < AccountsRecord
  has_many :subscriptions
  has_many :published_posts,
           -> { where(published: true) },
           through: :subscriptions,
           source: :posts,
           class_name: "Post",
           disable_joins: true
end

where(published: true)句は、contentデータベースに対して実行される2番目のクエリに付加されます。これは正しい挙動で、スコープ内でターゲットテーブルのカラムを参照しても問題ありません。一方で、中間テーブルのカラムをそのスコープで参照することはできません。スコープ付きクエリが実行される時点で、中間クエリはすでに完了しているためです。

# subscriptions.activeはcontentデータベースのカラムではないため失敗する
has_many :active_posts,
         -> { where("subscriptions.active = ?", true) },
         through: :subscriptions,
         source: :posts,
         disable_joins: true

中間レコードでフィルタリングしたい場合は、代わりに中間アソシエーション自体にスコープを追加します。

class User < AccountsRecord
  has_many :active_subscriptions, -> { where(active: true) }, class_name: "Subscription"
  has_many :active_posts, through: :active_subscriptions, source: :posts, disable_joins: true
end

こうすればsubscriptions.activeによる絞り込みは1番目のクエリ(accountsデータベース)で行われ、アクティブなsubscriptionのIDだけが2番目のクエリに渡されます。

水平シャーディングとシャード横断アソシエーション

tenant_idのようなパーティションキーに基づいて1つの論理データベースを複数サーバに分割する(水平シャーディング)と、クラスタ横断問題に新たな次元が加わります。disable_joinsの仕組みは引き続き有効ですが、接続ルーティングがより複雑になります。

Railsはリクエスト内でシャードを切り替えるためのconnected_toを提供しています。

ActiveRecord::Base.connected_to(role: :writing, shard: :shard_one) do
  User.find(1) # shard_oneにヒットする
end

アソシエーションがクラスタとシャードの両方にまたがる場合は、シャードコンテキストとdisable_joinsオプションの両方が揃っている必要があります。shard_one上のUserが、別のcontentデータベースに存在するpostsにアクセスする場合でも、同じ2クエリ分解が必要です。

Rails 8では、実行時にシャードトポロジを把握しやすくするイントロスペクション用メソッドが追加されました。

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

  connects_to shards: {
    shard_one: { writing: :shard_one },
    shard_two: { writing: :shard_two }
  }
end

class User < ShardedBase; end

User.shard_keys # => [:shard_one, :shard_two]
User.sharded? # => true

ShardedBase.connected_to_all_shards do
  User.current_shard # :shard_one、次に:shard_two を順に評価
end

connected_to_all_shardsは、全シャードのレコードを処理する必要があるバックグラウンドジョブで特に有用です。各シャードを順番にイテレートし、ブロック実行ごとに接続コンテキストを切り替えます。

テナントベースのシャーディングでは、シャード切り替え時のlock: trueデフォルトにより、リクエスト途中の意図しないテナント移動(テナントホッピング)が防止されます。これは安全機構です。あるリクエストがテナントのシャードにルーティングされた後、アプリケーションコードは明示的にlock: falseを渡さない限り、別テナントのシャードに切り替えられません。同一テナントのシャード内における、別クラスタに触れるアソシエーションは引き続きdisable_joinsを使用します。

クラスタ横断アソシエーションのテスト

マルチデータベース構成のテストでは、テスト環境が本番のデータベーストポロジを正確に再現している必要があります。Railsのテストフレームワークはこれをサポートしていますが、設定は明示的に行わなければなりません。

database.yml内の各データベースにはtest環境のブロックが必要です。フィクスチャやファクトリベースのテストデータは、正しいデータベースをターゲットにしなければなりません。Userファクトリがaccountsデータベースにレコードを作り、Postファクトリがcontentデータベースにレコードを作る場合、両者のアソシエーションは、同一テストトランザクション内で両方のレコードがそれぞれのDBに存在するときにのみ機能します。

Railsはデフォルトで各テストをトランザクションで包みますが、そのトランザクションは接続ごとに独立しています。複数データベースの場合、各接続が独自のトランザクションを持つため、テスト終了時の自動ロールバック(クリーンアップ)は各データベースで独立して行われます。テストがaccountsにUserを書き込み、contentにPostを書き込んだ場合、両方ともロールバックされますが、それはテストフレームワークが両方の接続を認識している場合に限られます。

fixtures宣言は、モデルが正しい抽象クラスを継承していれば自動的にこれを処理します。ファクトリベースのセットアップ(FactoryBot、Fabricatorなど)では、モデル自身のconnects_toルーティングに任せることで、各ファクトリのcreate戦略が正しいデータベースに書き込むようにします。

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    # UserはAccountsRecordを継承しているため、自動的にaccounts DBに書き込まれる
    name { Faker::Name.name }
  end
end

# spec/factories/posts.rb
FactoryBot.define do
  factory :post do
    # PostはContentRecordを継承しているため、自動的にcontent DBに書き込まれる
    association :subscription
    title { Faker::Lorem.sentence }
  end
end

クラスタ横断アソシエーションが期待通りのクエリ数を発行しているか検証するには、sql.active_record通知を購読します。

# spec/support/query_counter.rb
module QueryCounter
  def assert_query_count(expected, &block)
    count = 0
    callback = ->(_name, _start, _finish, _id, payload) do
      count += 1 unless payload[:name] == "SCHEMA" || payload[:sql].start_with?("EXPLAIN")
    end

    ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
    assert_equal expected, count, "Expected #{expected} queries, got #{count}"
  end
end

単一レコードに対するhas_many :through + disable_joins: trueは、ちょうど2クエリを発行するはずです。1クエリしか観測されなければ、まだJOINが試みられており(本番の分離サーバに対しては失敗します)、N+1になっているならEager Loadingが期待どおりに機能していません。

留意すべき注意点

disable_joinsはアソシエーションのロード問題を解決しますが、クエリチェーンまで拡張されるわけではありません。複数クラスタのカラムを参照する.where.order.groupなどを、単一のActive Recordリレーション上でチェーンすることはできません。

# 動作しない:クラスタをまたいでordersのカラムでproductsを絞り込むことはできない
customer.purchased_products.where("orders.total > ?", 100)

複数クラスタのデータに基づくフィルタリングやソートが必要なクエリは、手動で分解しましょう。片方のクラスタから必要なIDや値を取得し、それをもう片方のクラスタへのクエリの入力として使います。

high_value_order_ids = Order.where(customer_id: customer.id)
                            .where("total > ?", 100)
                            .pluck(:id)

line_item_product_ids = LineItem.where(order_id: high_value_order_ids).pluck(:product_id)

products = Product.where(id: line_item_product_ids)

これはdisable_joinsが内部的に行っているのと同じ分解処理ですが、明示的に記述することで各段階でフィルタリングを適用できます。コードは冗長になりますが、クラスタ境界がRailsのアソシエーション構文の裏に隠れるのではなく、コード上に可視化されるというメリットがあります。

編集部注:本記事は2023年1月に初公開され、正確性を保つために更新されています。


  1. AppSignalでDigitalOcean上のRuby on Railsアプリを監視する:初心者向け完全ガイド

    想像してみてください。新しいRailsアプリの開発を終え、DigitalOceanのようなクラウドプロバイダーにデプロイしたばかりのところです。開発者であれば誰でも、自分の作品に誇りを感じる一方で、次のような不安や疑問が残るものです。 新しいアプリはトラフィックにどれくらい耐えられるのか 実装した最適化が実際に機能するのかどうか 目標は、最高のユーザー体験を提供することです。エラーや重要なイベントが発生した際にすぐ通知を受け取り、迅速に対処したいと考えるのは自然なことでしょう。 そんなときに役立つのが、アプリケーションを自動的に監視してくれる仕組みです。そこで登場するのがAppSignal

  2. Rails × Hotwire入門:JavaScriptを書かずにSPAのような高速Webアプリを作る方法

    ページ遷移やフォーム送信を高速化したい、複雑なページをコンポーネント単位に分割したい——しかもJavaScriptのコードを一切書かずに。そんな願いを叶えてくれるのがHotwireです。本記事では、サーバーサイドレンダリングを実現するツール「Hotwire」を活用して、Railsアプリケーションを次のレベルへ引き上げる方法を、実例を交えながら詳しく解説します。 Hotwireとは? Hotwireは、JSONの代わりにHTMLを通信経路上でやり取りすることで、JavaScriptを書かずにモダンなWebアプリケーションを構築できるフレームワークです。HTMLベースの配信により、ページの表示