Rails: JOINすべきかどうか、それが問題だ — #includesの振舞いを理解する(翻訳)

概要 原著者の許諾を得て翻訳・公開いたします。 英語記事: To join or not to join? An act of #includes 公開日: 2017/08/07 著者: Tiago Farias 訳注: actの基本的な意味は「演技(する)」「(舞台の)場面」であり、タイトルはこれにかかっています。 Rails: JOINすべきかどうか、それが問題だ — #includesの振舞いを理解する(翻訳) ORMを日常的に使っていれば、リレーションに沿ってオブジェクトにクエリをかけようとして、ありがちな問題に時間を食われてしまった経験がきっとあることでしょう。たとえば、Ruby on Railsプロジェクトでエンティティ同士にごく簡単なリレーションが設定されているところを想像してみてください。 class User has_many :books end class Book belongs_to :user end u1 = User.create(name: ‘Guava’) u2 = User.create(name: ‘Foo’) u3 = User.create(name: ‘Bar’) Book.create(title: ‘Hamlet’, author: ‘Shakespeare’, user: u1) Book.create(title: ‘King Richard III’, author: ‘Shakespeare’, user: u2) Book.create(title: ‘Macbeth’, author: ‘Shakespeare’, user: u3) ここで、本1冊ごとにユーザーを取得しようとしたらどうなるでしょうか。 books = Book.all user_names = books.map { |book| book.user.name } Railsコンソールの出力を見ると、何だか残念なことが起こっている様子です。 Book Load (0.7ms) SELECT “books”.* FROM “books” User Load (0.2ms) SELECT “users”.* FROM “users” WHERE “users”.”id” = ? LIMIT ? [[“id”, 1], [“LIMIT”, 1]] User Load (0.1ms) SELECT “users”.* FROM “users” WHERE “users”.”id” = ? LIMIT ? [[“id”, 2], [“LIMIT”, 1]] User Load (0.1ms) SELECT “users”.* FROM “users” WHERE “users”.”id” = ? LIMIT ? [[“id”, 3], [“LIMIT”, 1]] “我がクエリに何が起こったのじゃ?” 「我が友よ」、これはN+1クエリ問題そのものです。最初のクエリ(N+1の「1」の方)はサイズNのコレクションをひとつ返しますが、コレクションの1つ1つについてデータベースへのクエリが実行されます(N+1の「N」の方)。 幸い、この例では本は3冊しかありません。しかしこのクエリのパフォーマンスは著しく低下する可能性があります。本が数百万冊になったときを想像してみてください。コレクションの大きさによっては、下手をするとコンピュータが爆発するかも!というのは冗談ですが、アプリはきっと止まってしまうことでしょう。そしてさらに悪いのは、あなたがこのクエリ爆発をハードウェアのせいにしてしまうことではないでしょうか。もちろん、Nクエリは特定のidカラム(インデックス)にヒットするので、(クエリがデータベースでひとたび処理された後なら)Nクエリは十分高速になり、パフォーマンスは向上します。しかし騙されてはいけません。たった2つ(下手をすると1つ)のクエリと引き換えにN+1クエリを許してしまえば、常にこの問題が発生します。データベースとのやりとりにおけるI/Oコスト(特にデータベースがアプリとは別マシンで動作している場合)の犯人はここにいます。 本記事では、ActiveRecordでの開発について、N+1クエリ問題を回避するための3つのメソッドとそれぞれの戦略をチェックします。3つのメソッドとは、#preload、#eager_load、#includesです。 “#preload”ひとつにも天の摂理が働いておるのだよ” 問題を解決する方法のひとつは、クエリを2つに分けることです。1つめのクエリは関連データを取得するクエリ、2つ目のクエリは最終的な結果を取得するクエリという具合です。 books = Book.all user_names = books.preload(:user).map { |book| book.user.name } 上のようなコードから、以下のような結果を得られます。 Book Load (0.3ms) SELECT “books”.* FROM “books” User Load (0.4ms) SELECT “users”.* FROM “users” WHERE “users”.”id” IN (1, 2, 3) 「おお何たること」、N+1のときより遅くなっている!大丈夫、一般的にはそうなりません。この例だけを見れば確かに元より遅くなっていますが、これは単にシードデータに本が3冊しかないからです。つまり、#preloadで2つのクエリを実行するのに0.7msかかっているのに、N=3では(私のPCでは)0.4msしかかかっていません。ご注意いただきたいのは、これらのN+1クエリはPostgreSQLのインデックステーブル機能(idを主キーとして使う)のおかげで強烈に速くなっていることです。ほとんどの場合、2つのクエリに分ける方がN+1よりも圧勝します。 しかし何事にも裏というものがあります。次のように、クエリにほんのちょっぴりフィルタをかけてみるとどうなるでしょうか? books.preload(:user).where(‘users.name=”Guava”‘) # … Continue reading Rails: JOINすべきかどうか、それが問題だ — #includesの振舞いを理解する(翻訳)