こんにちは、エンジニアの toki (@tokai235) です。法人向け eGift サービス giftee for Business の開発をしています。普段はバックエンドやインフラのお仕事が多いですが、フロントエンドが好きです(?)。
今私が開発している giftee for Business は Rails + GraphQL Batch で構築しているんですが、おかげさまでここ数年で利用いただく方が増えているので、Read Only なクエリを Reader DB に向けるということをやりました。その中で GraphQL Batch の特性により、期待通りに Reader DB が活用されないケースを踏みました。
この記事では、この課題を解決するために GraphQL Tracer を使った実装をしたんですが、それについてお話します。
GraphQL Batch とは
GraphQL Batch gem は、GraphQL クエリ実行時に発生する N+1 問題を解決するための Ruby Gem です。複数のクエリをバッチにまとめることで1つの SQL に結合し、SQL 実行のオーバーヘッドを小さくします。
なお今回は GraphQL そのものについての詳細は割愛させてください。
以下は GraphQL Batch を利用した簡単なコード例です。Post has one User という関係で Post.user
を GraphQL で取得しようとすると N+1 回のクエリが発行されてしまうのですが、GraphQL Batch Loader を利用することでクエリが遅延発行され、1回の SQL で済むようになります。
# GraphQL Batch Loader class UserLoader < GraphQL::Batch::Loader def perform(user_ids) User.where(id: user_ids).each { |user| fulfill(user.id, user) } user_ids.each { |id| fulfill(id, nil) unless fulfilled?(id) } end end # 利用側 class PostType < GraphQL::Schema::Object field :user, UserType, null: false def user UserLoader.load(object.user_id) end end
Reader DB へクエリを向ける
スケーラビリティの向上のため、先程のクエリを Reader DB に向けてみましょう。
Rails(ActiveRecord) には connected_to
メソッドが用意されていて、これを使うとトランザクション単位でクエリを投げるデータベースを設定することができます。
def some_query_method ActiveRecord::Base.connected_to(role: :reading) do User.all end end
これをさっきのコードに当てはめると以下のようになります。
# 利用側 class PostType < GraphQL::Schema::Object field :user, UserType, null: false def user # これで Reader に向くはず! ActiveRecord::Base.connected_to(role: :reading) do UserLoader.load(object.user_id) end end end
しかし、GraphQL Batch を使っている場合、この方法では User クエリは Reader を向いてくれません。(どうして...)
これは GraphQL Batch の処理によるものです。
なぜネストされたクエリで問題が発生するのか
GraphQL Batch では、クエリの実行が以下のような流れになります。詳しくは GraphQL Batch - Promises を見てください。
- GraphQL Field の解決時に Promise オブジェクトが登録される
- 登録された Promise が後でまとめて実行される
この仕組みにより、connected_to
ブロック内で Promise が登録されても、実際のクエリ実行時にはブロックを抜けているため、Writer DB でクエリが実行されてしまうというわけです。
# 利用側 class PostType < GraphQL::Schema::Object field :user, UserType, null: false def user # これで Reader に向くはず! => 嘘だった... ActiveRecord::Base.connected_to(role: :reading) do # この時点では Promise が登録されるだけ UserLoader.load(object.user_id) end # ブロックを抜けた後に Promise が実行される # connected_to の指定は効かず、デフォルトロール (Writer) で実行される end end
この Promise が処理されるのは PostType
の外側になってしまうため、この user
メソッド内ではハンドリングできません。そこで GraphQL Ruby が持つ Tracer という仕組みを使います。
GraphQL Tracerとは
GraphQL Tracer は、GraphQL クエリの実行過程で発生する各イベントをフックできるミドルウェアです。フィールドの実行、クエリの解析、バリデーションなど、様々なタイミングで処理を挟むことができます。
実装してみる
GraphQL Tracer を使って Reader DB へのクエリを制御する例です。
今回はフィールド単位で Reader / Writer の制御がしたかったので、フィールド単位での実行の際に call される execute_field
, execute_field_lazy
を override しました。
Read Only かどうかを判別する方法は各自お好きにという感じなんですが、今回の実装では GraphQL::Schema::Field にカスタムフィールドを追加しています。(実装方法は後述します)
# Tracer の実装 module Tracers module DatabaseConnectionTracer # フィールド実行時に呼ばれるメソッド def execute_field(field:, query:, **_args, &block) switch_readonly(field, query, &block) end # 遅延実行フィールド(Batch 処理)実行時に呼ばれるメソッド def execute_field_lazy(field:, query:, **_args, &block) switch_readonly(field, query, &block) end private def switch_readonly(field, query, &block) if readonly?(field, query) # Reader DB でクエリを実行 ActiveRecord::Base.connected_to(role: :reading, &block) else # Writer DB でクエリを実行 ActiveRecord::Base.connected_to(role: :writing, &block) end end end end # GraphQL Schema での呼び出し class ApplicationSchema < GraphQL::Schema query QueryType mutation MutationType # DatabaseConnectionTracer を有効化 trace_with Tracers::DatabaseConnectionTracer use GraphQL::Batch end
ちなみにトレースできるイベントは他にも色々あって、よくありそうな例でいうと graphql-rubyで、queryだけのリクエストの場合リードレプリカに接続する で紹介されているように、GraphQL execute の実行1回分全体である execute_multiplex
を使うことも多いかと思います。
なお上記の記事の実装で使われている Schema.trace
は現在 Deprecated で、Schema.trace_with
を使ってほしいとのことです。(参考: https://github.com/rmosolgo/graphql-ruby/issues/2929#issuecomment-2071053094)
この記事のコード例も Schema.trace_with
で記載しています。
フィールドレベルでの制御
GraphQL Tracer の中で Read Only を判別するために、今回は GraphQL::Schema::Field にカスタムフィールドを追加する方法を取りました。 実装例は公式ドキュメントに載っているものをほぼそのまま真似しています。
もちろん各 field メソッドで ActiveRecord::Base.connected_to
を呼び出せば同じことはできるんですが、あまりにも大変なのと、基本的に意図しない限りは QueryType は Read Only としてほしいためこういった実装になっています。
# GraphQL::Schema::Field の拡張 module Types class BaseField < GraphQL::Schema::Field attr_reader :writer def initialize(**kwargs, &block) # writer オプションを抽出 @writer = kwargs.delete(:writer) || false super end end end # GraphQL::Schema::Object の拡張 module Types class BaseObject < GraphQL::Schema::Object field_class Types::BaseField end end # クエリ実装側 class QueryType < Types::BaseObject # 通常のクエリフィールド(Reader DBで実行) field :users, [UserType], null: false # Writer DB で実行したいクエリフィールド field :user_with_lock, UserType, null: false, writer: true def users User.all end def user_with_lock # SELECT FOR UPDATE など Writer DB での実行が必要な処理 User.lock.find(context[:user_id]) end end
まとめ
この記事では Rails + GraphQL Batch 環境でクエリ全体に対するコントロールをするために、GraphQL Tracer を利用する実装例についてご紹介しました。これによって giftee for Business はアクセス負荷の増大に対して Read Replica を増やすことで楽に対応ができるようになりました。
また GraphQL の Batch Loader についても理解が深まり、いい勉強の機会になりました。同じような実装で悩んでいる方の参考になれば幸いです。
ギフティではスケーラビリティを大事にする方も興味あるぜ!という方も大募集しています。興味がある方はぜひカジュアル面談などでお話しましょう!