列表页显示 30 篇文章,每篇带分类名和作者名,如果你在模板里直接 {$post.category.name},数据库会收到 1 + 30 + 30 条查询。ThinkPHP 的关联查询同样需要预载入,只是写法和 Laravel 略有不同。

ThinkPHP 模型关联与预载入:列表页不再查爆数据库 示意图

定义关联

namespace app\model;

use think\Model;

class Post extends Model
{
    // 文章属于一个分类
    public function category()
    {
        return $this->belongsTo(Category::class, 'category_id');
    }

    // 文章属于一个作者
    public function author()
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    // 文章有多条评论
    public function comments()
    {
        return $this->hasMany(Comment::class, 'post_id');
    }
}

belongsTo 的外键在当前表(category_id),hasMany 的外键在关联表(post_id),方向弄反是最常见的错误。

用 with 预载入

$posts = Post::with(['category', 'author'])
    ->where('status', 'published')
    ->order('published_at', 'desc')
    ->paginate(20);

这样会先查文章,再用 where id in (...) 把分类与作者一次取回,SQL 从 41 条降到 3 条。模板里继续写 {$post.category.name} 即可,不需要改视图。

只取需要的字段

Post::with([
    'category' => function ($query) {
        $query->field('id,name');
    },
])->paginate(20);

注意字段里必须包含关联键(这里是 id),否则预载入匹配不上,表现为关联数据全是 null。

统计数量用 withCount

$posts = Post::withCount('comments')->paginate(20);

// 模板中
// {$post.comments_count}

withCount 生成的是子查询,不会把评论数据全部读进内存。需要按评论数排序时直接用 order('comments_count', 'desc')。

嵌套预载入

Post::with([
    'category',
    'comments' => function ($query) {
        $query->with('user')->limit(3);
    },
])->paginate(20);

嵌套预载入适合「列表里显示最新三条评论」这类场景。但要注意 limit 在预载入里是全局限制,不是每个父模型三条;需要按父模型取固定条数时,改用关联查询或单独查询再分组。

分页与关联的组合

关联数据不会影响分页总数,paginate 仍然按主表统计。如果列表需要按关联表字段筛选(例如「有评论的文章」),用 has 或 whereHas,而不是取出数据后在 PHP 里过滤:

Post::has('comments', '>=', 1)->paginate(20);

whereHas 会生成子查询,数据量大时要确认关联表的外键有索引,否则它会成为新的性能瓶颈。

缓存与查询次数

列表页这类高频查询可以在模型层做短缓存,键里带上页码、分类与排序条件。缓存时间不宜太长,否则作者改了文章标题,列表要等缓存过期才更新。

排查时最实用的办法是打开 SQL 日志,看一页请求到底发了几条 SQL。也可以把慢查询日志粘到文本处理工具里,按耗时排序后再看具体语句。

小结

预载入的核心只有一句话:知道要用的关联,就一次性取回来。定义关联时把外键方向写对、字段裁剪时带上关联键、统计用 withCount,就能避免绝大多数列表页的数据库压力。想对比不同写法的执行计划,可以参考Eloquent N+1 优化里的思路,两者本质相同。