バートリーのさいとうです。
今回は
N + 1問題はなぜ起きてしまうのか?
ということを、わかりやすく解説していきたいと思います。
この記事を読めば
- N + 1問題って何?
- N + 1問題が起きちゃうのはなぜ?
- その解決方法は?
ということを理解することができます。
多くのデータを扱う実務の現場では、この問題に対応していないと、アプリの動作が重くなり、使い手がストレスを感じる原因になりかねません。
アウトプット経験豊富なrails実務経験者の僕が、その原因と解決策を提示しますので、サクサクのアプリを作れるように勉強していきましょう!
N + 1問題とは?
では早速、本題に入っていきましょう。
まずは、N + 1問題の定義を見てみましょう。
N+1問題とは、データベースからデータを取り出す際に、大量のSQLが実行されて動作が重くなるという問題です。
【Rails】N+1問題って何?原因と対処法を徹底解説!(https://pikawaka.com/rails/n1)
これだけでも十分わかりやすい説明になっていますが、少し補足します。
N + 1問題を理解するのに非常に大切な、SQL(Structured Query Language)についてです。
SQLとは、データを取り扱うための言語です。具体的には、取得・挿入・更新・削除などをテーブルに指示することができます。
N + 1問題は、そのSQL文が大量に実行されてしまうことで起きる問題のようですね。
では、次に実際にどう起きているかを確認してみましょう。
実際の様子
ここでは、Userモデル(親)とItemモデル(子)が関連づけされている場合を例に取ります。
実際にN + 1問題が起きると、こういうことになります。すぐ後に解説するので、今は文の量に注目してください。
User Load (0.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 14 ORDER BY `users`.`id` ASC LIMIT 1
↳ app/views/shared/_header.html.erb:17
Rendered shared/_header.html.erb (Duration: 2576.6ms | Allocations: 1555125)
Item Load (0.3ms) SELECT `items`.* FROM `items` ORDER BY id DESC
↳ app/views/items/index.html.erb:128
ActiveStorage::Attachment Load (2.8ms) SELECT `active_storage_attachments`.* FROM `active_storage_attachments` WHERE `active_storage_attachments`.`record_id` = 29 AND `active_storage_attachments`.`record_type` = 'Item' AND `active_storage_attachments`.`name` = 'image' LIMIT 1
↳ app/views/items/index.html.erb:133
Started GET "/" for ::1 at 2021-05-04 15:43:36 +0900
(0.4ms) SET NAMES utf8, @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'), @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
Processing by ItemsController#index as HTML
Rendering items/index.html.erb within layouts/application
ActiveStorage::Blob Load (1.1ms) SELECT `active_storage_blobs`.* FROM `active_storage_blobs` WHERE `active_storage_blobs`.`id` = 33 LIMIT 1
↳ app/views/items/index.html.erb:133
User Load (1.4ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 14 ORDER BY `users`.`id` ASC LIMIT 1
↳ app/views/shared/_header.html.erb:17
Rendered shared/_header.html.erb (Duration: 49.9ms | Allocations: 10106)
Purchase Exists? (1.4ms) SELECT 1 AS one FROM `purchases` WHERE `purchases`.`item_id` = 29 LIMIT 1
...以下略
解消するとこうなります。
Item Load (3.9ms) SELECT `items`.* FROM `items` ORDER BY id DESC
↳ app/views/items/index.html.erb:128
User Load (3.7ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (14, 4, 12, 9)
↳ app/views/items/index.html.erb:128
例が小さくて申し訳ないないですが、個人開発のアプリでも、パッと見ただけでターミナルに流れてくるログの量が全然違うことがわかると思います。
ざっくり起きている処理の違いをまとめると
- N + 1問題解決の前は、
Items
テーブルの情報が呼び出されるごとに、そのレコードに紐づくusers
テーブルのレコードがその都度呼び出されている
=SQL文がその都度呼び出される。 - 解決後は、3行目の
IN (14, 4, 12, 9)
で、items
テーブルから取得されるレコードに紐づく全てのユーザー情報を一気に取得している
=SQL文1回で必要な情報全てが取得できる。
という違いが生まれます。
では、どのように違いが生まれるのでしょうか?
処理速度が低下する
冒頭で説明した、SQL文を呼び出すというのは、時間がかかる行為です。
理解がしやすいように、住民票を役場に取りに行く時の例で考えて見ましょう。
住民票を役場に取りに行く時には、まず自分の住民票というデータにアクセスするために、用紙を記入する必要があります。これがSQL文です。
その用紙を、役場の方が処理し、必要なデータ(登録されている住所や名前など)を取得し、僕たちにその結果を返してくれます。
ここでは、複数の情報が必要な場合を考えましょう。
家族4人分の情報が必要なのに、その情報を一枚一枚書く、すなわち必要な処理の数だけSQL文を発行するのは処理してくれる職員さんも大変です。これが、N + 1問題です。
だから、これを解決するために、記入する用紙には、同一世帯の人をまとめて書くスペースがありますよね。
これが、N + 1問題の解決につながります。代表の人の情報をもとに、他のすべての必要なデータを取得する。そしたら、処理する職員の方も楽そうです。
これと似たことを、プログラミングでもやろうよ、っていうのがN + 1問題の解決です。
- N + 1問題は、SQL文が大量に発行されることによって引き起こされる処理速度の低下
- 一つのデータ取得に対して、複数のデータを取得する必要がある際に起きる。
- 処理速度が低下するのは、SQL文一つ一つの発行に時間がかかるため。
N + 1問題を解決する3つの方法
さて、最後に簡単にN + 1問題の解決策を二つ述べていきたいと思います。
includes
includesを利用すると、N + 1問題を解消できます。つまり、先ほど提示した
Item Load (3.9ms) SELECT `items`.* FROM `items` ORDER BY id DESC
↳ app/views/items/index.html.erb:128
User Load (3.7ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (14, 4, 12, 9)
↳ app/views/items/index.html.erb:128
という状況を作り出せます。
具体的には、以下の通りです。
def index
@items = Item.includes(:user)
end
抽象化するとこうなります。
def index
@items = モデル名.includes(:モデル名)
end
今回の例では、itemsは外部キーとしてuserの情報を持ち、Viewではこのitemそれぞれに紐づくユーザー情報が表示されています。
<% @items.each do |item|%>
<h3 class='user-name'>
<%= item.user.nickname %>
(略)
もちろん、all
を利用しても同じことが出来ますが、all
だとN + 1問題が発生してしまうので、結果として処理速度低下の原因になりかねません。
しかし、こちらの記事によると、このincludesはどうやら推奨されていないようです。
代わりに、以下の二つを紹介します。
preloadとeager_load
正直僕は、これがまだ理解できていません。
なので、先ほどのqiitaの記事を参考にさせていただくと
preloading: 多対多のアソシエーションの場合
eager_load: 1対1あるいはN対1のアソシエーションをJOINする場合(belongs_to, has_one アソシエーション)
ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由
とあります。
つまり、アソシエーションの状況に応じて(データの取得をどう行いたいかも含めて)使い分けをするということです。この使い分けについては、参考の記事が非常にわかりやすく纏まっておりますので参考になさってください。
- includesを利用する
- preload, eager_loadをアソシエーションに応じて使い分ける
まとめ
いかがだったでしょうか。
今回は、N + 1問題がなぜ起きるのかというところを重点的に説明してみました。
少しでも、N + 1問題が理解できるきっかけになれば嬉しいです。
このように、週に1回~2回、Ruby, Ruby on Railsを中心に技術ブログを更新しています。
もし参考になったなと思ったら、ブックマークをお願い致します。
最後まで読んでいただき、ありがとうございました!
それでは。