ThinkPHP8 高效查询实战:with预加载干掉N+1,游标分页解决大偏移

2026-08-03 0 963

项目里有个文章列表接口,文章表里存着作者名,评论数要统计,每篇文章还要带上一周的热度值。功能不复杂,但接口响应贼慢。后来发现在循环里查了无数次数据库,典型的 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_iduser_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?基础做好了,性能自然就上去了。

ThinkPHP8 高效查询实战:with预加载干掉N+1,游标分页解决大偏移
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP8 高效查询实战:with预加载干掉N+1,游标分页解决大偏移 https://www.taomawang.com/server/thinkphp/2482.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务