Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)

こんにちは、hachi8833です。N+1問題の検出といえばbullet gemですが、BatchLoaderはより積極的かつ一般性の高い方法でN+1を解決するgemです。

exAspArk/batch-loader - GitHub

概要

原著者の許諾を得て翻訳・公開いたします。

  • 2017/09/07: 初版公開
  • 2022/11/10: 更新

Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)

訳注

訳注: バッチング(batching)という語はここでは主に「(小分けにされたものを)1つにする」操作を指しています。

Unityなど3Dゲーム制作方面では、バッチングは「個別の物体のレンダリングを1つにまとめる」ことを指すようです。

本記事では、バッチングと呼ばれる技法でN+1クエリを回避する方法、HaskelのHaxlやJavaScriptのDataLoaderでのバッチング、およびRubyプログラムでできるアプローチについて解説します。

N+1クエリとは何か

最初に、N+1クエリとその呼び名の由来について説明します。userspostsという2つのSQLテーブルがあるとすると、ActiveRecordモデルを使って次のように書くことができます。

posts = Post.where(id: [1, 2, 3])
# SELECT * FROM posts WHERE id IN (1, 2, 3)
users = posts.map { |post| post.user }
# SELECT * FROM users WHERE id = 1
# SELECT * FROM users WHERE id = 2
# SELECT * FROM users WHERE id = 3

最初のSELECT * FROM postsクエリは1件、その後のSELECT * FROM users...クエリはN件になるので、このコードでは1+N件のクエリが生成されます。加算は順序を変えても合計が変わらないことから、一般には順序を変えたN+1クエリで呼ばれます。

N+1クエリのよくある解決法

Ruby界隈では一般に、N+1クエリ問題の解決に次の2つの方法がよく使われます。

  • モデルでのeager loading
posts = Post.where(id: [1, 2, 3]).includes(:user)
# SELECT * FROM posts WHERE id IN (1, 2, 3)
# SELECT * FROM users WHERE id IN (1, 2, 3)
users = posts.map { |post| post.user }

指定された関連付けを背後でpreloadし、利用しやすくします。ただし、ORMではこの手が常に使えるとは限りません(別のデータベースがあるなど、データを異なる場所から読み込む必要がある場合)。

  • preloadしたデータを引数経由で渡す
class Post < ApplicationRecord
  def rating(like_count, angry_count)
    like_count * 2 - angry_count
  end
end

posts = Post.where(id: [1, 2, 3])
# SELECT * FROM posts WHERE id IN (1, 2, 3)

post_emoticons = Emoticon.where(post_id: posts.map(&:id))

like_count_by_post_id = post_emoticons.like.group(:post_id).count
angry_count_by_post_id = post_emoticons.angry.group(:post_id).count
# SELECT COUNT(*) FROM emoticons WHERE name = 'like' AND
# post_id IN (1, 2, 3) GROUP BY post_id
# SELECT COUNT(*) FROM emoticons WHERE name = 'angry' AND
# post_id IN (1, 2, 3) GROUP BY post_id

posts.map do |post|
  post.rating(
    like_count_by_post_id[post.id],
    angry_count_by_post_id[post.id]
  )
end

この手法は柔軟性が高く、#includesを使ったシンプルな方法が利用できない場合にも使えます。また、メモリ効率も高くなることがあります。

上のコード例ではpostごとのレーティングを計算するためにすべてのemoticon(絵文字)を読み込むわけではありません。そうした面倒な部分はすべてデータベースが肩代わりし、各postのcountは引数として渡されます。ただし、特に下にいくつものレイヤがある(Emoticonsを読み込み、Users経由でPostsに渡すなど)場合に、引数経由でのデータ渡しが複雑になることがあります。

これら2種類のアプローチは、いずれもN+1クエリを回避するのに有用です。問題は、トップレベルでどのデータをpreloadする必要があるかを「事前に」見極めなければならない点です。さらに、preloadが不要な場合にもpreloadが行われてしまいます。たとえばGraphQLを使っていると、ユーザーからのクエリにどんなフィールドが含まれるかを事前に予測しきれないため、こうしたアプローチが使えません。このような場合にN+1クエリを回避する妙案はないものでしょうか。それがバッチングと呼ばれる手法です。

バッチングとは

バッチングは、N+1の解決手法として新しいものではありません。Facebookは2014年にHaskel Haxlライブラリをリリースしましたが、技法自体はずっと前から使われており、Monad(モナド)、Applicative、Functor(関手)といった概念が採り入れられています。これらの概念を説明しようとするとそれだけで個別の記事が必要になるので、ここではこれ以上踏み込みません(Rubyでの関数プログラミングについてもっと知りたいという方はいませんか?)。

訳注

これらの用語は主に数学の圏論に由来しています。Applicativeには今のところ定訳はないようです。

バッチングの概念は他のプログラミング言語にも実装されています。その中で最もよく知られているのはJavaScriptのDataLoaderライブラリで、GraphQLとともに非常に人気が高まっています。FacebookのエンジニアLee Byronによる素晴らしい動画とソースコードが公開されており、わずか300行であるにもかかわらず、かなり素直なコードです。

一般的なバッチングの手順

  1. アプリで読み込む項目をバッチに渡す(読み込む場所はアプリ内のどこでもよい)
  2. バッチ内で、受け取った項目の値の読み込みとキャッシュを行う
  3. 読み込んだ値は、項目が渡された元の場所で取得される

この手法の主な利点は、バッチングが独立していることです。そのおかげで、アプリのどの場所でも必要なタイミングでデータを読み込めます。

JavaScript DataLoaderを使った基本的な使用例をご紹介します。

var batch = (userIds) => ...;
var loader = new DataLoader(userIds => batch(userIds));
    // “load”は、Node.js “process.nextTick” でキューをdispatchするジョブを
    // スケジューリングし、promiseを1つ返す
loader.load(userId1).then(user1 => console.log(user1));
loader.load(userId2).then(user2 => console.log(user2));
loader.load(userId3).then(user3 => console.log(user3));

最初にloaderを作成します。ここでは、userIdsをloadする項目のコレクションをすべて受け取る関数を1つloaderに渡します。これらの項目は、全ユーザーを一括で読み込むbatch関数に渡されます。次に、loader.load関数を呼びます。この関数は、読み込んだひとつの値(user)を含むpromiseを返します。なるほど。ところでRubyではどうやればよいのでしょうか。

Rubyでのバッチング

Universeでは毎月ハッカソンが開催されており、EthereumやElixir、プログレッシブWebアプリといったアイデアや技術を誰でも自由に体験できます。私は前回のハッカソンで指名を得ることができたのですが、ちょうどGraphQLでN+1クエリを回避する既存の手法や、GraphQLクエリをバッチングでMongoDBのAggregation Pipelineに変換する方法を学んでいたところでした。Aggregation Pipelineでさまざまなコレクションをjoinしたりfilterしたりserializeしたりする方法については、前回の記事をご覧ください。

また、その頃の私たちは、従来のRESTful APIのサポートを継続しながらGraphQLに統合する作業も進めていました。通常、私たちの開発しているアプリでは、プラットフォームのスケールアップを繰り返そうとすると、N+1のDBクエリやHTTPリクエストによるボトルネックが主な問題となっていました。

そういうわけで、私たちは既存のRESTful APIとGraphQLの両方でN+1クエリを解決できるツールの開発を決めました。Ruby開発者なら誰でも内容を理解して利用できるシンプルなツール、その名もBatchLoaderです。

lazyな実装

class Post < ApplicationRecord
  belongs_to :user

  def user_lazy
        # BatchLoaderでクールなコードをここに書く
  end
end

posts = Post.where(id: [1, 2, 3])
# SELECT * FROM posts WHERE id IN (1, 2, 3)
users_lazy = posts.map { |post| post.user_lazy }

BatchLoader.sync!(users_lazy)
# SELECT * FROM users WHERE id IN (1, 2, 3)

BatchLoaderの実装は、他のプログラミング言語における非同期的な性質を持つ実装の猿真似を目指しておらず、Promiseのような余分なプリミティブを使っていません。EventMachineを使っているのでもなければ、Rubyでそうしたものを使う理由はありません。

それに代わるものとして、Rubyの標準ライブラリにある「lazyなオブジェクト」というアイデアを採用しています。たとえば「lazyな配列」では、要素の操作を、それが必要になる最終段階で解決することができます。

range = 1..Float::INFINITY
values_lazy = range.lazy.map { |i| i * i }.take(10)
values_lazy.force
# => [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

上から分かるように、2つのコードブロックでも次の同じパターンが使われています。

  • lazyなオブジェクトのコレクションを取る
  • コレクションは最後に解決する

バッチング

それでは、Post#user_lazyの部分を詳しく見てみましょう。このメソッドはlazyなBatchLoaderインスタンスを返します。

# app/models/post.rb
def user_lazy
  BatchLoader.for(user_id).batch do |user_ids|
    User.where(id: user_ids)
  end
end

BatchLoader.forは項目(user_id)を1つ受け取ります。この項目はコレクションされ、後でバッチングに使われます。次に、#batchメソッドを呼び出します。このメソッドに渡されるブロックは、コレクションされたすべての項目(user_ids)に適用されます。ブロックの内部では、項目を取得するバッチクエリ(User.where)を1つ実行します。

JavaScript DataLoaderでは、渡された項目や読み込まれた値を明示的にマップしていますが、この方法は次の2つの制約に依存しています。

  • 渡された項目の配列(user_ids)の長さは、読み込まれた値の配列(user)の長さと同じでなければならない。通常、この条件を満たすために配列の値がない部分をnilで埋める必要があります。
  • 渡された項目の配列の各要素のインデックスは、読み込まれた値の配列の各要素の同じインデックスと対応していなければならない。通常、この条件を満たすために配列の値をソートする必要があります。

一方、BatchLoaderが提供する読み込み用メソッドは、渡された項目(user_ids)と読み込まれた値(user)を単純にマップします。

# app/models/post.rb
def user_lazy
  BatchLoader.for(user_id).batch do |user_ids, batch_loader|
    User.where(id: user_ids).each { |u| batch_loader.load(u.id, u) }
  end
end

RESTful APIの例

今度は、普通のRailsアプリがN+1 HTTPリクエストを行っている状況を考えてみましょう。

# app/models/post.rb
class Post < ApplicationRecord
  def rating
    HttpClient.request(:get, "https://example.com/ratings/#{id}")
  end
end

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    posts = Post.limit(10)
    serialized_posts = posts.map do |post| 
      {id: post.id, rating: post.rating} # <== N+1 HTTP requests
     end
    render json: serialized_posts
  end
end

grosser/parallel - GitHub

parallelというgemを使うと、すべてのHTTPリクエストをスレッド化して並列(concurrent)実行することでリクエストをバッチ化できます。幸いなことにMRIでは、スレッドがブロッキングI/O(ここではHTTPリクエスト)に遭遇するとGIL(global interpreter lock)を解放してくれます。

# app/models/post.rb
def rating_lazy
  BatchLoader.for(post).batch do |posts, batch_loader|
    Parallel.each(posts, in_threads: 10) do |post|
      batch_loader.load(post, post.rating)
    end
  end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    posts = Post.limit(10)
    serialized_posts = posts.map do |post| 
      {id: post.id, rating: post.lazy_rating}
    end
    render json: BatchLoader.sync!(serialized_posts)
  end
end

スレッド安全性

上のコンカレントなHTTPリクエストのコード例は、HttpClientがスレッドセーフでないと正常に動作しません。BatchLoader#loadは最初からスレッドセーフなので、余分な依存性が発生しません。

GraphQLの例

バッチングが特に有用なのは、GraphQLです。データの事前読み込みのような手法でN+1クエリを回避しようとすると、どのフィールドもユーザーからのクエリに含まれる可能性があるため、コードが恐ろしく複雑になってしまうことがあります。graphql-rubyの簡単なスキーマでコード例を見てみましょう。

Schema = GraphQL::Schema.define do
  query QueryType
end

QueryType = GraphQL::ObjectType.define do
  name "Query"
  field :posts, !types[PostType], resolve: ->(obj, args, ctx) do
    Post.all
  end
end

PostType = GraphQL::ObjectType.define do
  name "Post"
  field :user, !UserType, resolve: ->(post, args, ctx) do
    post.user # <== N+1 queries
  end
end

UserType = GraphQL::ObjectType.define do
  name "User"
  field :name, !types.String
end

次のようなシンプルなクエリを実行すると、post.userを実行するたびにN+1クエリが発生します。

query = "
{
  posts {
    user {
      name
    }
  }
}
"
Schema.execute(query)
# SELECT * FROM posts WHERE id IN (1, 2, 3)
# SELECT * FROM users WHERE id = 1
# SELECT * FROM users WHERE id = 2
# SELECT * FROM users WHERE id = 3

この問題は、リゾルバを変更してBatchLoaderを使うだけで回避できます。

PostType = GraphQL::ObjectType.define do
  name "Post"
  field :user, !UserType, resolve: ->(post, args, ctx) do
    BatchLoader.for(post.user_id).batch do |ids, batch_loader|
      User.where(id: ids).each { |u| batch_loader.load(u.id, u) }
    end
  end
end

後は、GraphQLに組み込まれている#lazy_resolveメソッドを使ってセットアップします。

Schema = GraphQL::Schema.define do
  query QueryType
  lazy_resolve BatchLoader, :sync
end

以上でおしまいです。GraphQLのlazy_resolveは、フィールドで基本的にresolve lambdaを呼び出します。lazyなBatchLoaderが返されると、後でBatchLoader#syncが呼び出され、実際に読み込まれた値を自動的に取得します。

GraphQLの作者にも認められたbatch-loader

キャッシュ

BatchLoaderには最初からキャッシュメカニズムが提供されているので、読み込み済みの値に対するクエリは発生しません。次の例をご覧ください。

def user_lazy(id)
  BatchLoader.for(id).batch do |ids, batch_loader|
    User.where(id: ids).each { |u| batch_loader.load(u.id, u) }
  end
end
user_lazy(1)      # リクエストなし
# => <#BatchLoader>

user_lazy(1).sync # SELECT * FROM users WHERE id IN (1)
# => <#User>

user_lazy(1).sync # リクエストなし
# => <#User>

まとめ

一言で言えば、バッチングはN+1クエリを回避するための強力な手法です。私は、GraphQLや他の言語を使っている開発者に限らず、すべてのRuby開発者がバッチングを知っておくべきであると確信しています。バッチングを使うことで、アプリから無関係な部分を切り離し、パフォーマンスを犠牲にせずにいつでもどこでもデータを読み込めるようになります。

Universeでは、本番のRESTful APIとGraphQLの両方でコードを共有するかたちでBatchLoaderを利用しています。詳しくはBatchLoaderのREADMEとソースコードをご覧ください。ソースはわずか150行です。

関連記事

Rails向け高機能カウンタキャッシュ gem「counter_culture」README(翻訳)

Rails: render_async gemでレンダリングを高速化(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。