はじめに
これまで「N+1問題」というものを知ってはいたものの、そこまで規模の大きい開発をしたことが無かったのであまり意識してこないまま今日まで過ごしてきました。。
しかし、実際に現場で働くと、規模が大きい開発ではデータの件数が何万件というものはザラにあるのでこれからは意識しないといけないと気付かされました(泣)
皆さんも、実際に現場入った際に必ずと言っていいほど必要な知識だと思うので是非ここで押さえておいてください!!
※ちょくちょくSQL用語が出てきますが、そこは理解している程で進めさせていただきます。
N+1問題とは?
簡単に言えば、ループ処理の中で都度SQLを発行してしまい、大量(必要以上)のSQLが発行されてパフォーマンスが低下してしまう問題のことです。
一つ例えを出しましょう。
AさんというUserのProduct(商品)を10件取得して、Aさんの商品一覧ページを表示したい時には、、
AさんのUserデータを取得するために1回
10件のProductsデータを取得するために10回
の合計11回のクエリを発行し、表示したいデータを取得することになります。
11件ほどでは大したことはないのですが、これが10000件や20000件だと大変です。
1回のクエリで0.001秒の時間がかかるとして、10000件とか20000件だと10秒や20秒もレスポンスに時間がかかってしまいます。ここまで待たされるのは面倒ですよね?
この問題はコードの書き方次第で解決することが出来て、
適切に書けば、仮に10000件のProductsであっても、2回のクエリで取得できるんです(笑)
問題解決するためのメソッドたち
さて、この問題を解決するためのやり方を見ていきましょう。
基本的には以下で紹介する5つのメソッドを活用していけば良いです。使いどころはそれぞれ少し違ったりするので使い分けられるように出来るようにしましょう。
メソッド | キャッシュ | クエリ | 用途 | データ参照 |
---|---|---|---|---|
joins | 無 | INNER JOIN | 関連テーブルでの絞り込み | 可 |
left_outer_joins | 無 | LEFT OUTER JOIN | 関連テーブルでの絞り込み | 可 |
eager_load | 有 | LEFT OUTER JOIN | ループ内で関連テーブルの値を使用する場合 | 可 |
preload | 有 | SELECT句をモデル毎に1回ずつ | ループ内で関連テーブルの値を使用する場合 | 不可 |
includes | 有 | 場合による | キャッシュ、必要なら絞り込み | 可 |
joins
ActiveRecordでSQLのINNER JOIN句を発行するメソッドのことです。このメソッドは、キャッシュしないのでメモリを必要最低限に抑えることが出来ます。関連モデルにある属性で絞り込みたい場合(絞り込み結果だけが必要など)に利用
すれば良いです。
# モデル名.joins(:関連名)
User.joins(:products).where(products: { id: 1 })
#=> SELECT `users`.* FROM `users` INNER JOIN `products` ON `products`.`user_id` = `users`.`id` WHERE `products`.`id` = 1
left_outer_joins
ActiveRecordでSQLのLEFT OUTER JOIN句を発行するメソッドです。
このメソッドでは、関連テーブルにレコードが存在しない場合にも結合元のレコードは全て取得します。
joinsメソッドと同様で、関連モデルにある属性で絞り込みたい場合(絞り込み結果だけが必要など)に利用
すれば良いです。
# モデル名.joins(:関連名)
User.left_outer_joins(:products).where(products: { id: 1 })
#=> SELECT "users".* FROM "users" LEFT OUTER JOIN "products" ON `products`.`user_id` = `users`.`id` WHERE `products`.`id` = 1
eager_load
関連付けをLEFT OUTER JOINで引いてきてキャッシュするメソッドです。このメソッドはクエリが1つで済むので高速処理が可能
です。
1対1あるいはN対1のアソシエーションをJOINする場合(belongs_to, has_one)や、JOINした先のテーブルの情報を参照したい場合(Whereによる絞り込みなど)の際に使えば良いです。
# モデル名.eager_load(:関連名)
User.eager_load(:products)
#=> SELECT `users`.`id`, `users`.`name`, `users`.`created_at`, `users`.`updated_at`, `products`.`id`, `products`.`user_id`, `products`.`created_at`, `products`.`updated_at` FROM `users` LEFT OUTER JOIN `products` ON `products`.`user_id` = `users`.`id`
注意点
- JOIN句は処理に時間が掛かるため、対象テーブル全体のカラム数や格納されたデータ量などにより処理速度が遅くなる事がある。
- 結合するテーブルサイズに注意する。
- JOIN句特有の問題が発生する可能性がある。
- RDB側で大きなデータ容量を使おうとしてメモリ不足になる
- ディスクを使用する事でスロークエリになる
preload
モデル毎にSQLを発行し関連付けをキャッシュするメソッドです。
しかし、関連先のデータ参照(Whereによる絞り込みなど)は出来ません。
多対多のアソシエーションの時に使ったら良いです。注意点としては、データ量が大きいと、IN句が大きくなりがちで、メモリを圧迫する可能性があるので注意する。
# モデル名.preload(:関連名)
User.preload(:products)
#=> SELECT `users`.* FROM `users`
#=> SELECT `products`.* FROM `products` WHERE `products`.`user_id` IN (1, 2, 3, ...)
注意点
- 関連モデル毎にSQLを発行するため、eager_loadよりもSQL発行回数が増えてしまう。
- RDBによってはIN句が長くなる事で予期しないRDB側のエラーが発生してしまう可能性がある。
- IN句に指定される要素の数に注意する。
includes
簡単に言うと、eager_loadとpreloadをよしなに使い分けてくれます。
しかし、個人的に言うと、includesは利用しない方が良い
です。
なぜならincludesは、後で誰かがコードリーディングする際に、そのコードを書いた時点で自分が何を意図してincludesを使用したのかわからなくなる
からです。
もし、意図を理解出来ても、それまでの時間がもったいないので出来るだけ、eager_loadかpreloadを使用したほうが良いでしょう。
# モデル名.includes(:関連名)
User.includes(:products)
#=> SELECT `users`.* FROM `users`
#=> SELECT `products`.* FROM `products` WHERE `products`.`user_id` IN (1, 2, 3, ...)
Bullet gemを使いこなしN+1問題を早期発見する
どれだけ気を付けていても抜けが出てしまうのが人間です。そんな部分をカバーしてくれるgemがBulletです。
開発中にN+1を検出した場合に、ブラウザでアラートを出力したりサーバーサイドでログ出力をし、同時に必要な対策も提示してくれる
優れものです。
また、CI上でのbuild時にN+1が無いか調べてくれるBagsnagやsentryなどの外部サービスに通知を行う機能などのオプションが用意されているので、ソースコード品質向上に役立ちます。
Bulletの使い方
1.Gemfileに以下を追記して、bundle install
を実行する。
group :development, :test do
...
gem 'bullet'
end
2.Bulletの初期設定を行うため、以下のコマンドを実行する。
※テスト環境でもbulletを有効にするか問われますので、状況に応じて対応してください。
$ rails g bullet:install
そうすると、config/environments/development.rb
に以下が追記されていると思います。
...
config.after_initialize do
# bulletを有効にする
Bullet.enable = true
# N+1を検出した際に問題点をJSのAlertで表示することを許可する
Bullet.alert = true
# bullet用のログファイルをlog/bullet.logを出力する
Bullet.bullet_logger = true
# N+1を検出した際にブラウザのコンソールにメッセージを表示する
Bullet.console = true
# Growlがインストールされている場合、Growlメッセージを表示する(デフォルトではコメントアウト)
# Bullet.growl = true
# BulletがRailsのログに記録することを許可する
Bullet.rails_logger = true
# N+1を検出した際に問題点をフッターに表示する
Bullet.add_footer = true
end
...
注意点
- すべてのN+1問題を検出できるわけでは無いので頼りすぎない。
- 慣れるまでの手助けや、見逃したN+1を検出する為のサポートツールとして利用する
- gemの導入は検討してから行う。
- バージョンアップのメンテナンスコストが発生する可能性がある
- チーム開発で他の人も同じコードで開発する上でbulletが開発のノイズになる可能性がある
終わりに
ここまでN+1問題についてどのように対応していけばいいかを見てきましたが、開発中からN+1問題を意識しながら作業することで気付きやすくなると思います。
ちなみに、キャッシュは便利ですが不要なところでキャッシュしてしまうと、パフォーマンス劣化やメモリ圧迫につながるので、しっかり判断していきましょう!
参考
Ruby on Railsのコードに潜むN+1クエリ問題をBullet gemで発見して、Railsサイトのレスポンスを最適化
ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由