giftee Tech Blog

ギフティの開発を支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信しています。

Rails + GraphQL Batch 環境でクエリを Reader DB に向ける

eye_catch

こんにちは、エンジニアの 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 を見てください。

  1. GraphQL Field の解決時に Promise オブジェクトが登録される
  2. 登録された 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 についても理解が深まり、いい勉強の機会になりました。同じような実装で悩んでいる方の参考になれば幸いです。

ギフティではスケーラビリティを大事にする方も興味あるぜ!という方も大募集しています。興味がある方はぜひカジュアル面談などでお話しましょう!

giftee.co.jp

herp.careers