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

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

概要

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

原文ではシェークスピアの古典劇『ハムレット』のセリフが多数引用されています。引用されたセリフのリンクをマウスオーバーするとシェークスピアの原文がポップアップします。

actの基本的な意味は「演技(する)」「(舞台の)場面」であり、タイトルはこれにかかっています。

  • 2017/09/25: 初版公開
  • 2021/09/22: 更新

訳注

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

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コスト(特にデータベースがアプリと別のマシンで動作している場合)の犯人はここにいます。

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

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

先ほどの戦略の問題は、クエリ内で別のテーブルにあるカラムにアクセスできないことです。その理由は、#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です。「誰に断ってかような無礼を働くのか...」。しかし物知り博士タイプのActive Recordは、自分はSQL文をこんなに知っているぞとドヤ顔で見せびらかそうとするものなので、そこを気にしてはなりません。しかし#eager_loadが1つのクエリで常にLEFT OUTER JOINを使うという事実は記憶に値します(OUTERのことはご心配なく: LEFT JOINと同じです)。

次に、Active Recordは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か。あなたならどちらにしますか?この愛すべき問題を作り出してくれたのはActive Recordなのです(私はActive Recordへの感謝を忘れたことはありません❤)から、Active Recordの#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チームは実に頭の切れる連中なので、きっと何かいいアイデアを思いついてくれることでしょう。

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

  • #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が呼ばれます。

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

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

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

関連記事

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

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

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


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

CONTACT

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