はじめに
業務で「こんな形でN+1が発生するんだな」と衝撃だったので、メモ
前提
環境
Laravel 8.5.0
PHP 8.0.2
やりたいこと
いわゆる「垢BAN機能」を実装したい
※ちなみに、この機能自体は本題と関係ないです。
テーブル構成
テーブルは以下の通り
※必要ない箇所は割愛
- Userテーブル
カラム名 | 型 | 制御 | 説明 |
---|---|---|---|
id | int | PK | ユーザーID |
name | string | not null | 名前 |
... | ... | ... | ... |
- UserAttributeテーブル
カラム名 | 型 | 制御 | 説明 |
---|---|---|---|
id | int | PK | ユーザー詳細ID |
user_id | string | FK | ユーザーID |
... | ... | ... | ... |
blocked | tinyint | nullable | ブロックフラグ(1だとBANされているアカウント) |
... | ... | ... | ... |
実装方法
事前に、UserモデルからuserAttributeモデルには以下のようにリレーションを張っている
public function userAttribute()
{
return $this->hasOne(UserAttribute::class);
}
そして、こちらのソースコードをほぼそのまま使って実装
アカウントのBANするか否かのチェックをミドルウェアで持たせてる
※実際はEnum使ったりしてますが、本題からそれるので割愛
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class CheckBlockedUser
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (Auth::check() && $user = Auth::user()) {
$userAttribute = $user->userAttribute; // ※
if ($userAttribute && $userAttribute->blocked == 1) {
Auth::logout();
$request->session()->regenerateToken();
Log::notice('Block user from login status. '.$user->id);
return redirect()->route('login')
->withErrors(['error' => 'アカウントは凍結されています。']);
}
}
return $next($request);
}
}
チェックするのはUserAttributesのblockedプロパティにあるので、※の箇所でuserからuserAttributesを取得してから、blockedへアクセスしている
上記太字部分が本題。
これで、問題ないだろうと思い、動作テストをしてみたところ垢BAN機能は実装できていた。
起こったこと
ただ、正常ユーザー(ブロックされていないユーザー)でログインできるかもテストしておこうと思い、ログインをしてみる
すると、こいつが出てきた
※実行環境がlocalhostなのは気になさらないでください。
いやいや、待て待て。
なぜN+1???
User→UserAttributeって$userAttribute = $user->userAttribute
で、1ユーザーしか取得してないやん。
しかも、ログイン後に遷移するページによってはN+1が発生するところと、発生しないところがあった
謎。。。
やったこと
とはいえ、N+1は対処しなければいけないので、N+1が発生するところでサクッとLaravel-debugbarでクエリを確認する
すると、原因が分かってきた
原因
原因はControlerでも同じように$user→userattributeへのアクセスをして同じSQLが実行されていたためだった。
実際にControllerで既に存在したソースコードが以下
$user = User::find(Auth::id());
$userAttribute = $user->userAttribute; // ※ココでuserAttributeを取得すSQLが発行
他のN+1が発生していたページのControllerを見ると同じような記述が見つかったので、これが原因だと断定
Laravelとしては既にMiddlewareで$userAttributeを取得しているのに、Controllerでも取得しているもんだから、N+1だと判断しちゃうんだな。。。
※N+1発生までのイメージは以下
Middleware(今回実装箇所) : userAttribute取得
↓
Route
↓
Controller : ここでもuserAttribute取得→既にMiddlewareで同じクエリ発行済みなのでN+1と判断される
対応方法
じゃあ、どうするか。
MiddelwareもControllerもどちらもAuth::idかAuth::userの取得でないと機能が成り立たないし、
Middlewareで取得した$userAttributeをControllerまで渡すのは現実的ではない。
なので、N+1のアラートを表示する laravel-query-detector の設定を変更し、アラートを表示させないようにした
具体的には laravel-query-detector をインストールした時に入ってくるconfig/querydetector.phpのexceptの箇所に以下記述
'except' => [
User::class => [
UserAttribute::class,
'userAttribute',
],
]
UserとUserAttributeは1対1なので、N+1は基本発生しない前提でこちらを設定
これで、N+1のアラートの発生は免れた
尚、 laravel-query-detector 本家のconfigのexceptは以下
最後に
まさか、MiddlewareとControllerで同じクエリが発行されているとは気づかないし、これをN+1と判断されるとも知らなかった…
すごく便利な laravel-query-detector ですが、ちょっとおせっかいな部分もあるのだな、と感じました。
同時に、こんな時にexceptの機能があるのはありがたいなと感じました。
最後までお読みいただきありがとうございました。