はじめに
Ruby on Rails界隈でよく耳にする「N+1問題」について解説します。
N+1問題を理解することで無駄なSQLの発行を抑え、処理時間の削減に繋げることができます。
↓読んでほしい人
- N+1問題を知らない人
- Ruby on Rails初心者〜中級者
- 性能改善したい人
N+1問題とは
ループ処理の中で都度SQLを発行してしまい、大量のSQLが発行されてパフォーマンスが低下してしまう問題のことです。
日常生活で例えるなら、スーパーで商品を1点ずつお会計するようなもの。
それだけ無駄なことを行なっている状態を指します。
え、そんなアホなこと普通はしないって思いますよね?
でもRailsを使用していると往往にしてこの問題が発生してしまうのです。
N+1問題の例
会社(companies)とそれに所属する人(users)を例に説明します。
例えばcompaniesテーブルとusersテーブルに以下のようなデータがあるとします。
companiesテーブル
id | name |
---|---|
1 | AB商事 |
2 | CDテクノロジー |
3 | EF株式会社 |
usersテーブル
id | company_id | name |
---|---|---|
1 | 1 | 田中さん |
2 | 1 | 佐藤さん |
3 | 1 | 鈴木さん |
4 | 2 | 吉田さん |
5 | 2 | 高橋さん |
6 | 3 | 山田さん |
7 | 3 | 渡辺さん |
1つの会社には複数の人が所属しています。言い換えると、会社と人との間には1対多の関係が成り立っています。それぞれのモデルは以下のようになっています。
class Company < ApplicationRecord
has_many :users
end
class User < ApplicationRecord
end
次に、各会社に所属する人の名前を出力するプログラムを作成します。
# すべての会社に対し、それぞれ所属する人の名前を出力する
Company.all.each do |comp|
# usersテーブルから名前を取得し、カンマ区切りで結合する
user_names = comp.users.pluck(:name).join(",")
p "#{comp.name}に所属する人は#{user_names}です"
end
上記のコードでは、まずはじめに会社をすべて取得し、その後各会社に対してusersテーブルから名前の情報を取得しています。
それでは、実行してみましょう。
Company Load (0.3ms) SELECT `companies`.* FROM `companies`
(0.3ms) SELECT `users`.`name` FROM `users` WHERE `users`.`company_id` = 1
"AB商事に所属する人は田中さん,佐藤さん,鈴木さんです"
(0.3ms) SELECT `users`.`name` FROM `users` WHERE `users`.`company_id` = 2
"CDテクノロジーに所属する人は吉田さん,高橋さんです"
(0.3ms) SELECT `users`.`name` FROM `users` WHERE `users`.`company_id` = 3
"EF株式会社に所属する人は山田さん,渡辺さんです"
上記のログからわかるように、まず1回目のSQLですべての会社の情報を取得し、その後、会社数分の人情報を取得するSQLが実行されています。
このように、N+1問題とは、はじめの1回のSQLでModelを取得し、そのModelに対するデータ数分(N回)のSQLが実行されてしまう状態のことを言います。
順番から考えると、N+1問題というよりは1+N問題と呼んだ方がイメージしやすいかと思います。
今回の例では3社だけなのでそれほど問題があるように見えませんが、データ数とSQLの実行回数が同じになるので、データ件数が増えた際に膨大な処理時間が掛かってしまいます。
次に、N+1問題の対応方法を説明します。
N+1問題の対策
先ほどのcompaniesとusersの例で考えると、eachよりも前にusersの情報を取得すればN+1が起きないようになると推測できます。
このような関連テーブルの情報を事前に読み込んでおく方法として、ここではActiveRecordのpreloadメソッドとeager_loadメソッドを紹介します。
まずは先ほどの処理をpreloadを使用して修正しました。
# すべての会社に対し、それぞれ所属する人の名前を出力する
Company.preload(:users).all.each do |comp|
# usersテーブルから名前を取得し、カンマ区切りで結合する
user_names = comp.users.pluck(:name).join(",")
p "#{comp.name}に所属する人は#{user_names}です"
end
先ほどとの違いはCompany
の後に.preload(:users)
を追加しただけです。これにより何が起きるかログを見て見ましょう。
Company Load (0.3ms) SELECT `companies`.* FROM `companies`
User Load (0.5ms) SELECT `users`.* FROM `users` WHERE `users`.`company_id` IN (1, 2, 3)
"AB商事に所属する人は田中さん,佐藤さん,鈴木さんです"
"CDテクノロジーに所属する人は吉田さん,高橋さんです"
"EF株式会社に所属する人は山田さん,渡辺さんです"
先ほどは4回実行されていたSQLが2回に減っています。また、1件目のSQLはどちらも同じなので、3回(Nの部分)のSQLが1つにまとめられているのがわかります。
このように、preloadを使用すれば事前にassociationをキャッシュしておくことができるのです。(Railsってすごい!)
同様にeager_loadもassociationをキャッシュすることができます。
# すべての会社に対し、それぞれ所属する人の名前を出力する
Company.eager_load(:users).all.each do |comp|
# usersテーブルから名前を取得し、カンマ区切りで結合する
user_names = comp.users.pluck(:name).join(",")
p "#{comp.name}に所属する人は#{user_names}です"
end
上記のコードは先ほどのpreloadをeager_loadに変えただけです。
実行結果を見てみましょう。
SQL (0.4ms) SELECT `companies`.`id` AS t0_r0, `companies`.`name` AS t0_r1, `companies`.`created_at` AS t0_r2, `companies`.`updated_at` AS t0_r3, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`company_id` AS t1_r2, `users`.`created_at` AS t1_r3, `users`.`updated_at` AS t1_r4 FROM `companies` LEFT OUTER JOIN `users` ON `users`.`company_id` = `companies`.`id`
"AB商事に所属する人は田中さん,佐藤さん,鈴木さんです"
"CDテクノロジーに所属する人は吉田さん,高橋さんです"
"EF株式会社に所属する人は山田さん,渡辺さんです"
eager_loadの場合はLEFT OUTER JOINが使われ、1回のSQLで全データを取得していることがわかります。
このように、preloadやeager_loadを用いて事前に必要な情報を読み込み&キャッシュしておくことで、N+1の対策を行うことができます。
まとめ
- N+1問題とは、データ量(N)+1回分のSQLが実行されパフォーマンスの低下につながる問題
- N+1問題の対策として、preloadやeager_loadを使用する
個人的な見解ですが、他の言語ではあまり聞かない問題なので、RailsのようなSQLをあまり意識しなくて済み、関連テーブルのデータもよしなに取ってきてくれるフレームワークだから起きがちな問題なんだと思っています。
しかし、Railsを使うようなweb系の業界ではミリ秒単位でのレスポンスタイム改善が求められるので、必ず意識しておく必要があると考えています。
また、N+1問題を検知するgemで「bullet」というものがありますが、正直ログを見ていれば普通に気付けると思っているので私は入れていません。
ただし、最初からN+1が発生しないように実装することは難しいので、まずは実装してみて、N+1が発生していたらpreloadやeager_loadをするという方法が良いと思います。
今回対策として出てきたpreloadとeager_loadの詳しい違いについては本記事では説明しませんので、他の方の記事を参考にしてみてください。