Open13

RailsでN + 1の環境を再現する Part 2

ハガユウキハガユウキ

WHY(なぜN + 1問題は発生してしまう)

  • あるレコード群に関連するデータを取得する際に、 N + 1問題を発生させてしまうような書き方をしているため。
  • 他にも理由があるかもだから、そこは別途調べる。

WHY(なぜN + 1問題は良くないのか)

  • 要件を満たすために別の最適なやり方があるにも関わらず、クエリを無駄に発行してしまっているのが問題。
  • データ数が少ない場合はN+1問題が発生しても大した影響はない。しかし、データ数が大量になってきた場合、N + 1問題が発生することでAPIレスポンスタイムも下がり、結果的にユーザーの画面表示が遅くなってしまう。なのでデータ数が大量なサービスにおいては、ユーザーの利便性を考えて、N + 1問題は解消すべき問題である。
  • アプリケーションのみならずDBにも複数回問い合わせをするので、DBにも負荷をかけてしまう。

WHAT(N + 1問題とは何か)

  • N+1問題とは、1回のクエリで取得できるレコード数に比例して、関連テーブルへのクエリ数が増えて、結果的にアプリケーションの処理パフォーマンスが落ちてしまう問題である。
  • Nは最初の1クエリのレコード数のことである。つまり、最初の1クエリの結果でNが決まる。

HOW(N + 1問題はどうやって解決する)

  • includes, eager_load, preloadを使う
  • 基本はincludesを使う。
  • 別途調べる。
ハガユウキハガユウキ

MEMO

  • 1レコード取得するのに1クエリ必要っててわけではないから、そこの認識を気をつける(SQLは1クエリで複数レコード取得できるから)。普段から「データ取得」って単語を聞いた際にどんなSQLが発行されるのか想像してないからそう思うんだな。あるテーブルの複数のレコードを取得するくらいなら1回のクエリで達成できる。データ取得って言っている時点でじゃあどうやって現実的にDBから取得するの?を考えた方が良いな。
ハガユウキハガユウキ

N + 1のNを全てのレコードとする

class UsersController < ApplicationController
  
  def index
    users = User.all
    render json: { users: }, include: :comments
  end

↓ ユーザー群を取得するクエリを発行して、その後、ユーザーに紐づくコメントを毎回DBに問い合わせして取得している

    # Started GET "/users" for 172.27.0.1 at 2024-03-02 15:43:47 +0900
    # Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
    # Processing by UsersController#index as */*
    # [active_model_serializers]   User Load (0.6ms)  SELECT `users`.* FROM `users`
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (0.7ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 1
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (0.7ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 2
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (0.8ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 3
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (1.1ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 4
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (0.8ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 5
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (1.3ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 6
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (1.0ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 7
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (0.6ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 8
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (0.9ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 9
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers]   Comment Load (0.7ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 10
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
    # [active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (117.57ms)
    # Completed 200 OK in 130ms (Views: 104.1ms | ActiveRecord: 16.4ms | Allocations: 23498)
ハガユウキハガユウキ

N + 1のNを3レコードとする

class UsersController < ApplicationController

  def index
    users = User.limit(3)
    render json: { users: }, include: :comments
  end
end
    # ^[[AStarted GET "/users" for 172.27.0.1 at 2024-03-02 15:49:08 +0900
    # Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
    # Processing by UsersController#index as */*
    # [active_model_serializers]   User Load (1.6ms)  SELECT `users`.* FROM `users` LIMIT 3
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:34:in `index'
    # [active_model_serializers]   Comment Load (0.9ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 1
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:34:in `index'
    # [active_model_serializers]   Comment Load (1.1ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 2
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:34:in `index'
    # [active_model_serializers]   Comment Load (0.9ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` = 3
    # [active_model_serializers]   ↳ app/controllers/users_controller.rb:34:in `index'
    # [active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (54.73ms)
    # Completed 200 OK in 79ms (Views: 58.8ms | ActiveRecord: 4.9ms | Allocations: 15167)
ハガユウキハガユウキ

1回目のクエリで取得されたレコード数に応じて、SELECTのクエリの発行回数が違うことが確認できた。

ハガユウキハガユウキ

preloadを使うとIN句でデータを取得する

preloadとは

preloadは親テーブルのレコードに関連したレコードを取得してキャッシュするメソッド。
キャッシュするからDB問い合わせが発生しても、アプリケーションはキャッシュしたデータを参照するようになる。
具体的には一回クエリを発行して親テーブルのデータを取得して、その後そのデータを元にIN句のクエリを発行して子テーブルのデータを取得する。なのでトータルクエリ数は2になっていので、データ量に比例してクエリ数が増えることを防げる。

preloadの使った方が良い場面

  • 親テーブルのレコード数が大きく、JOINのコストが大きい場合に使える
  • あるモデルを軸にその関連モデルの値を取得したい場合のN+1問題の解消の場面で使える

preloadの使わない方が良い場面

  • 親モデルと子モデルのデータを別々のクエリで取得しているので、子モデルのレコードがこの値の時の親モデルのレコードを取得するみたいのはできないの。その場面では使えない。

↓ Nがテーブルの全てのレコードの場合

class UsersController < ApplicationController

  def index
    users = User.preload(:comments)
    render json: { users: }, include: :comments
  end
end
Started GET "/users" for 172.27.0.1 at 2024-03-02 17:28:54 +0900
Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as */*
[active_model_serializers]   User Load (15.4ms)  SELECT `users`.* FROM `users`
[active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
[active_model_serializers]   Comment Load (10.4ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
[active_model_serializers]   ↳ app/controllers/users_controller.rb:5:in `index'
[active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (147.19ms)
Completed 200 OK in 173ms (Views: 115.7ms | ActiveRecord: 37.3ms | Allocations: 16990)

↓ Nが3の場合

class UsersController < ApplicationController
  def index
    users = User.limit(3).preload(:comments)
    render json: { users: }, include: :comments
  end
end
Started GET "/users" for 172.27.0.1 at 2024-03-02 17:33:07 +0900
Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as */*
[active_model_serializers]   User Load (1.8ms)  SELECT `users`.* FROM `users` LIMIT 3
[active_model_serializers]   ↳ app/controllers/users_controller.rb:4:in `index'
[active_model_serializers]   Comment Load (0.9ms)  SELECT `comments`.* FROM `comments` WHERE `comments`.`user_id` IN (1, 2, 3)
[active_model_serializers]   ↳ app/controllers/users_controller.rb:4:in `index'
[active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (133.17ms)
Completed 200 OK in 152ms (Views: 54.1ms | ActiveRecord: 84.2ms | Allocations: 12952)
ハガユウキハガユウキ

子レコードの値で親レコードを絞り込めないことの例

class UsersController < ApplicationController
  def index
    users = User.limit(3).preload(:comments).where( comments: { id: 1 } )
    render json: { users: }, include: :comments
  end
end

usersテーブルに発行されたクエリをよく見ると、where句にcomments.id = 1が指定されている。しかし、preloadはjoinではないので、comments.idなんかusersテーブルにないよと言われてこのエラーが出ている。joinしているならこのクエリは成功するんだけどね。

Started GET "/users" for 172.27.0.1 at 2024-03-02 17:53:30 +0900
Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as */*
[active_model_serializers]   User Load (3.8ms)  SELECT `users`.* FROM `users` WHERE `comments`.`id` = 1 LIMIT 3
[active_model_serializers]   ↳ app/controllers/users_controller.rb:4:in `index'
[active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (10.79ms)
Completed 500 Internal Server Error in 16ms (ActiveRecord: 3.8ms | Allocations: 2096)



ActiveRecord::StatementInvalid (Mysql2::Error: Unknown column 'comments.id' in 'where clause'):

app/controllers/users_controller.rb:4:in `index'
ハガユウキハガユウキ

eager_loadはLEFT_OUTER_JOINのクエリを発行して、関連先テーブルのデータをキャッシュする

LEFT OUTER JOINは関連先がなくても関連元を取得する。なので、APIレスポンスで関連元のデータが存在している。これに関してはpreloadでも一緒か。
LEFT OUTER JOINしているので発行されているクエリ自体は1個である。
1回のクエリで関連先のデータを取得して、その関連先のデータをキャッシュするからN + 1問題が解決する。

eager_loadを使った方が良い場面

  • 関連先のレコードの条件で、関連元のレコードを数を絞り込みたい場合に使える。

eager_loadを使わない方が良い場面

  • JOINのコストは関連元のレコード数 * 関連先のレコード数なので、関連元のレコード数と関連先のレコード数が多い場合だと、JOINのコストハンパないから使わない方が良い。
class UsersController < ApplicationController
  def index
    users = User.eager_load(:comments)
    render json: { users: }, include: :comments
  end
end
Started GET "/users" for 172.27.0.1 at 2024-03-02 18:01:35 +0900
Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as */*
[active_model_serializers]   SQL (6.5ms)  SELECT `users`.`id` AS t0_r0, `users`.`first_name` AS t0_r1, `users`.`last_name` AS t0_r2, `users`.`email` AS t0_r3, `users`.`created_at` AS t0_r4, `users`.`updated_at` AS t0_r5, `comments`.`id` AS t1_r0, `comments`.`title` AS t1_r1, `comments`.`body` AS t1_r2, `comments`.`user_id` AS t1_r3, `comments`.`created_at` AS t1_r4, `comments`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `comments` ON `comments`.`user_id` = `users`.`id`
[active_model_serializers]   ↳ app/controllers/users_controller.rb:4:in `index'
[active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (65.52ms)
Completed 200 OK in 85ms (Views: 62.6ms | ActiveRecord: 7.2ms | Allocations: 14318)
ハガユウキハガユウキ

ちゃんと関連先のテーブルの値を使って、関連元のテーブルを絞れた

class UsersController < ApplicationController
  def index
    users = User.eager_load(:comments).where(comments: { id: 1 } )
    render json: { users: }, include: :comments
  end
end
Started GET "/users" for 172.27.0.1 at 2024-03-02 18:24:40 +0900
Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as */*
[active_model_serializers]   SQL (0.9ms)  SELECT `users`.`id` AS t0_r0, `users`.`first_name` AS t0_r1, `users`.`last_name` AS t0_r2, `users`.`email` AS t0_r3, `users`.`created_at` AS t0_r4, `users`.`updated_at` AS t0_r5, `comments`.`id` AS t1_r0, `comments`.`title` AS t1_r1, `comments`.`body` AS t1_r2, `comments`.`user_id` AS t1_r3, `comments`.`created_at` AS t1_r4, `comments`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `comments` ON `comments`.`user_id` = `users`.`id` WHERE `comments`.`id` = 1
[active_model_serializers]   ↳ app/controllers/users_controller.rb:4:in `index'
[active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (96.93ms)
Completed 200 OK in 131ms (Views: 94.9ms | ActiveRecord: 10.0ms | Allocations: 10189)
ハガユウキハガユウキ

joinsは関連先のデータも含めたJOINIしたレコードそのものをARインスタンスとして返している。なので重複が発生している。

class UsersController < ApplicationController
  def index
    users = User.limit(3).joins(:comments)
    render json: { users: }
  end
end
curl localhost:3000/users | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   551    0   551    0     0   1546      0 --:--:-- --:--:-- --:--:--  1592
{
  "users": [
    {
      "id": 1,
      "first_name": "Valorie",
      "last_name": "Mraz",
      "email": "normand_murazik@bartell.test",
      "created_at": "2024-03-02T13:43:41.265+09:00",
      "updated_at": "2024-03-02T13:43:41.265+09:00"
    },
    {
      "id": 1,
      "first_name": "Valorie",
      "last_name": "Mraz",
      "email": "normand_murazik@bartell.test",
      "created_at": "2024-03-02T13:43:41.265+09:00",
      "updated_at": "2024-03-02T13:43:41.265+09:00"
    },
    {
      "id": 1,
      "first_name": "Valorie",
      "last_name": "Mraz",
      "email": "normand_murazik@bartell.test",
      "created_at": "2024-03-02T13:43:41.265+09:00",
      "updated_at": "2024-03-02T13:43:41.265+09:00"
    }
  ]
}
Started GET "/users" for 172.27.0.1 at 2024-03-02 18:42:32 +0900
Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as */*
[active_model_serializers]   User Load (0.6ms)  SELECT `users`.* FROM `users` INNER JOIN `comments` ON `comments`.`user_id` = `users`.`id` LIMIT 3
[active_model_serializers]   ↳ app/controllers/users_controller.rb:4:in `index'
[active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (26.04ms)
Completed 200 OK in 45ms (Views: 22.9ms | ActiveRecord: 6.2ms | Allocations: 7978)

eager_loadは関連元のインスタンスだけを返している。あと、joinしたレコードの重複を取り除いた上で、3件を表示してくれている。

class UsersController < ApplicationController
  def index
    users = User.limit(3).eager_load(:comments)
    render json: { users: }
  end
end
curl localhost:3000/users | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   562    0   562    0     0   1360      0 --:--:-- --:--:-- --:--:--  1405
{
  "users": [
    {
      "id": 1,
      "first_name": "Valorie",
      "last_name": "Mraz",
      "email": "normand_murazik@bartell.test",
      "created_at": "2024-03-02T13:43:41.265+09:00",
      "updated_at": "2024-03-02T13:43:41.265+09:00"
    },
    {
      "id": 2,
      "first_name": "Samuel",
      "last_name": "Pouros",
      "email": "cornelius@heaney-konopelski.test",
      "created_at": "2024-03-02T13:43:41.282+09:00",
      "updated_at": "2024-03-02T13:43:41.282+09:00"
    },
    {
      "id": 3,
      "first_name": "Coralie",
      "last_name": "Oberbrunner",
      "email": "clinton.grady@wyman.example",
      "created_at": "2024-03-02T13:43:41.299+09:00",
      "updated_at": "2024-03-02T13:43:41.299+09:00"
    }
  ]
Started GET "/users" for 172.27.0.1 at 2024-03-02 18:41:22 +0900
Cannot render console from 172.27.0.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
Processing by UsersController#index as */*
[active_model_serializers]   SQL (0.8ms)  SELECT DISTINCT `users`.`id` FROM `users` LEFT OUTER JOIN `comments` ON `comments`.`user_id` = `users`.`id` LIMIT 3
[active_model_serializers]   ↳ app/controllers/users_controller.rb:7:in `index'
[active_model_serializers]   SQL (1.0ms)  SELECT `users`.`id` AS t0_r0, `users`.`first_name` AS t0_r1, `users`.`last_name` AS t0_r2, `users`.`email` AS t0_r3, `users`.`created_at` AS t0_r4, `users`.`updated_at` AS t0_r5, `comments`.`id` AS t1_r0, `comments`.`title` AS t1_r1, `comments`.`body` AS t1_r2, `comments`.`user_id` AS t1_r3, `comments`.`created_at` AS t1_r4, `comments`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `comments` ON `comments`.`user_id` = `users`.`id` WHERE `users`.`id` IN (1, 2, 3)
[active_model_serializers]   ↳ app/controllers/users_controller.rb:7:in `index'
[active_model_serializers] Rendered ActiveModel::Serializer::Null with Hash (53.11ms)
Completed 200 OK in 75ms (Views: 43.7ms | ActiveRecord: 13.3ms | Allocations: 11603)
ハガユウキハガユウキ
cpfile() {
  tmpfile=$(mktemp)
  selected_filepath=$(find . -type f | fzf --preview 'cat {}')

  # -zで文字列の長さがゼロかどうかをチェック
  if [ -z "$selected_filepath" ]; then
    echo "ファイルが選択されませんでした"
    rm "$tmpfile"
    exit 1
  fi

  echo "新しく作るファイルのパスを入力してください:"
  echo "選択したファイルのパス: $selected_filepath"
  read new_filepath

  if [ -z "$new_filepath" ]; then
    echo "ファイルのパスが入力されませんでした"
    rm "$tmp_file"
    exit 1
  fi

  new_dir=$(dirname "$new_filepath")
  mkdir -p "$new_dir"

  cp "$selected_filepath" "$new_filepath"

  # $?は直前に実行されたコマンドの終了ステータス(終了コード)を保持する特殊変数
  # -eqは数値の等価比較を行う演算子
  if [ $? -eq 0 ]; then
    echo "ファイルを正常にコピーしました"
    echo "元のパス: $selected_filepath"
    echo "新しいパス: $new_filepath"

    export LAST_CP_FILEPATH="$new_filepath"
  else
    echo "ファイルのコピーに失敗しました"
  fi

  rm "$tmpfile"
}