Laravel の様なデータベースを写したモデルをプログラム中で取り扱う時、しばしば次の様にしてしまいがちです。
// 全ての会員を取得し
Member::get()->map(static function(Member $member){
// 各会員の持つ投稿全てについているコメントの数を集計する
return $member->posts->sum(fn($post)=>$post->comments->count());
});
これは PHP のコード上では短いコードですが、実際に実行すると長大な実行時間になりやすいです。実際には次の様に 1 度のクエリで引っ張ったレコードの数 N 分のクエリを発行する、N + 1 問題が起きます。これが起きると SQL の実行回数が非常に多くなりやすく、パフォーマンスの低下を招きます。
| クエリ実行回数 | 実行されるクエリの簡易表現 |
|---|---|
| 1 | select * from members |
| 会員の数 | select * from posts where member_id = {$memberId} |
| 会員の数×投稿の数 | select * from comments where post_id = {$postId} |
これの対策としては JOIN 句を使って必要なデータ全てを一度のクエリで取得する方法があります。また、Laravel の様なフレームワークにはしばしば N+1 が起きそうな書き方をしても N+1 を起こさない仕組みがあります。Laravel では次の様に with メソッドを使うことで少ないクエリ数で必要なレコードを集められます。
Eloquent:リレーション 6.x Laravel#Eagerロード
// with('posts.comments') とリレーションを指定すると適した形でリレーション先をあらかじめロードしてくれます。
Member::with('posts.comments')->get()->map(static function(Member $member){
return $member->posts->sum(fn($post)=>$post->comments->count());
});
また、既にモデルインスタンスがある場合でも with を使わずに次の様に同様のリレーション先のロードができます。
Eloquent:リレーション 6.x Laravel#遅延Eagerロード
/** @var Member $member */
$member->load('posts.comments');
毎回これらを使えればいいのですが、難しい時もあります。そういった時、同様のクエリを手動で作りたい時があります。これは次の様にできます。
public static function loadPosts(Collection $members): Collection
{
// 紐づけ用に主キーをCollectionのキーにします。(元のキーが大事な場合はもっと工夫が必要)
$members = $members->keyBy('member_id');
// 主キーである member_id の値を集めます
$memberIds = $members->map->member_id;
$posts = Post::whereIn('member_id', $memberIds)// members と紐づいているキーで where in
->get();// 必要な Post を一度のクエリで全取得
$posts->each(static function (Post $p) use ($members) {
$member = $members[$p->member_id];// ↑で主キーをキーにしたCollectionにすることで直ぐに呼べます
if(!isset($member->posts)){
$member->posts = collect();// null なら空のCollectionを用意
}
$members->posts->push($p);// postsプロパティに Post インスタンスを追加
});
// posts を付け終わった members を返却
return $members;
}
この様に WHERE IN で必要なレコードを全取得、数え上げ系処理で取得した各レコードをリレーションにしたがって後付け、とすることで with, load なしに同様の後付け処理が少ないクエリ数で実現できます。