预加载与 N+1 问题
N+1 问题是 ORM 开发中最常见的性能陷阱:查询列表时触发 1 条查询,然后为每一行触发 1 条关联查询——100 行数据就是 101 条查询。Laravel 的 with() 预加载能把 N+1 优化成 2 条查询。
N+1 问题演示
// ❌ N+1 问题:
$tasks = Task::all(); // 查询 1:SELECT * FROM tasks(100 行)
foreach ($tasks as $task) {
echo $task->user->name;
// 查询 2-101:SELECT * FROM users WHERE id = ?(每行一次!)
}
// 总共:101 条 SQL 查询
// ✅ 预加载解决方案:
$tasks = Task::with('user')->get();
// 查询 1:SELECT * FROM tasks
// 查询 2:SELECT * FROM users WHERE id IN (1, 2, 3, ...)
// 总共:2 条 SQL 查询,无论有多少行
foreach ($tasks as $task) {
echo $task->user->name; // 从内存读取,不触发查询
}
with():预加载语法大全
// 预加载单个关联
Task::with('user')->get();
// 预加载多个关联
Task::with(['user', 'tags', 'project'])->get();
// 嵌套预加载
Task::with('comments.user')->get();
// 等价于 with(['comments' => fn($q) => $q->with('user')])
// 带条件的预加载
Task::with([
'comments' => function ($query) {
$query->where('resolved', false)->latest()->limit(5);
},
'tags' => fn($q) => $q->orderBy('name'),
])->get();
// 预加载 + 计数(同时获取关联数据和数量)
Task::with('user')
->withCount(['comments', 'tags'])
->get();
// 嵌套三层
Task::with('project.team.members')->get();
load() vs with()
// with():在初始查询时预加载(推荐,查询更优化)
$tasks = Task::with('user')->get();
// load():在已有集合上按需加载(用于动态场景)
$tasks = Task::all();
// ... 条件判断后决定是否需要加载
if ($needsUserData) {
$tasks->load('user'); // 触发一条额外查询
}
// loadMissing():只加载尚未加载的关联(防止重复查询)
$tasks->loadMissing('user');
// 单个模型加载
$task = Task::find(1);
$task->load(['user', 'comments', 'tags']);
发现 N+1 问题
方法一:Laravel Debugbar(开发环境)
composer require barryvdh/laravel-debugbar --dev
安装后访问页面时会在底部看到查询数量。如果看到大量重复的 SELECT * FROM users WHERE id = ?,就是 N+1。
方法二:Telescope
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
访问 /telescope/queries 可以看到按页面分组的所有查询,重复查询一目了然。
方法三:代码中强制检测
// 在测试或开发环境中,发现 N+1 时抛出异常
// app/Providers/AppServiceProvider.php
public function boot(): void
{
if (app()->isLocal() || app()->isTesting()) {
// Laravel 8.77+ 内置 N+1 检测
\Illuminate\Database\Eloquent\Model::preventLazyLoading();
// 触发懒加载时抛出 LazyLoadingViolationException
}
}
// 或者只记录日志(不中断)
\Illuminate\Database\Eloquent\Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
logger()->warning("N+1 detected: {$model}::{$relation}");
});
真实场景:TaskFlow 任务列表 API
// ❌ 会产生 N+1 的代码
public function index()
{
$tasks = Task::paginate(20);
return TaskResource::collection($tasks);
}
// TaskResource.php:
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'user' => ['name' => $this->user->name], // N+1!
'tags' => $this->tags->pluck('name'), // N+1!
'comments' => $this->comments_count, // N+1!
];
}
// ✅ 正确写法
public function index(Request $request)
{
$tasks = Task::query()
->forUser(auth()->id())
->with(['user:id,name,avatar', 'tags:id,name,color'])
->withCount('comments')
->when($request->status, fn($q, $s) => $q->where('status', $s))
->latest()
->paginate(20);
return TaskResource::collection($tasks);
}
// 总查询数:3 条(任务 + 用户 + 标签),不管有多少条任务
高级预加载:关联的关联
// 任务列表,带评论,带评论作者,带作者头像
Task::with([
'comments' => function ($query) {
$query->latest()->limit(3)->with('user:id,name,avatar');
},
'user:id,name',
'tags:id,name',
])->paginate(20);
// with() 中指定字段(减少查询数据量)
Task::with([
'user:id,name,email', // 只取 id,name,email(必须包含 id!)
'tags:id,name,color',
])->get();
预加载与 API Resource
// 在 API Resource 中安全地使用 whenLoaded()
// 未预加载时不会触发 N+1,而是直接跳过该字段
class TaskResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
// whenLoaded():只有预加载了才包含,否则省略该字段
'user' => new UserResource($this->whenLoaded('user')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'comments_count' => $this->when(
isset($this->comments_count),
$this->comments_count
),
];
}
}
下一章:数据库迁移、Seeder 与 Factory——Eloquent 模型设计好了,现在用 Migration 把表结构版本化,用 Factory 生成真实感的测试数据。