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)
includesはデフォルトではpreloadと同じ挙動をして、関連先のテーブルの要素で絞り込みを行った場合などはeager_loadと同じ挙動をする。
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"
}