Laravel Boostのadvanced-queriesを、発行されるSQLまで降りて読み直す
Laravel Boostを入れると、.claude/skills/laravel-best-practices/ の下にルールファイルが20本ほど置かれます。AIエージェントに読ませるための文章ですが、そのうちの rules/advanced-queries.md は人間が読んでも収穫が大きかったです。一覧画面が重いときに開く引き出しが、8つ並んでいます。
最初に開いたときの感想は「短すぎないか」でした。1ルールあたり10行前後で、一言の理由とコード例が1つ載って終わり。なぜそれで速くなるのかは書いていません。ところが読み込むほど、その一言が毎回きっちり急所を指していることが分かってきます。発行されるSQLまで降りて読み直したのがこの記事です。
環境はLaravel 13.20 / PHP 8.4。ルールファイルの原文はlaravel/boostのリポジトリにあるので、以下の引用はそこからのものです。
ひとつ先に断りを入れておくと、ルールファイルのコード例は scopeWithLastLoginAt($query) というメソッド名の接頭辞で書かれていますが、今のドキュメントのローカルスコープは #[Scope] 属性です。skillの入口である SKILL.md 自身がこう書いています。
Each rule file teaches what to do and why. For exact API syntax, verify with
search-docs. (各ルールファイルは、何をすべきかとその理由を教えるものである。正確なAPIの構文はsearch-docsで確認すること) — Laravel Boost「SKILL.md」
構文は自分で確かめる前提で書かれている、ということです。以下のコードは属性の形に直してあります。
前提にするスキーマ
ルールファイルの例に出てくるテーブルを、そのまま通し例に使います。
companies id, name
users id, name, email, company_id, created_at
logins id, user_id, ip_address, created_at -- 1ユーザーあたり数百〜数千件
features id, title, status, user_id -- status: Requested / Planned / Completed
comments id, feature_id, user_id, body
「ユーザー一覧に最終ログイン日時を出す」「ステータス別の件数を出す」といった、管理画面でよくある要件を想定しています。以降ずっと敵になるのは logins です。1ユーザーあたり数千件まで平気で膨らむのに、画面に出したいのはそのうちの1行だけ、という非対称がほぼ全パターンの出発点になっています。
8つのルールを先に並べるとこうなります。
| やりたいこと | 避ける書き方 | 推奨 |
|---|---|---|
| has-manyから値を1つだけ欲しい | with('logins') で全件ロード |
addSelect() の相関サブクエリ |
| has-manyの最新1件をモデルで欲しい | ロードしてPHPで ->first() |
サブクエリで外部キーを作り belongsTo |
| ステータス別の件数 | count() を3回 |
count(case when ...) を1回 |
| 子から親を参照するビュー | そのまま $child->parent |
setRelation() か chaperone() |
| リレーション先の条件で絞る | whereHas() |
whereIn() にサブクエリを渡す |
| 複雑な結合 | 巨大な1本のSQL | 選択性の高い2本に分割 |
| 複数列でのソート | 単一列インデックスを列の数だけ | 列順を揃えた複合インデックス |
| has-manyの値でソート | join して orderBy |
orderByDesc() にサブクエリ |
1. has-manyの単一値はaddSelectのサブクエリで取る
ユーザー一覧に最終ログイン日時を出す、という要件から始めます。素直に書くとこうなります。
$users = User::with('logins')->paginate(15);
{{ $user->logins->sortByDesc('created_at')->first()?->created_at }}
発行されるSQLは2本です。
select * from `users` limit 15 offset 0;
select * from `logins` where `logins`.`user_id` in (1, 2, ..., 15);
N+1ではありません。デバッグバーのクエリ数を見て「2本だしヨシ」と通してしまうのは、この形です。それでも問題があります。仮に1ユーザーあたり3,000件のログインがあれば、15 × 3,000 = 45,000行をDBから引いて、PHPで45,000個の Login モデルにハイドレート(オブジェクト化)します。欲しいのは15個の日時だけなのに、です。
クエリ本数が少ない = 速い、という素朴な指標がここで裏切られます。自分がこのルール集で一番持ち帰ったのは、たぶんこの1点でした。
ルールファイルの一文はここを指しています。
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via
addSelect(). This pulls the value directly in the main SQL query — zero extra queries. (最新のタイムスタンプのような単一の値のために、has-manyリレーション全体をeager loadするのではなく、addSelect()による相関サブクエリを使う。値をメインのSQLクエリで直接引けるので、追加のクエリはゼロになる) — Laravel Boost「advanced-queries.md」
書き換えるとこうなります。
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
#[Scope]
protected function withLastLoginAt(Builder $query): void
{
$query->addSelect([
'last_login_at' => Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])->withCasts(['last_login_at' => 'datetime']);
}
$users = User::withLastLoginAt()->paginate(15);
$users->first()->last_login_at->diffForHumans();
出るSQLは1本です。
select
`users`.*,
(
select `created_at` from `logins`
where `user_id` = `users`.`id`
order by `created_at` desc
limit 1
) as `last_login_at`
from `users`
limit 15 offset 0
whereColumn('user_id', 'users.id') が、内側のクエリを外側の1行に紐づけています。外側が返す行ごとに評価されるのが相関サブクエリで、ここでは limit 15 が先に効くので実行は15回。転送されるのは15行 × 1カラムで、Login モデルは1つも生成されません。logins(user_id, created_at) の複合インデックスがあれば、1回あたりインデックスの末尾を1行読むだけで済みます。
このパターンで引っかかる点が4つあります。
take(1) は必須です。サブクエリが2行以上返した時点でDBがエラーにします(MySQLなら Subquery returns more than 1 row)。
withCasts() も要ります。これが無いと last_login_at は文字列のまま返り、ビューで落ちます。
Call to a member function diffForHumans() on string
last_login_at は users に実在しないカラムなので、モデルの casts() に書く発想が出てこないのが厄介なところです。
ソースを追うと、キャストの判定は array_key_exists($key, $this->getCasts()) だけで、実在するカラムかどうかは見ていません(HasAttributes.php)。つまり casts() に書いても動きます。
それでも withCasts() を使うのは、スコープを付けたときしか存在しない属性の型宣言を、モデル全体の定義に混ぜないためです。値を作る場所と型を決める場所が、同じスコープの中に収まります。
select('users.*') を自分で書く必要はありません。addSelect() は、select句がまだ未設定なら {$this->from}.* を先に足してからサブクエリを追加する実装になっています(Builder.php)。
スコープを付け忘れると、例外ではなく null が返ります。User::find(1)->last_login_at は何も言わずに null です。一覧画面には日時が出ているのに詳細画面だけ空欄、のような形で出るので、ビューで使うならコントローラ側でスコープを付けたかまで見る癖を付けたほうがよさそうです。
集計で表せる値ならwithMaxで足りる
同じことは組み込みのヘルパでも書けます。
User::withMax('logins', 'created_at')->paginate(15);
// $user->logins_max_created_at
生成されるSQLはほぼ同じで、こちらのほうが短い。集計関数で表せる値なら withMax / withCount / withSum が第一選択です。
addSelect() のサブクエリが要るのは、集計では表せない値のときです。たとえば最終ログインのIPアドレスは MAX(ip_address) では取れません。それが返すのは最後のログインのIPではなく、辞書順で最大のIPだからです。
'last_login_ip' => Login::select('ip_address')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
ある列で並べ替えたときの先頭行の、別の列の値。ここが集計関数の守備範囲の外で、このパターンの本領です。
2. サブクエリで外部キーを作って動的なリレーションにする
最終ログインの日時もIPもUser-Agentも欲しい、となるとサブクエリを3本並べることになります。ルールファイルはそこで発想を変えろと言います。値ではなく id をサブクエリで取り、それを外部キーとして belongsTo を張る。
use Illuminate\Database\Eloquent\Relations\BelongsTo;
public function lastLogin(): BelongsTo
{
return $this->belongsTo(Login::class);
}
#[Scope]
protected function withLastLogin(Builder $query): void
{
$query->addSelect([
'last_login_id' => Login::select('id')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])->with('lastLogin');
}
$users = User::withLastLogin()->paginate(15);
$users->first()->lastLogin->ip_address;
$users->first()->lastLogin->created_at;
SQLは2本です。
select
`users`.*,
(select `id` from `logins` where `user_id` = `users`.`id`
order by `created_at` desc limit 1) as `last_login_id`
from `users` limit 15 offset 0;
select * from `logins` where `logins`.`id` in (912, 445, 1203, ...);
15ユーザーぶんの完全な Login モデルが、2クエリ・15行で手に入ります。
正直に書くと、ここは最初マジックに見えて疑いました。定義していないカラムに belongsTo が張れるはずがないだろう、と思いながらSQLを出して納得した口です。
タネは名前です。belongsTo(Login::class) は、メソッド名 lastLogin から外部キーを last_login_id と推測します。addSelect() のエイリアスをわざとその名前にしているので、Eloquentから見ると users に last_login_id が実在しているのと区別が付きません。あとは通常の with() が動くだけです。存在しないカラムをselect句で捏造してリレーションを成立させている、と言い換えられます。
エイリアス名とリレーション名の対応が命なので、片方だけリネームすると静かに壊れます。加えて2つ注意があります。
捏造したカラムはwhere句では使えません。where('last_login_id', 5) はDBに「そんなカラムは無い」と怒られます。selectのエイリアスを同じクエリのwhereから参照できないというSQLの仕様によるものです。having から参照できるのはMySQLの独自拡張で、PostgreSQLは同じように弾きます。
対象が本当に常に1件なら、hasOne()->latestOfMany() という組み込みの手段もあります(Has One of Many)。users 側にカラムを捏造しない代わりに生成されるSQLは複雑になるので、素直さを取るか、SQLを自分で握るかの選択になります。
3. countをN本から1本にする条件付き集計
ダッシュボードに「Requested: 42 / Planned: 13 / Completed: 87」を出したい、という要件です。
$requested = Feature::where('status', 'Requested')->count();
$planned = Feature::where('status', 'Planned')->count();
$completed = Feature::where('status', 'Completed')->count();
3回のスキャンと3往復のレイテンシがかかります。ステータスが8個に増えれば8クエリです。1本にまとめます。
$statuses = Feature::toBase()
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
->first();
$statuses->requested; // 42
select
count(case when status = 'Requested' then 1 end) as requested,
count(case when status = 'Planned' then 1 end) as planned,
count(case when status = 'Completed' then 1 end) as completed
from `features`
limit 1
テーブルを1回走査するだけで全部の数字が揃い、往復も1回になります。
case when 条件 then 1 end は、条件に合えば 1 を、合わなければ else を書いていないので NULL を返します。そして count() はNULLを数えません。この2つが噛み合って「条件に合った行だけを数える」が成立します。同じことは sum(case when ... then 1 else 0 end) でも書けるので、意図が伝わるほうを選べばよいと思います。
toBase() はEloquent Builderを素のクエリビルダに落とすメソッドです。付けないと、結果が Feature モデルとしてハイドレートされます。requested や planned という実在しない属性を持ち、id は null で、それでも save() は呼べてしまうモデルです。何かの拍子にそれが渡り歩く未来を考えると、toBase() の6文字はかなり安い。欲しいのはスカラー値3つだけなので、モデル化はそもそも純粋なコストでもあります。toBase() を付ければ stdClass が返り、ハイドレーションごと消えます。
selectRaw() にユーザー入力をそのまま埋め込まないのが唯一の注意点です。動的な値なら第2引数のバインディングを使います。
->selectRaw('count(case when status = ? then 1 end) as requested', ['Requested'])
なお、行を横に並べたいのではなく縦に欲しいなら、groupBy('status')->selectRaw('status, count(*) as total') のほうが素直です。条件付き集計が効くのは、1行に横並びで欲しいときに限ります。
4. setRelationで子から親への循環N+1を潰す
機能リクエストの詳細ページで、コメント一覧を出しつつ、各コメントに「どの機能へのコメントか」を添えている画面を考えます。
$feature = Feature::find(1);
$feature->load('comments.user');
@foreach ($feature->comments as $comment)
{{ $comment->user->name }}
<a href="{{ route('features.show', $comment->feature) }}">{{ $comment->feature->title }}</a>
@endforeach
comments.user はeager loadしているのに、クエリログはこうなります。
select * from `features` where `id` = 1;
select * from `comments` where `feature_id` = 1;
select * from `users` where `id` in (...);
select * from `features` where `id` = 1; ← コメント1件目
select * from `features` where `id` = 1; ← コメント2件目
... ← 以下コメント数だけ繰り返す
$comment->feature がコメント1件ごとに遅延ロードを発火しています。しかも取ってくるのは、既に $feature としてメモリにあるまったく同じ1行です。
この形が厄介なのは、原因がリンクを1本足しただけの変更にあることです。ビューに {{ $comment->feature->title }} を書いた本人には、リレーションを触った自覚がありません。comments.user のような下向きのeager loadは気にしていても、子から親へ戻る参照は視界に入らない。しかも同じ1行なのでバッファプールに乗り、開発環境の10件のコメントでは体感できません。コメントが200件付く画面で初めて効いてきます。
ルールファイルの解はこれです。
$feature->load('comments.user');
$feature->comments->each->setRelation('feature', $feature);
追加クエリは0本になります。やっているのは、各 Comment の $relations['feature'] に、すでに手元にある $feature そのものを差し込むことです。Eloquentは $comment->feature にアクセスしたときまず $relations を見て、入っていればそれを返してクエリを投げません。
->each-> はCollectionのハイオーダーメッセージで、each(fn ($comment) => $comment->setRelation('feature', $feature)) と同じです。
おまけとして、全コメントが同一のインスタンスを共有するのでメモリも減ります。遅延ロードだと、同じ行なのにコメント数だけ別インスタンスができていました。
解として気持ちがよかったのは、これがクエリの工夫ではないところです。SQLを1文字も変えずに、メモリ上のオブジェクトの持ち方だけでクエリが消えます。
同じことをフレームワークにやらせる手段もあります。Laravel 11.22で入った chaperone() です。
public function comments(): HasMany
{
return $this->hasMany(Comment::class)->chaperone();
}
これで $feature->comments をロードした時点で、各 Comment の feature に親が自動で注入されます(Automatically Hydrating Parent Models on Children)。呼び出し側が何も意識しなくてよくなるので、恒久的に直すならこちらです。使い分けはこうなります。
setRelation() |
chaperone() |
|
|---|---|---|
| 書く場所 | コントローラなどの呼び出し側 | モデルのリレーション定義 |
| 効果範囲 | その1箇所だけ | 全呼び出し箇所 |
| 向く場面 | ピンポイントの修正 | 恒久対応 |
5. whereHasをwhereIn + サブクエリに置き換える
会社名でユーザーを絞り込む一覧です。users.company_id が companies.id を参照しています。
User::whereHas('company', fn ($q) => $q->where('name', 'like', "%{$term}%"))
->paginate(15);
select * from `users`
where exists (
select * from `companies`
where `users`.`company_id` = `companies`.`id`
and `name` like '%acme%'
)
limit 15 offset 0
exists の中が users.company_id を参照しているので、これも相関サブクエリです。ルールファイルの指摘はここなのですが、8つの中で唯一「言い切りすぎでは」と引っかかった節でもあります。理由は後で書きます。
whereHas()emits a correlatedEXISTSsubquery that re-executes per row. UsingwhereIn()with aselect('id')subquery lets the database use an index lookup instead, without loading data into PHP memory. (whereHas()は行ごとに再実行される相関EXISTSサブクエリを吐く。select('id')のサブクエリをwhereIn()に渡せば、DBは代わりにインデックス参照を使えるうえ、PHPのメモリにデータを載せずに済む) — Laravel Boost「advanced-queries.md」
書き換えるとこうです。
User::whereIn('company_id', Company::where('name', 'like', "%{$term}%")->select('id'))
->paginate(15);
select * from `users`
where `company_id` in (
select `id` from `companies` where `name` like '%acme%'
)
limit 15 offset 0
内側が users の行を参照していません。非相関なので、DBは内側を先に1回だけ評価してIDの集合を作り、そのうえで users.company_id のインデックスを使えます。
ここで大事なのは、IDをPHPに持ってこないことです。
// これは別物
$ids = Company::where('name', 'like', "%{$term}%")->pluck('id');
User::whereIn('company_id', $ids)->paginate(15);
pluck() した時点でクエリが1本走り、全IDがPHPのメモリに載り、in (1, 2, 3, ..., 50000) という巨大なSQL文字列が生成されます。whereIn() にBuilderインスタンスをそのまま渡すと、Laravelはそれを実行せずサブクエリのSQLとして埋め込みます。このとき select('id') を忘れると、サブクエリの列数が多すぎるとしてエラーになります。
whereHasが常に悪いわけではない
ルールファイルは短いぶん言い切っていますが、鵜呑みにはできない部分です。近年のMySQL 8やPostgreSQLのオプティマイザは EXISTS をセミジョインに書き換えられるので、書き換えが効けば whereHas でも whereIn と同等のプランになります。一方で、統計情報や結合順序の推定が外れると行ごとの再実行に落ちる。MySQLの EXPLAIN に DEPENDENT SUBQUERY と出ているのがその状態です。
なので実務での判断はこうなると思っています。数万行程度なら差はほぼ出ないので whereHas の可読性を取る。遅い一覧画面に当たったらまず EXPLAIN を見て、DEPENDENT SUBQUERY が出ていたら置き換える。推測でどちらかに決めない。
置き換えると失うものもあります。whereHas('company') はリレーション定義に書かれた制約をすべて引き継ぎますが、手書きの whereIn は引き継ぎません。
public function company(): BelongsTo
{
return $this->belongsTo(Company::class)->where('is_active', true);
}
この定義があるとき、whereIn('company_id', Company::where('name', ...)->select('id')) は is_active = true を落とします。速くする目的の書き換えで検索結果が変わる、というのが一番やってはいけない壊し方で、しかもレビューでは気付きにくい。リレーション定義を開いてから移行するのが最低限だと思います。外部キー名をハードコードすることになる点も、リファクタ耐性としては劣化です。
なお Company にSoftDeletesのグローバルスコープが付いている場合は、Eloquent Builderを渡している以上ちゃんと適用されます。こちらの心配は要りません。
6. 1本の複雑なクエリより2本の単純なクエリ
このルールだけコード例がなく、考え方だけが書かれています。
Running a small, targeted secondary query and passing its results via
whereInis often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. (小さく的を絞った2本目のクエリを走らせ、その結果をwhereInで渡すほうが、1本の複雑な相関サブクエリや結合より速いことが多い。2本目が高い選択性を持ち、自身のインデックスを使えるなら、往復が増えるぶんの価値はある) — Laravel Boost「advanced-queries.md」
前項と並べると分かりにくいのですが、矛盾はしていません。同一ホストへの往復は0.1〜1ms程度で、オプティマイザが結合順序を誤って500msのプランを選ぶことのほうがずっと高くつく、という話です。クエリは1本にまとめるほど偉い、という思い込みを一度捨てにいく節でもあります。自分はけっこう捨てられていませんでした。
// 1本にまとめた版
Order::whereIn('user_id', User::whereIn('company_id',
Company::where('industry', 'SaaS')->select('id')
)->select('id'))
->where('created_at', '>=', now()->subDay())
->get();
// 2本に分けた版
$userIds = User::whereIn('company_id', Company::where('industry', 'SaaS')->select('id'))
->pluck('id'); // 300件しか返らない
$orders = Order::whereIn('user_id', $userIds)
->where('created_at', '>=', now()->subDay())
->get();
分けたほうが速くなる条件は、引用の後半にあるとおり1本目の選択性です。300件で済むなら in に並ぶ値も300個で、しかも「300件しかない」という確定情報をオプティマイザに渡せるのでプランが安定します。逆に1本目が50,000件返るなら、巨大なINリストがSQL文字列として生成され、パース時間・パケットサイズ・PHPのメモリのすべてで問題になります。目安は数千件までで、それを超えるならパターン5のようにBuilderをそのまま渡すべきです。
7. orderByの列順に一致する複合インデックス
ユーザー一覧を姓・名の順に並べてページネーションします。
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
ここで効かないのが、単一列インデックスを2本張ることです。
$table->index('last_name');
$table->index('first_name');
これで「ソートに使う列にはインデックスを張ってあります」と言えてしまうのが、この節の怖いところです。張ってはいます。ただ使われません。
Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. (単一列インデックスは複数列のソートのために組み合わせて使うことができない。複合インデックスが無ければ、DBはfilesortを行う) — Laravel Boost「advanced-queries.md」
filesortは、インデックスの順序を使えずメモリかディスク上で並べ替え直す処理です。EXPLAIN に Using filesort と出ます。正解は1本の複合インデックスです。
$table->index(['last_name', 'first_name']);
B-Treeインデックスは ('Sato', 'Ichiro') < ('Sato', 'Jiro') < ('Suzuki', 'Akira') のように、タプルとして並んだ状態でディスクに保存されています。order by last_name, first_name はこの並び順そのものなので、DBはインデックスを先頭から15件読むだけでソート済みの結果を返せます。ソート処理が消滅する、というのがこのルールの効き方です。
複合インデックスを張るときに押さえる点が3つあります。
1. 左端から連続していないと使えない
index(['last_name', 'first_name']) は last_name だけのソートや検索にも使えますが、first_name だけには使えません。先頭から連続した並びであることが条件です。
2. ASCとDESCの混在は効かない
orderBy('last_name')->orderByDesc('first_name') は、昇順のみの複合インデックスでは効きません。MySQL 8やPostgreSQLなら降順インデックスを作る手はありますが、$table->index() では表現できないため DB::statement() を書くことになります。全列の向きを揃えられないか設計を見直すほうが現実的だと思います。
3. whereとorder byはセットで考える
User::where('company_id', 5)->orderBy('last_name')->paginate();
このクエリに要るのは index(['company_id', 'last_name']) です。等価比較の列を先に、ソートの列を後に置く。
逆順の index(['last_name', 'company_id']) にすると、last_name 順に並んだインデックスを先頭から舐めながら company_id で弾いていく形になります。並べ替え自体は済んでいるので EXPLAIN に Using filesort は出ませんが、絞り込みにインデックスを使えていないぶん、15件を埋めるまでに読む行数が膨らみます。出ていないから健全とは限らない、という分かりにくい壊れ方をします。
8. has-manyの値でのソートは相関サブクエリで
最終ログインが新しい順にユーザーを並べたい、という要件です。joinで解くとこうなります。
User::join('logins', 'logins.user_id', '=', 'users.id')
->orderByDesc('logins.created_at')
->paginate(15);
paginate() が発行するのは2本です。
select count(*) as aggregate from `users`
inner join `logins` on `logins`.`user_id` = `users`.`id`;
select * from `users`
inner join `logins` on `logins`.`user_id` = `users`.`id`
order by `logins`.`created_at` desc
limit 15 offset 0;
ルールファイルは括弧書きで理由まで書いています。
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside
orderBy()instead, paired with anaddSelectscope for eager loading. (has-manyリレーションの値でソートするときは、joinを避ける(行が重複するため)。代わりにorderBy()の中で相関サブクエリを使い、eager loading用のaddSelectスコープと組み合わせる) — Laravel Boost「advanced-queries.md」
1ユーザーが3,000回ログインしていれば、結合後にそのユーザーが3,000行出ます。1本目の count(*) が数えているのも結合後の行なので、総件数はユーザー数ではなくログイン総数になり、1ページ目が同じユーザーだけで埋まる。
ページャに「全1,204,331件」と出ているのにユーザーは数万人しかいない、という画面、見たことがある人は意外と多いのではないでしょうか。
distinct() を足すと今度は count(distinct) が重くなり、users.id と logins.id の衝突も面倒を見ることになります。groupBy で潰す手もありますが、SQLは一気に複雑になり、MySQLの ONLY_FULL_GROUP_BY にも引っかかります。
相関サブクエリなら行は増えません。
#[Scope]
protected function orderByLastLogin(Builder $query): void
{
$query->orderByDesc(
Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1)
);
}
$users = User::withLastLoginAt() // 表示用の値
->orderByLastLogin() // 並べ替え
->paginate(15);
出るSQLはこうなります。
select
`users`.*,
(
select `created_at` from `logins`
where `user_id` = `users`.`id`
order by `created_at` desc
limit 1
) as `last_login_at`
from `users`
order by (
select `created_at` from `logins`
where `user_id` = `users`.`id`
order by `created_at` desc
limit 1
) desc
limit 15 offset 0
users が1行も増えていません。件数クエリのほうは select count(*) as aggregate from users になり、サブクエリもjoinも残らないので、ページャの総件数はユーザー数のままです。
ルールファイルが「addSelect スコープとペアで使え」と書いているのは、表示とソートが別物だからです。order by のサブクエリは並べ替えに使われるだけで、結果には含まれません。画面に日時も出したいなら、パターン1のスコープを併用します。
SQL上は同じサブクエリが2回書かれることになりますが、これを1回にまとめてくれるかはDB次第です。
気になるなら orderByRaw('last_login_at desc') でselectのエイリアスを参照する手もあります。order byからの出力列名参照は標準SQLで認められているので、MySQLでもPostgreSQLでも動きます。where句とは事情が違うところです。
ソート用のサブクエリはlimitより先に全行を舐める
パターン1と同じ形をしているので同じ性能だと思い込みそうになりますが、ここだけ前提が違います。ルールファイルにも書かれていない部分です。
パターン1のselectのサブクエリは、limit後の15行に対してしか走りませんでした。しかし order by は並べ替えてから15件取るので、先に全ユーザーぶんのサブクエリを評価しないと順位が決まりません。同じサブクエリでも、置き場所がselect句かorder by句かで実行回数の桁が変わります。
ユーザーが1,000人ならサブクエリは1,000回で、logins(user_id, created_at) の複合インデックスがあれば実用の範囲です。100万人なら100万回に加えて100万行のソートで、確実に落ちます。前提としてこのインデックスは必須で、無ければサブクエリ1回ごとにテーブルスキャンが走って掛け算で破綻します。
規模が大きいなら、答えは非正規化です。
// migration
$table->timestamp('last_login_at')->nullable()->index();
// ログイン処理で更新
$user->forceFill(['last_login_at' => now()])->save();
// クエリはただのインデックススキャンになる
User::orderByDesc('last_login_at')->paginate(15);
サブクエリのパターンは、非正規化するほどではない規模か、まだ要件が固まっていない段階の解です。スキーマを変えずに済むのが最大の利点なので、まずこれで作り、遅くなったら非正規化に移る順序が現実的だと思います。
効いたかどうかを自分の目で確認する
ここまで書いておいて何ですが、解説を読んで分かった気になるのが一番危ういと思っています。8つとも「データが増えたときに効く」ものなので、1,000件のシードデータでは差が出ません。むしろ複雑にしたぶん遅く見えることすらあります。手元でSQLを覗く手段を先に用意しておくのが前提です。
まず発行されるSQLを見ます。toRawSql() はバインド済みの実行可能なSQLを返します。
User::withLastLoginAt()->orderByLastLogin()->toRawSql();
リクエスト中の全クエリを流したいなら AppServiceProvider::boot() でリスナを張り、php artisan pail を別ターミナルで動かしておきます。
DB::listen(function ($query) {
Log::info($query->sql, ['bindings' => $query->bindings, 'time' => $query->time]);
});
パターン4の循環N+1は、遅延ロードを禁止すれば一発で炙り出せます。これは同じskillの rules/db-performance.md 側に書かれているものです。
Model::preventLazyLoading(! app()->isProduction());
遅延ロードが起きた瞬間に例外が飛ぶので、嫌でも気付きます。パンくずを足したコミットがその場で止まる、というのが理想的な気付き方です。
そのうえで実行計画を見ます。
User::whereHas('company', fn ($q) => $q->where('name', 'like', '%acme%'))->explain()->dd();
MySQLの出力で見るべきところは、だいたいこの5つに絞れます。
| 出力 | 意味 | 対処 |
|---|---|---|
DEPENDENT SUBQUERY |
相関サブクエリが行ごとに再実行されている | パターン5 |
Using filesort |
インデックスでソートできていない | パターン7 |
Using temporary |
一時テーブルを作っている | group byとdistinctを見直す |
type: ALL |
フルテーブルスキャン | インデックス追加 |
rows が実データ量に近い |
絞り込めていない | where句とインデックスを見直す |
ルール集の使い方はルール集自体に書いてある
8つを通して読むと、根っこは3つに集約されます。
見るべきはクエリ本数ではなく、転送行数とハイドレート数です。with('logins') は2クエリですが45,000行をPHPに載せ、addSelect() は1クエリで15個のスカラー値を返す。本数だけを指標にすると判断を間違えます。
そして、PHPでやっていることをSQLに押し戻します。全部取ってきてPHPで sortByDesc()->first() する、PHPで count() を繰り返す。どれもDBが桁違いに得意な仕事を、桁違いに苦手な場所で肩代わりしている状態です。サブクエリも条件付き集計も複合インデックスも、判断をDBのインデックスに任せるための道具でした。
3つ目は、常にこう書けではないということです。特にパターン5とパターン8は、規模とオプティマイザ次第で結論が変わります。これは自分の感想ではなく、skillの入口が最初に書いていることでもあります。
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. (どのルールを適用する前にも、そのアプリケーションが既にどうしているかを確認すること。Laravelには複数の妥当なやり方があり、最良の選択は、理論上は別のパターンのほうが優れていたとしても、コードベースが既に使っているものである。一貫性の無さは、最適でないパターンより悪い) — Laravel Boost「SKILL.md」
一覧画面を片っ端からサブクエリに書き換えるためのルール集ではない、ということです。早すぎる最適化ではなく、遅い画面が見つかったときに開く引き出しとして持っておく。それがこの8つの正しい使い方だと思います。
まとめ
- has-manyから単一の値が欲しいだけなら
addSelect()の相関サブクエリ。take(1)とwithCasts()が必須で、集計で表せるならwithMaxのほうが短いです - サブクエリのエイリアスを
last_login_idにするとbelongsToが成立します。捏造したカラムなのでwhere句では使えません count()のN本はcount(case when ...)の1本にまとめられます。スカラー値しか要らないのでtoBase()でモデル化を省きます- 子から親への参照は循環N+1になります。1箇所なら
setRelation()、恒久対応ならchaperone() whereHasからwhereInへの置き換えは、EXPLAINにDEPENDENT SUBQUERYが出てから。リレーション定義の制約を落とす副作用があります- 複数列のソートには列順を揃えた複合インデックス。単一列インデックスを並べても組み合わせては使えません
- has-manyの値でのソートは
orderByDesc()にサブクエリを渡します。ただし全行が評価されるので、規模が大きいなら非正規化
100行ちょっとのファイルを読むのに、SQLを1本ずつ書き出しながら半日使いました。密度で言えば、これまで読んだLaravelの文章の中でも上位に入ります。以前Boostのガイドラインを精読したときにも思いましたが、AIに読ませるために削り込まれた文章が、人間にとっては良い問題集になっている。理由が書かれていないぶん、自分でSQLに降りるしかないからだと思います。
手元の一覧画面にはまだ whereHas がそのまま残っています。次に「あの画面が重い」と言われたら、書き換える前に explain() から始めます。