Ruby on Rails

【Rails】N + 1問題はなぜ起きるか、よくわからないデータの動きを探ってみた

バートリーのさいとうです。

今回は

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を中心に技術ブログを更新しています。

もし参考になったなと思ったら、ブックマークをお願い致します。

最後まで読んでいただき、ありがとうございました!

それでは。

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA