こんにちは、hachi8833です。N+1問題の検出といえばbullet gemですが、BatchLoaderはより積極的かつ一般性の高い方法でN+1を解決するgemです。
Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)
訳注
訳注: バッチング(batching)という語はここでは主に「(小分けにされたものを)1つにする」操作を指しています。
Unityなど3Dゲーム制作方面では、バッチングは「個別の物体のレンダリングを1つにまとめる」ことを指すようです。
本記事では、バッチングと呼ばれる技法でN+1クエリを回避する方法、HaskelのHaxlやJavaScriptのDataLoaderでのバッチング、およびRubyプログラムでできるアプローチについて解説します。
N+1クエリとは何か
最初に、N+1クエリとその呼び名の由来について説明します。users
とposts
という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行であるにもかかわらず、かなり素直なコードです。
一般的なバッチングの手順
- アプリで読み込む項目をバッチに渡す(読み込む場所はアプリ内のどこでもよい)
- バッチ内で、受け取った項目の値の読み込みとキャッシュを行う
- 読み込んだ値は、項目が渡された元の場所で取得される
この手法の主な利点は、バッチングが独立していることです。そのおかげで、アプリのどの場所でも必要なタイミングでデータを読み込めます。
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
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
が呼び出され、実際に読み込まれた値を自動的に取得します。
キャッシュ
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行です。
概要
原著者の許諾を得て翻訳・公開いたします。