Atrae Advent Calendar 2019 5日目を担当する新卒1年目の土屋です。
普段は、ビジネス版マッチングアプリ yenta でサーバサイドエンジニアとしてRailsで開発をしています。
アトラエに入社してから半年間、度々立ち向かってきたN+1問題について書きます。
対象読者は、Ruby on Railsを使って開発をしている初級者〜中級者です、ご容赦ください。
N+1問題とは
ループ処理(each, mapなど)を用いてデータを取得してくる際に、
必要以上にSQL文(クエリ)が発行され、レスポンスが遅くなる(パフォーマンスが低下する)
ことです。例えば、
AさんというUserのPosts(投稿)を5件取得して、Aさんの投稿一覧のページを表示したいといった時には、
- AさんのUserデータを取得するために1回
- 5件のPostsデータを取得するために5回
の合計6回のクエリを発行し、表示したいデータを取得することになります。
5件だったら大した問題にはなりませんが、
これが10000件だったら大変です。
仮に1回のクエリで0.001秒しか時間がかからなかったとしても、
10001回もクエリを叩いたら、10秒もかかります。
普段使うアプリやWebサービスでそんなにローディングで待たされたことがあるでしょうか。
このように、ループ処理によって、
N件のデータを取得したい時に、N+1回もクエリを発行してしまうことによって発生するパフォーマンス低下を、N+1問題といいます。
この問題はコードの書き方次第で解消でき、
適切に書けば、仮に10000件のPostsであっても、2回のクエリで取得できます。
(あんまり大きいテーブルをjoinしたくないとかindex張ろとかそういう話は今回はなしで、、!)
とりあえずpreloadかeager_load書こ
結論としては、ループ処理の前に preload
か eager_load
を書けばほぼ解決します。
無思考でも、この2つのどちらかを書いておけばひとまず解決することが多いです。
また、具体的にどう書いてなぜ解決されるのか?などは、ググるとたくさんの素晴らしい記事が出てきますので、そちらを見て頂ければいいかなと思いますw
参考:
【Ruby on Rails】N+1問題ってなんだ?
preloadとeager_loadで1000000億倍早くなったはなし
けどincludesはやめとこ
「rails N+1問題」などでググると、
上述のpreload
やeager_load
以外に、includes
を用いた解決法もいくつか出てくるかと思います。
が、includes
を用いるのは個人的にはあまりお勧めしません。
理由は、includes
を用いると、Railsがよしなにやろうとしすぎて、自分が予期していない挙動になる可能性があるのと、
preloadとeager_loadの違いは明確に理解して使い分けた方が良いと思うためです。
が、詳しくは下記の素晴らしい参考記事達に譲りますw
参考:
ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い
ActiveRecordのincludes, preload, eager_load の個人的な使い分け
[Rails] そのincludesはpreloading?それともeager loading?
また、上記の preload
, eager_load
, includes
, joins
などの違いを考えるにあたって、
テーブル同士の内部結合、外部結合周りが怪しいと理解しづらいので、怪しい方は先にこっちから整理すると良いと思います。
(僕はそもそもこっちが怪しかったので、最初全然ピンとこなかったです。)
参考:
SQL素人でも分かるテーブル結合(inner joinとouter join)
INNER JOINとOUTER JOINとは?
tips
上記の通り、基本的にはググればわかりやすい記事がたくさんあるのですが、
その中でも僕が実際にN+1問題と戦った時に、
「知りたいけどあんまり出てこなかった」「先輩のコードを見て / 直接聞いて知った」ことを、少し書きます。
孫以下の要素の(多段)joinの仕方
UserのPostについたCommentのデータをpreloadしたい時、
CommentはUserの孫要素にあたりますが、以下のように書きます。
User.preload(posts: :comments).each.{~~
UserのPostについたCommentについたFavoriteのデータをpreloadしたい時、
FavoriteはUserの曽孫要素にあたりますが、以下のように書きます。
User.all.preload(posts: [comments: :favorites]).each.{~~
その次や次の次は、、
User.all.preload(posts: [comments: [favorites: :hoge]]).each.{~~
User.all.preload(posts: [comments: [favorites: [hoge: :fuga]]]).each.{~~
のようにどんどんネストして行くように書きます。
複数かつ多段のjoinの仕方
前節とほぼ同じですが、地味に書き方迷ったので。
Postの子として、CommentとFavoriteがある場合が以下です。
User.all.preload(posts: [comments, favorites]).each.{~~
eager_load多すぎたらeager_loadだけまとめてscopeにしちゃう
の方がスッキリすると思います。
scope :eager_load_for_hogehoge, -> {
eager_load(hoge: [:fuga, piyo: [abc: :def]]).merge(User.where(id: 111))
}
チェーンで書かないと、せっかくeager_loadしても意味ない
「完璧にeager_loadingしたはずなのになぜかクエリが繰り返される、、」という時は、
色々とメソッドを介した結果、せっかくeager_loadingしたのに、
また改めてモデルを呼んでる場合があります。
おまけ
先日、検索機能を作っている時に、納期に焦って、このN+1問題の確認と解消をサボって雑に進めたら、
検索した際のクエリが重すぎて見事にstagingのDBが落ちました。
これが本番だったらと思うと、ぞっとします。
自分が発行するクエリには責任を持って開発していきたいですね。
また、Railsは全くの未経験で入社して8ヶ月程経ちましたが、流石に慣れてきたと同時に、
サーバサイドはデータを司る神になった気分()になれるので、好きになってきました。
次回は、同じく1年目の小倉です。よろしく!