项目里有个文章列表接口,文章表里存着作者名,评论数要统计,每篇文章还要带上一周的热度值。功能不复杂,但接口响应贼慢。后来发现在循环里查了无数次数据库,典型的 N+1 问题。趁着周末优化了一下,顺手把分页也改了,从 2.3 秒降到了 0.2 秒。这篇把过程完整写出来。
一、问题复现:为什么接口这么慢
先看表结构。文章表 article 有 id、title、user_id、status、created_at。评论表 comment 有 id、article_id、content、likes。另一个 user 表有 id、nickname。
最开始的查询逻辑是:先查出文章列表,然后循环每篇文章,去查作者昵称,再去查评论数量,最后去查热度。代码如下:
$articles = Article::where('status', 1)
->order('created_at', 'desc')
->limit(10)
->select();
foreach ($articles as $article) {
$article->author_name = User::where('id', $article->user_id)->value('nickname');
$article->comment_count = Comment::where('article_id', $article->id)->count();
}
如果有 10 篇文章,那么查询次数是 1 + 10 + 10 = 21 次 SQL。如果你一次取 50 篇,就是 101 次。数据库连接来回建立、SQL 解析执行,时间全浪费在网络开销和数据库上下文切换上。
更离谱的是评论数统计,每条文章都单独 COUNT(*),其实可以用一个 GROUP BY 解决。这个案例就是典型的 N+1 模型。
二、用 with 预加载拯救关联查询
ThinkPHP 8 的模型关联有一个非常实用的机制叫做“预加载”。你可以在查询主模型时,通过 with() 方法把关联数据一次性查出来,然后在内存里对应到每个模型。这样数据库只执行两条 SQL:一条查文章,一条查关联数据。
首先在 Article 模型里定义关联:
namespace appmodel;
use thinkModel;
class Article extends Model
{
// 关联用户表(一对一)
public function author()
{
return $this->belongsTo(User::class, 'user_id');
}
// 关联评论表(一对多)
public function comments()
{
return $this->hasMany(Comment::class, 'article_id');
}
}
然后在查询时用 with 带上它们:
$articles = Article::with(['author', 'comments'])->where('status', 1)->limit(10)->select();
这样,ThinkPHP 会先执行第一条 SQL 查出 10 篇文章,再执行一条 SQL 查出这 10 篇文章的关联 user,再执行一条 SQL 查出这 10 篇文章的关联 comment。总共 3 条 SQL,而不是 21 条。
但这里有个小坑:如果你把评论全部查出来,然后每条文章去 collection 里 count 评论,那需要确保关联的正确性。更好的做法是:用 withCount 来统计关联数量,而不是加载全部评论。
改一下关联定义,单独加一个评论数量字段:
$articles = Article::with(['author'])
->withCount(['comments' => function($query) {
$query->where('status', 1);
}])
->where('status', 1)
->limit(10)
->select();
withCount 会在查询时增加一个子查询,计算出每篇文章的评论数,并注入到模型的 comments_count 属性中。这种方式既高效又准确。
此时 SQL 一共有 3 条:文章查询、用户查询(通过 IN)、评论计数查询(通过 GROUP BY)。不管文章有多少条,关联查询永远只需要 2 条。
三、性能对比:你说这叫同样功能?
我在本地对 50 篇文章做了测试,使用 Debug 工具查看 SQL 日志。原来的写法产生了 101 条 SQL,总耗时 328ms。优化后只产生 3 条 SQL,总耗时 14ms。整整快了 20 多倍。
如果你的文章一页 30 条,原写法要 61 条 SQL,优化后只有 3 条。在数据量变大后效果更明显。
所以永远不要在循环里写查询语句,这应该是 ThinkPHP 开发的基本素养。
四、分页太慢?试试游标分页
解决了 N+1 问题,但接口在翻到后面几页时依然很慢。因为用的是 paginate() 基于 LIMIT offset 的分页方式。比如第 10000 页要 OFFSET 100000 LIMIT 10,数据库还是要扫描前面 100000 行然后丢弃,非常耗时。
游标分页(也叫基于游标的分页)是一种更高效的方案:它不是跳过多少条,而是记录上一页最后一条数据的某个唯一字段(通常 ID 或创建时间),下一页只查询这个字段之后的数据。
以 ID 为例,第一页正常查:
$lastId = 0;
$articles = Article::where('id', '>', $lastId)
->order('id', 'asc')
->limit(10)
->select();
然后把这一页最后一条的 id 作为下一页的游标,传给下一页接口:
$cursor = $articles->last()->id;
$nextPage = Article::where('id', '>', $cursor)
->order('id', 'asc')
->limit(10)
->select();
这种方式让数据库可以直接利用主键索引定位到游标位置,不需要扫描跳过大量记录,性能极其稳定。即使数据量到几百万,翻到最后一页和翻第一页速度基本一样。
实现一个简单的游标分页接口:
public function list(Request $request)
{
$cursor = $request->param('cursor', 0);
$list = Article::with(['author'])
->withCount('comments')
->where('id', '>', $cursor)
->order('id', 'asc')
->limit(10)
->select();
return json([
'code' => 0,
'data' => $list,
'next_cursor' => $list->last() ? $list->last()->id : null,
]);
}
用 ID 做游标需要保证 ID 是严格递增的,或者可以用创建时间,但时间戳可能有重复,需要配合 ID 做二次排序。
如果你的项目里文章列表是按创建时间倒序显示,游标可以基于时间戳加 ID:
$list = Article::where(function($query) use ($cursorTime, $cursorId) {
$query->where('created_at', '<', $cursorTime)
->whereOr(function($q) use ($cursorTime, $cursorId) {
$q->where('created_at', $cursorTime)
->where('id', '<', $cursorId);
});
})
->order('created_at', 'desc')
->order('id', 'desc')
->limit(10)
->select();
写法复杂了一些,但性能依然远胜 offset。
五、一个容易忽视的坑:预加载的字段筛选
用了 with(['author']) 之后,它会默认把 user 表的所有字段查出来。如果 user 表里有密码、手机号等敏感字段,这肯定不行。可以在关联定义时用 field 方法限制字段。
public function author()
{
return $this->belongsTo(User::class, 'user_id')->field('id, nickname, avatar');
}
同理,withCount 也可以通过子查询条件统计指定范围的数据。比如只想统计有效评论:
withCount(['comments' => function($query) {
$query->where('is_hidden', 0);
}])
这样做不会把已经删除的评论算进去,保证数据准确。
六、更多优化细节
除了 with 和游标分页,还有几个和查询效率相关的习惯值得养成。
1. 永远不要使用 select *
在 ThinkPHP 中直接 Article::select() 会查询所有字段。如果表有很多冗余字段,比如文章内容几千字,列表页根本不需要。用 field 指定列表所需的字段,可以有效减少内存占用和传输时间。
Article::field('id, title, user_id, created_at')->select();
2. 避免用 late 静态绑定搞花活
很多人喜欢在模型里写复杂静态方法,比如 Article::getListWithAll()。如果方法内部又去调用其它模型查询,很容易造成代码耦合。建议在模型层只负责关联定义和基础查询,复杂业务逻辑统一放到 Service 层。
3. 使用 query 对象批量构造条件
当搜索条件很多时,尽量用数组方式查询或者调用 where 闭包,避免字符串拼接出现 SQL 注入。ThinkPHP 8 的参数绑定和数组查询方式还是相当安全的。
4. 必要的时候加索引
游标分页依赖的 id 主键天然有索引。如果按 created_at 排序,建议给 created_at 加索引。关联外键 article_id 和 user_id 也建议加上,否则预加载的 IN 查询会全表扫。
你可以在迁移文件里添加索引:
Schema::table('comment', function (Blueprint $table) {
$table->index('article_id');
});
七、完整的接口示例
把上面的内容整合成一个标准的列表接口,包含预加载、评论计数、游标分页,还有字段筛选。
public function index(Request $request)
{
$cursor = $request->param('cursor', 0);
$list = Article::field('id, title, user_id, created_at')
->with(['author' => function($query) {
$query->field('id, nickname');
}])
->withCount(['comments' => function($query) {
$query->where('status', 1);
}])
->where('status', 1)
->where('id', '>', $cursor)
->order('id', 'asc')
->limit(15)
->select();
return json([
'code' => 0,
'data' => $list,
'next_cursor' => $list->last() ? $list->last()->id : null,
]);
}
这个接口第一页传 cursor=0,之后传上一页返回的 next_cursor。不会再出现深翻页卡死的情况。
八、总结
写 ThinkPHP 的查询,脑子里始终绷紧一根弦:SQL 越少越好,扫描行数越少越好。用关联预加载解决 N+1,用游标分页代替大偏移量,这两招基本能覆盖大多数列表性能问题。
优化完之后,我这个接口从最初的 2 秒降到 0.2 秒,而且代码可读性也提高了。想想之前循环里写 SQL 的日子,实在不应该。
最后还是那句话,每天写代码之前多想想,能不能用一小段 with 代替一个循环?能不能把 limit 100000, 10 改成 where id > xxx?基础做好了,性能自然就上去了。

