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

Laravel Eloquent 性能优化:把 N+1 查询消灭在列表页 示意图

问题是怎么发生的

$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 的执行计划,也可以先用站内的文本处理工具整理日志再分析。