🔄

GraphQL::BatchでN+1を解決する重複排除の仕組み

に公開

はじめに

GraphQL::Batchを使っていて、重複排除の仕組みが気になったので調べてみました。

異なるフィールドから同じキーでload()が呼ばれているのに、なぜperform()は1回しか実行されないのか。その裏側にある仕組みを本記事で解説します。同じ疑問を持った方の参考になれば幸いです。

GraphQL::Batchとは

GraphQL::Batchは、ShopifyがOSSとして公開しているRuby用のデータローダーライブラリです。GraphQLクエリで発生しがちなN+1問題を解決し、データベースやAPIへのアクセスを効率化します。ShopifyやGitHubなどで長年プロダクション環境で使われてきた、成熟度の高いライブラリです。

GraphQL::DataloaderとGraphQL::Batchの比較

特徴 GraphQL::Batch GraphQL::Dataloader
並行処理のプリミティブ promise.rbのPromiseを使用 RubyのFiber APIを使用
コードの複雑性 .thenチェーンを必要とし、複雑性がある Fiberの透過的なポーズ/再開により、Promiseが不要
成熟度 GraphQL-Rubyと同じくらいの歴史があり、高い成熟度を持つ 新しい実装であり、Ruby 3.0のFiber.schedulerAPIをサポート
スコープ GraphQLの外部でも使用可能 現在、GraphQLの外部では使用できない

GraphQL::Batchは成熟したライブラリであり、多くのプロダクション環境で実績があります。

【コラム】ハンバーガーショップで直感的に理解する

「Promise」や「バッチ処理」という言葉に馴染みがない場合、ハンバーガーショップをイメージすると分かりやすくなります。

GraphQL::Batchの動きは、まさにこのオーダーシステムそのものです。

  • 注文(load
    レジでハンバーガーを注文します。この時点では、まだ商品は手渡されません。

  • 呼び出しベル(Promise
    商品を受け取る代わりに、「呼び出しベル(ブザー)」を渡されます。これがPromise(予約票)です。

  • 待機と調理(バッチ処理)
    あなたは席で待ちます。この間、キッチンでは個別に作るのではなく、入った注文をまとめて肉を焼いています(SQLの実行)。

  • 提供(fulfill
    ベルが鳴って、ハンバーガー(データ)と交換します。

もし、同じテーブルの人が同じタイミングで同じバーガーを頼んだとしても(重複リクエスト)、キッチンは効率よくまとめて調理してくれます。これがGraphQL::Batchの世界観です。

重複排除の仕組み

GraphQL::Batchの重複排除は、3つのステップで実現されています。実際のコードで見てみましょう。

# 1. 同じローダーインスタンスを取得
loader = RecordLoader.for(User)

# 2. 複数箇所から同じキーでload()を呼び出し
promise1 = loader.load(1)  # author_id: 1
promise2 = loader.load(1)  # editor_id: 1(重複)
promise3 = loader.load(1)  # reviewer_id: 1(重複)

# 3. GraphQL実行時、perform()は重複を排除して一度だけ実行
# loader.perform([1])  # 重複したキー1は内部で排除される

データフローを図で理解する

同じキーで複数回load()が呼ばれても、内部で統合されて効率的にバッチ処理されます:

ポイントは、同じキー(1)で2回load()が呼ばれても、perform()に渡されるのは[1, 2]の2つだけという点です。

実装例

ローダーの定義

GraphQL::Batchには、関連データを効率的にロードするためのRecordLoaderが用意されています。

class RecordLoader < GraphQL::Batch::Loader
  def initialize(model)
    @model = model
  end

  def perform(ids)
    # 重複排除されたIDで一度だけクエリを実行
    records = @model.where(id: ids).index_by(&:id)
    
    ids.each do |id|
      fulfill(id, records[id])
    end
  end
end

GraphQLスキーマでの使用

ブログ記事には、作成者(author)と最終編集者(editor)、レビュアー(reviewer)がいるとします。これらが同じユーザーの場合、重複排除が効果を発揮します。

class PostType < GraphQL::Schema::Object
  field :title, String, null: false
  field :author, UserType, null: false
  field :editor, UserType, null: true
  field :reviewer, UserType, null: true

  def author
    # User ID: 1 をload()
    RecordLoader.for(User).load(object.author_id)
  end

  def editor
    # 同じ User ID: 1 をload()(重複!)
    RecordLoader.for(User).load(object.editor_id)
  end

  def reviewer
    # また同じ User ID: 1 をload()(重複!)
    RecordLoader.for(User).load(object.reviewer_id)
  end
end

重複排除が発生する状況

例えば、以下のようなデータがあるとします:

# author_id, editor_id, reviewer_idがすべて同じユーザー
post = Post.new(
  author_id: 1,
  editor_id: 1,   # authorと同じ
  reviewer_id: 1  # authorと同じ
)

このとき、以下のGraphQLクエリを実行すると:

query {
  post {
    author { name }
    editor { name }
    reviewer { name }
  }
}

内部では:

  1. authorフィールドでload(1)が呼ばれる
  2. editorフィールドでload(1)が呼ばれる(重複)
  3. reviewerフィールドでload(1)が呼ばれる(重複)
  4. でもperform([1])1回だけ実行される

これがGraphQL::Batchの重複排除です。

パフォーマンス効果

実際にどれくらい効果があるのか、数値で見てみましょう。

Before: GraphQL::Batchなし

-- 各フィールドごとにクエリが発行される(N+1問題)
SELECT * FROM users WHERE id = 1;  -- author
SELECT * FROM users WHERE id = 1;  -- editor(重複)
SELECT * FROM users WHERE id = 1;  -- reviewer(重複)

実行時間: 3クエリ × 5ms = 15ms

After: GraphQL::Batch使用

-- 重複を排除して一度だけクエリを実行
SELECT * FROM users WHERE id = 1;

実行時間: 1クエリ × 5ms = 5ms

パフォーマンス改善: 約3倍 🚀

ただし、この数値はあくまで理論値です。実際のプロダクション環境では、ネットワークレイテンシーやデータベースの負荷状況によって変動します。それでも、N+1問題を解決できる効果は絶大です。

重複排除の3つのポイント

重複排除がどのように機能しているのか、3つの重要なポイントを押さえましょう。

1. ローダーインスタンスの共有

これが最も重要なポイントです。

# ❌ 毎回新しいインスタンスが作られるため、重複排除されない
def author
  RecordLoader.new(User).load(object.author_id)
end

# ✅ .for()を使うことで同じインスタンスが再利用される
def author
  RecordLoader.for(User).load(object.author_id)
end

.for()メソッドは、同じ引数で呼ばれた場合に同じインスタンスを返します。これにより、異なるフィールド(author, editor, reviewer)からのload()呼び出しが同じローダーインスタンスに集約され、重複排除が機能するわけです。

2. 同じキーに対する重複リクエストの統合

# 同じキーで複数回load()を呼んでも...
loader = RecordLoader.for(User)
loader.load(1)  # 1回目(author_id)
loader.load(1)  # 2回目(editor_id)- 同じPromiseが返される
loader.load(1)  # 3回目(reviewer_id)- 同じPromiseが返される

# perform()は一度だけ、ユニークなキーで実行される
# perform([1])

内部でSetやHashを使ってユニークなキーを管理しているため、同じキーに対しては同じPromiseオブジェクトが返されます。

3. キャッシュプライミング

すでにロード済みのレコードがある場合、キャッシュに設定することでデータベースへのアクセスを避けられます。

# すでにロード済みのユーザーをキャッシュに設定
loader = RecordLoader.for(User)
loader.prime(1, existing_user)

# この後のload(1)はデータベースにアクセスしない
loader.load(1)  # キャッシュから返される

ただし、プライミング処理はそのキーと値が以前に存在しなかった場合にのみキャッシュに追加されます。この重複排除ルールにより、無駄なキャッシュ更新を防いでいます。

デバッグのコツ

「本当に重複排除されてるの?」と疑問に思ったら、ログを仕込んで確認してみましょう。

class RecordLoader < GraphQL::Batch::Loader
  def load(key)
    puts "🔥 load() called with key: #{key}"
    super(key)
  end

  def perform(ids)
    puts "🎯 perform() called with unique keys: #{ids}"
    records = @model.where(id: ids).index_by(&:id)
    ids.each { |id| fulfill(id, records[id]) }
  end
end

実行結果の例:

🔥 load() called with key: 1  # author
🔥 load() called with key: 1  # editor(重複)
🔥 load() called with key: 1  # reviewer(重複)
🎯 perform() called with unique keys: [1]  # 重複排除済み

この出力を見ると、load()は3回呼ばれているのに、perform()は重複を排除した[1]で1回だけ実行されていることが確認できます。実際にログを見ると、GraphQL::Batchの賢さを実感できます。

よくあるハマりポイント

実装していて「あれ?重複排除されてない?」と思ったら、以下をチェックしてみてください。

ハマりポイント1: 異なる引数でのローダー初期化

# これらは別のローダーインスタンスとして扱われる
loader1 = RecordLoader.for(User)
loader2 = RecordLoader.for(Post)

# それぞれ独立してバッチ処理される

.for()の引数が異なると、別のローダーインスタンスになります。つまり、重複排除も独立して行われるということです。

ハマりポイント2: GraphQLリクエストをまたいだキャッシュ

GraphQL::Batchのキャッシュは、単一のGraphQLリクエスト内でのみ有効です。異なるリクエスト間では共有されません。これは意図的な設計で、データの整合性を保つためです。

もしリクエストをまたいでキャッシュを保持したい場合は、別の仕組み(Redisなど)を検討する必要があります。

まとめ

GraphQL::Batchの重複排除機能は、以下の3つの仕組みで実現されています:

  1. ローダーインスタンスの共有 - .for()による同一インスタンスの再利用
  2. キーの重複排除 - 同じキーに対する複数のload()呼び出しを統合
  3. バッチ実行 - 重複を排除したユニークなキーでperform()を1回実行

この仕組みのおかげで、複数のフィールドから同じデータをロードしても、データベースへのクエリは1回だけで済みます。N+1問題を効率的に解決できるのは、この賢い重複排除があるからなんですね。

GraphQL::Batchを使う際は、必ず.for()でローダーインスタンスを取得すること。これが重複排除を機能させるための最も重要なポイントです。

この記事が、GraphQL::Batchの理解に少しでも役立てば嬉しいです。

参考リンク

合同会社春秋テックブログ

Discussion