N+1 是 Eloquent 最常见也最容易被忽视的性能问题:列表里 20 篇文章,每篇都要显示分类名,于是先查 1 次文章、再查 20 次分类,一页 41 条 SQL。开发环境数据少时完全感觉不到,上线后接口就开始变慢。

问题是怎么发生的
$posts = Post::published()->latest()->paginate(20);
foreach ($posts as $post) {
// 每循环一次就查一次分类
echo $post->category->name;
}
Eloquent 的关联是懒加载:第一次访问 $post->category 时才去查库,而且只查这一条。想确认是否命中,打开查询日志即可。
DB::listen(function ($query) {
logger($query->sql, $query->bindings);
});
用 with 预载入
$posts = Post::with('category:id,name')
->published()
->latest()
->paginate(20);
加上 with 之后,Eloquent 会先查文章,再用一条 where id in (...) 把分类一次性取回,SQL 从 21 条降到 2 条。注意 category:id,name 里必须带上关联键 id,否则匹配不上。
嵌套预载入与多个关联
Post::with([
'category:id,name',
'author:id,name',
'tags:id,name',
])->published()->paginate(20);
标签这种多对多关联也能一次预载入。若某个页面只在详情时才需要作者信息,就不要放进列表查询,避免多余开销。
只统计数量时用 withCount
列表上显示「3 条评论」并不需要把评论查出来。用 withCount 会生成子查询,比预载入更省内存。
Post::withCount('comments')
->published()
->orderByDesc('comments_count')
->paginate(20);
如果还要在排序或筛选里用到这个计数,可以直接 having('comments_count', '>', 5),但要清楚它是子查询结果,数据量很大时记得给关联表的外键建索引。
字段裁剪与索引
- 列表页用
select(['id', 'title', 'slug', 'published_at']),不要select *把正文也读出来; status、published_at、category_id是常见筛选条件,组合索引比三个单列索引更有效;- 排序字段与筛选字段顺序有关:
where status = ? order by published_at desc适合(status, published_at)复合索引; - 用
EXPLAIN确认是否走索引,而不是凭感觉加索引。
loadMissing 与按需加载
同一个模型可能在不同分支里被复用时,用 loadMissing 比 load 安全:已经预载入过的关联不会被重复查询。
$post->loadMissing('category', 'tags');
反过来,如果确定某段逻辑不需要关联,不要把 with 写在全局作用域里,按需选择才是最优。
分页与缓存
深分页(第 500 页)会越来越慢,因为数据库仍要扫描前面的行。数据量大时改成基于游标的分页(cursorPaginate),或者限定可翻页的最大页数。
首页、分类页这类高频且变化不快的列表,可以配合 Cache::remember 缓存 60 秒,键里带上页码与筛选条件。缓存失效策略比缓存本身更重要,这一点在ThinkPHP 缓存实践里也适用。
一个检查清单
- 列表页是否用了
with/withCount; - 是否有
select *读取了不需要的大字段; - 筛选与排序字段是否有对应索引;
- 分页是否用了
paginate而不是get()后手动切片; - 查询日志里的 SQL 条数是否随每页条数线性增长。
把这些养成习惯,绝大多数「越用越慢」的列表页都能在半小时内定位。想快速验证一段 SQL 的执行计划,也可以先用站内的文本处理工具整理日志再分析。