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

概要

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

訳注: 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"')
# => 
# => no such column: user.name: SELECT “books”.* FROM “books” WHERE (user.name = Guava)

クエリでusers.nameカラムが見つからないとActiveRecordに怒られてしまいました。しかしカラムがなくなったわけではありません。#preload(この名前がそもそもヒントです)は、別のクエリで関連付けを事前に読み込んでないと、読み込みやフェッチができないのです。クエリでusers.nameを使いたければ、2つのテーブルをJOINする必要があるでしょう。つまり、#preloadは絶対的な解決法ではないということです。では、クエリの中で関連付けにアクセスする必要がある場合はどうすればよいのでしょうか?そこで話は次の戦略につながります。

“JOINじゃ、JOINじゃ!JOINと引き換えにこの王国をくれてやるわい!”

訳注: この引用だけ、ハムレットではありません(リチャード三世)。

先ほどの戦略の問題は、クエリ内で別のテーブルにあるカラムにアクセスできないということです。その理由は、#preloadが常にクエリを分割してしまうためです。しかし「恐れてはならぬ」のです。#eager_loadが存在しているのには、ちゃんと理由があります。#eager_loadが関連付けからデータを読み込むときには、LEFT JOINを使って1つのクエリだけで関連するレコードをすべて取り出します。つまり、次のように書けます。

user_names = books.eager_load(:user).map { |book| book.user.name }

#=> SQL (0.4ms) SELECT “books”.”id” AS t0_r0, “books”.”title” AS t0_r1, “books”.”author” AS t0_r2, 
#=> “books”.”books_id” AS t0_r3, “books”.”user_id” AS t0_r4, “books”.”created_at” AS t0_r5, 
#=> “books”.”updated_at” AS t0_r6, “users”.”id” AS t1_r0, “users”.”name” AS t1_r1, 
#=> “users”.”created_at” AS t1_r2, “users”.”updated_at” AS t1_r3 FROM “books” 
#=> LEFT OUTER JOIN “users” ON “users”.”id” = “books”.”user_id”

最初にご注目いただきたいのは、Railsのログに出力されているLEFT OUTER JOINです。「誰に断ってこんな無礼を働くのか…」。しかし物知り博士タイプのActiveRecordは、自分はSQL文をこんなに知っているぞとドヤ顔で見せびらかそうとするやつなので、そこは気にしてはなりません。しかし#eager_loadが1つのクエリで常にLEFT OUTER JOINを使うという事実は記憶に値します(OUTERのことはご心配なく: LEFT JOINと同じです)。

次に、ActiveRecordは2つのテーブルをメモリ上に読み込む(ここにご注目!)ことで、関連付けられたテーブル(users)のフィールドにアクセスできるようになることにご注目ください。これは、#preloadで起きた問題そのものです。つまり、以下のコードを実行すれば正常に動きます。

books.eager_load(:user).where('users.name = "Guava"').map { |book| book.author }

もうひとつ興味深いのは、これは#joinsとは違うものであるという点です。では#joinsだとどうなるのでしょうか?

  1. #joinsではLEFT OUTER JOINではなくINNER JOINが使われる。
  2. 目的が異なる: 関連付けとともにレコードを読み込むのではなく、クエリの結果をフィルタするために使われる。関連付けのeager loadingを行わないので、N+1クエリを防ぎません。
  3. 関連付けられたテーブルのフィールドにアクセスせずにクエリをフィルタしたい場合には問題なく利用できる。ずばりその理由は、#joinsは単に結果をフィルタするだけであり、関連付けられたテーブルを読み込んだり展開したりしないからです。

この3つから、#joins#preload#eager_loadと(そして後述する#includesとも)併用できることがわかります。目的が異なっているので、これは正当な利用法です。

いずれにしろ私たちは、どれにするか決めなければなりません。LEFT JOINで1つのクエリだけを生成する#eager_loadか、それとも、先ほどのようにクエリを分割してから関連付けられたデータをフェッチする#preloadか。あなたならどちらにしますか?この愛すべき問題を作り出してくれたのはActiveRecordなのです(私はActiveRecordへの感謝を忘れたことはありません❤)から、ActiveRecordの#includesがこの問題をどのように解決(または少なくともジレンマを軽減)するかを見ていくことにしましょう。

“物事にいいも悪いもない: #includesすればそうなるのだ”

前述の#includesの使いみちは一体何なのかが気になる方もいると思います。前述のとおり、Rails 4以前の#includesは、それぞれの場合にどちらのeager loading戦略を選択するかという責務を委譲するのに使われていました。#includesは基本的にWHEREやORDERの条件を監視して、関連付けられたテーブルへの参照があるかどうかを監視し、参照がある場合は#eager_loadを(前述のとおり明らかにテーブルのJOINが必要です)、参照がない場合は単に#preloadを使います。次の例をご覧ください。

books.includes(:user).where('users.name="Guava"')
#=>
#=> SELECT "books".”id” AS t0_r0, "books"."title" AS t0_r1, 
#=> "books."author" AS t0_r2, "books"."books_id" AS t0_r3, 
#=> "books"."user_id" AS t0_r4, "books"."created_at" AS t0_r5, 
#=> "books"."updated_at" AS t0_r6, "users"."id" AS t1_r0, 
#=> "users"."name" AS t1_r1, "users"."created_at" AS t1_r2, 
#=> "users"."updated_at" AS t1_r3 FROM "books" 
#=> LEFT OUTER JOIN "users" ON "users"."id"= "books"."user_id" 
#=> WHERE (users.name="Guava")

これで、関連付けられたテーブルがWHERE条件にない場合のデフォルトの動作は次のようになります。

books.includes(:user).where(author: 'Shakespeare')
#=>
#=> SELECT "books".* FROM "books" WHERE "books"."author" = ? [["author", "Shakespeare"]]
#=> SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3)

しかしRailsチームはRails 4以降このあたりを諦めてしまった様子です。非推奨メッセージに「完璧なSQLパーサーがない限り、問題の発生は避けられない。私たちはSQLパーサーなど書きたくないので、この機能は削除する(doing this without writing a full-blown SQL parser is inherently flawed. Since we don’t want to write an SQL parser, we are removing this functionality)」という一文があります。シンプルで簡潔な#includesは、Rails 5以降#preloadと完全に同じ動作になってしまいました。前の例はRails 5でもエラーをスローしますが、これは#preloadが「JOINされなかった関連テーブル内のカラムにはアクセスできない」と通知するからです。

訳注: 非推奨メッセージ全体は次のとおりです:
Currently, Active Record recognizes the table in the string, and knows to JOIN the comments table to the query, rather than loading comments in a separate query. However, doing this without writing a full-blown SQL parser is inherently flawed. Since we don’t want to write an SQL parser, we are removing this functionality. From now on, you must explicitly tell Active Record when you are referencing a table from a string:

ただしここでひとつ注意があります。関連付けられたテーブルを#includesでJOINしたい場合は、たとえば次のように#referencesメソッドで関連テーブルを明示する必要があります。

books.includes(:user).where('users.name="Guava"').references(:user)

#=> SQL (0.4ms)  SELECT "books"."id" AS t0_r0, "books"."title" AS t0_r1, 
#=> "books"."author" AS t0_r2, "books"."books_id" AS t0_r3, 
#=> "books"."user_id" AS t0_r4, "books"."created_at" AS t0_r5, 
#=> "books"."updated_at" AS t0_r6, "users"."id" AS t1_r0, 
#=> "users"."name" AS t1_r1, "users"."created_at" AS t1_r2, 
#=> "users"."updated_at" AS t1_r3 FROM "books" 
#=> LEFT OUTER JOIN "users" ON "users"."id" = "books"."user_id" 
#=> WHERE (users.name="Guava")

私個人の意見ですが、Rails 4以前の#includesは、枕の下半分のようにクール(=「ひんやりしてる」のシャレ: 最近流行りの言い回し)だったと思います。実装上の困難から#includesの動作が変更されたのは十分理解できますが、#referencesメソッドが存在しているということ自体が、#eager_loadを使っても真のDRYにはならず、コードも明確にならないという事実そのものを示しています。#includesを呼ばないと#referencesは呼び出せませんし、#referencesなしで#includesを呼ぶと常にpreload戦略が選択されてしまいます。

それなら、query.includes(:user).references(:user)のようなだるい書き方をしなくても普通に#eager_loadを呼ぶだけでいいのではないか、あるいは、金魚のフンみたいな#includesを使わずに単に#preloadを呼べばいいのではないか、その方が意図も明確になるのではないか、という疑問が生じます。これに関する回答をいくつか読んでみましたが、私にも何とも言いようがありません(単に私も腑に落ちてないだけなのですが)。さらに言えば、#includeはどちらの戦略に委譲するかという決定を下さなければならない分オーバーヘッドが生じ、先の2つのメソッドより若干速度が落ちます。いずれにしろ、Railsチームは実に頭の切れる連中なので、きっと何かいいアイデアを思いついてくれることでしょう。

query.includes(:user).references(:user)のようなだるい書き方をしなくても普通に#eager_loadを呼ぶだけでいいのではないか、あるいは、金魚のフンみたいな#includesを使わずに単に#preloadを呼べばいいのではないか、その方が意図も明確になるのではないか

“美しき人に美しき花を手向けようではないか: さらばじゃ” — まとめ

  • #preload#eager_load#includesは似た者同士であり、いずれもeager loading戦略を取ります。
  • #joinsは上のどれとも違っており、関連付けを読み込まず(訳注: AR::Relationのオブジェクト化を指していると考えられます)、INNER JOINでクエリをフィルタします。
  • #preload: 関連付けられたテーブルの読み込みで、常にクエリを分割します。
  • #eager_load: 関連付けられたテーブルの読み込みで、常にLEFT JOINを使います。
  • #includes: Rails 4より前は(そこそこ)賢くできていて、eager loadingとpreloadingからよりよい戦略を見つけてくれました。Rails 4以降は、#referencesで明示的にLEFT JOINの使用を指定しない限りpreloading戦略を使います。
  • #references: #includesなしでは利用できず、逆に#referenceなしの#includesではpreloadが呼ばれます。

“ActiveRecordにはコードだけではわからないことがいくらでもあるのだよ、ホレーショ”

ハムレット王子は、自らの義父となった叔父のクローディアス王を殺すべきかどうかという重大なジレンマに直面して「生きるべきか死すべきか(To be or not to be?)」とつぶやきました。それはともかく、本記事で申し上げたいのは、N+1クエリつぶしを面倒臭がってはならないということです。私たちが本当に知りたいのは「クエリでJOINすべきかどうか、するならどの程度JOINすべきなのか」なのですが、ハムレットの苦悩と同様、これは難しい問いかけです。本記事が、ActiveRecordのeager loading戦略について皆さまの疑問を少しでも解消し、オブジェクトの関連付けをクエリにするときに合理的な決定を下せるようになれば幸いです。

Sergio Fontes、Filipe W. Lima、Leonardo Brito、Chico Carvalhoに感謝申し上げます。

追記

k0kubunさんの以下の記事も合わせて読むことをおすすめします。Rails 5から#left_outer_joins(またはエイリアスの#left_joins)が使えるそうです。また、#includesがAR::Baseを生成するために効率が落ちることがある点も指摘しています。

関連記事

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

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

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

Railsの`CurrentAttributes`は有害である(翻訳)

Rein: RailsのActiveRecordでDB制約やデータベースビューを使えるgem(README翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833

コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。
これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。
かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。
実は最近Go言語が好き。
仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

BPSアドベントカレンダー

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ