Laravel的Eloquent ORM用起来非常顺手,定义好模型关联之后,$post->author一行代码就能拿到关联数据。但这份便利背后藏着性能陷阱:在循环里访问关联属性时,Laravel会为每一条记录单独发起一次查询,这就是臭名昭著的N+1问题。列表页只有20条数据时你几乎感觉不到,可当数据量涨到几百上千条,数据库会被无意义的重复查询拖垮。这篇文章围绕关联查询的性能优化展开,从原理到方案逐步拆解。

N+1问题是怎么产生的
先看一段典型的慢代码。假设有一个博客系统,文章模型属于某个作者,属于某个分类,我们在控制器里遍历输出:
$posts = Post::all(); // 第1次查询
foreach ($posts as $post) {
echo $post->author->name; // 每次循环1次查询
echo $post->category->title; // 每次循环又1次查询
}如果表里有100篇文章,这段代码会执行 1 + 100 + 100 = 201条SQL。Laravel的关联属性采用了懒加载机制:只有在你真正访问$post->author的那一刻,Eloquent才会根据外键去authors表查一次。单看每次查询都很快,但架不住次数多,网络往返和数据库解析的开销会成倍累积。
这类问题在开发环境很难暴露,因为本地数据少、数据库就在同机,一条查询可能只要0.5毫秒。到了生产环境,应用服务器和数据库之间有网络延迟,每次往返按2毫秒算,200次查询就是400毫秒纯开销。所以排查时别只看单条SQL的耗时,要关注查询次数。开启Laravel的Debugbar或者监听DB::listen,可以直观看到一次请求到底执行了多少条SQL。
用with预加载一次性解决
解决N+1最直接的手段是预加载,也就是在主查询发出之后,用一条额外的查询把所有关联数据一次性取回来,再在内存里做好匹配。写法很简单:
$posts = Post::with(['author', 'category'])->get();
// 总共只执行3条SQL:
// select * from posts
// select * from authors where id in (...)
// select * from categories where id in (...)
foreach ($posts as $post) {
echo $post->author->name; // 直接读内存,不再触发查询
echo $post->category->title;
}不管文章有多少条,查询次数固定是1加上关联数量,这就是预加载的核心价值。嵌套关联用点号语法即可,比如with('author.profile'),Laravel会分别针对authors和profiles各发一条查询。
预加载还可以做两件更精细的事。第一是指定查询字段,避免把大字段拖回来:
$posts = Post::with(['author:id,name,avatar'])->get();
注意指定字段时必须包含关联所需的外键,否则匹配会失败。第二是给预加载加约束条件,比如只加载最近30天发布过文章的作者:
$posts = Post::with(['author' => function ($query) {
$query->where('active', 1)->select('id', 'name');
}])->get();Laravel 11之后还支持更简洁的写法with('author:name'),条件约束也逐步支持链式语法,建议根据自己项目的版本选择合适的写法。
延迟预加载与has约束的合理使用
有时候你无法在最初查询时就确定需要哪些关联,比如在服务层里先拿到集合,后续逻辑分支才用到关联。这时可以用延迟预加载:
$posts = Post::all();
// 后续某处才发现需要作者信息
$posts->load('author', 'category');load方法会对已有集合补发预加载查询,效果和一开始就写with一致,同样只需一条SQL。还有一种常见误区:为了过滤数据,有人在循环里判断关联属性。比如只想显示有作者的文章,写成了遍历后判断$post->author是否为空,这同样会触发N+1。正确做法是用has或whereHas把条件下推到SQL层面:
// 只取有作者的文章,1条SQL搞定
$posts = Post::has('author')->get();
// 作者名包含关键词的文章
$posts = Post::whereHas('author', function ($query) {
$query->where('name', 'like', '%张%');
})->get();has底层会生成where exists子查询,由数据库索引完成过滤,效率远高于取回全量数据后在PHP里筛。但也要注意whereHas的like条件可能用不上索引,数据量大时需要结合全文索引或搜索引擎考虑。
join、分页与缓存的组合拳
预加载解决的是查询次数问题,但有些场景下Eloquent本身就不合适。比如报表类查询,要跨三四张表聚合统计,用关联模型层层取数会生成大量对象,内存占用高。这种时候直接写join更实在:
$stats = DB::table('posts')
->join('authors', 'authors.id', '=', 'posts.author_id')
->select('authors.name', DB::raw('count(*) as total'))
->groupBy('authors.name')
->get();join一次往返拿到结果,没有对象实例化开销,统计类接口用它性能能提升一个量级。当然代价是丢失了模型的便利性,比如访问器、类型转换都用不了,所以业务接口还是优先Eloquent加预加载,报表和导出走query builder或原生SQL。
另外两个容易被忽视的点。一是分页:永远不要对预加载的集合在PHP里做分页,Post::with('author')->get()之后再slice等于把全表读进内存,正确做法是Post::with('author')->paginate(20),让数据库只返回当前页数据,关联查询也只针对这20条。二是缓存:对于更新频率低的关联数据,比如文章的分类信息,可以用缓存 remember住结果,减少重复查询。
$categories = Cache::remember('post_categories', 3600, function () {
return Category::withCount('posts')->get();
});总结一下优化思路:先看查询次数,用with消掉N+1;再用字段指定和约束减少无效数据传输;过滤条件尽量下推到SQL;统计报表用join;最后配合分页和缓存控制整体负载。按这个顺序排查,绝大多数关联查询的性能问题都能落地解决。
Laravel关联查询 eager loading N+1查询修改时间:2026-09-08 15:01:13