はじめに
GraphQL で RDS のリードレプリカを使用することで、読み取り専用クエリの負荷を分散させることができます。本記事では、Rails + GraphQL-Ruby でリードレプリカにクエリをルーティングする実装方法と、注意すべきポイントについてまとめます。
アーキテクチャ概要
GraphQL のクエリ実行時に、読み取り専用クエリかどうかを判定し、自動的にリードレプリカまたはライターに接続を切り替えます。
GraphQL Query
↓
DatabaseConnectionTracer (クエリ種別の判定)
↓
readonly? → リードレプリカ (primary_replica)
mutation? → ライター (primary)
実装方法
1. データベース設定
まず、config/database.ymlでリードレプリカの設定を行います。
production:
primary:
<<: *default
primary_replica:
<<: *default
host: <%= ENV["GIFT_WALLET_NEO_DATABASE_READER_HOST"] %>
replica: true
2. DatabaseConnectionTracer
GraphQL の Tracer を使って、クエリ実行時にデータベース接続を自動的に切り替えます。
module Tracers
module DatabaseConnectionTracer
EVENT_NAME = "execute_multiplex".freeze
def self.trace(event, data)
if event == EVENT_NAME
multiplex = data[:multiplex]
read_query = multiplex.queries.all?(&:query?)
if read_query
ActiveRecord::Base.connected_to(role: :reading, prevent_writes: true) do
yield
end
else
ActiveRecord::Base.connected_to(role: :writing) do
yield
end
end
else
yield
end
end
end
end
この Tracer は以下のロジックで動作します:
-
execute_multiplexイベントをフック - すべてのクエリが読み取り専用(
query?がtrue)の場合、ActiveRecord::Base.connected_to(role: :reading, prevent_writes: true)でリードレプリカに接続 - 1 つでもミューテーションが含まれる場合、
ActiveRecord::Base.connected_to(role: :writing)でライターに接続
3. GraphQL スキーマへの適用
スキーマに Tracer を登録します。
class InternalAPISchema < GraphQL::Schema
# ... その他の設定 ...
trace_with Tracers::DatabaseConnectionTracer
use GraphQL::Dataloader
end
正しい使い方
Dataloader を使った関連データのロード
GraphQL で関連データをロードする際は、Dataloader のAssociationPreloaderを使用します。これにより、N+1 問題を回避しつつ、親のクエリが読み取り専用の場合、リードレプリカに接続されます。
module Types::InternalAPI
class UserType < Types::BaseObject
field :profile, User::ProfileType, null: true
def profile
dataloader.with(Loaders::AssociationPreloader, ::User, :profile).load(object)
end
end
end
この実装では、UserTypeのprofileフィールドを解決する際に、親のクエリが読み取り専用であれば、リードレプリカからデータを取得します。
やってはいけない方法
❌ Type レベルで readonly を設定する方法
以下のように、Type のメソッド内で直接readonlyを呼び出すのは推奨されません。
# ❌ 悪い例
module Types::InternalAPI
class UserType < Types::BaseObject
field :profile, User::ProfileType, null: true
def profile
ActiveRecord::Base.connected_to(role: :reading, prevent_writes: true) do
# この方法では、loader内のクエリがwriterに向けられる可能性がある
dataloader.with(Loaders::AssociationPreloader, ::User, :profile).load(object)
end
end
end
end
問題点:
-
Loader の関連先が writer にクエリされる
-
DatabaseConnectionTracerが全体のクエリをreadonlyブロックでラップしているにもかかわらず、Type 内で再度readonlyを設定すると、loader 内のデータベースクエリが意図しない接続先(writer)に向けられる可能性があります - GraphQL::Dataloader の実行コンテキストと、ActiveRecord の接続管理が競合する可能性があります
-
-
接続状態の不整合
- 親のクエリがすでに
readonlyブロック内で実行されている場合、Type 内で再度readonlyを設定すると、接続状態が不整合になる可能性があります
- 親のクエリがすでに
✅ 正しい方法
Tracer レベルで一括管理し、Type 内では Dataloader をそのまま使用します。
# ✅ 良い例
module Types::InternalAPI
class UserType < Types::BaseObject
field :profile, User::ProfileType, null: true
def profile
# Tracerが全体をreadonlyでラップしているので、そのまま使用する
dataloader.with(Loaders::AssociationPreloader, ::User, :profile).load(object)
end
end
end
まとめ
- GraphQL の Tracer を使って、クエリ実行時に自動的にリードレプリカまたはライターに接続を切り替える
- Type レベルで個別に
readonlyを設定するのは避ける(loader 内のクエリが writer に向けられる可能性がある) - Dataloader を使用する際は、Tracer レベルでの接続管理に任せる
- エラーハンドリングを適切に行い、リーダーのスケールインなどによる接続エラーに対応する
この実装により、読み取り専用クエリの負荷をリードレプリカに分散させ、パフォーマンスを向上やリーダーのスケールアウトをさせることができます。