thinkphp分页慢主因是默认paginate()强制执行count(*)全表扫描并叠加深分页limit丢行,应禁用总数统计、启用simple分页或改用基于主键的游标分页。

ThinkPHP分页慢,八成卡在重复执行 COUNT(*) 上——它不是框架写得烂,而是默认 paginate() 强制先查总数、再查数据,两次全表扫描叠加深分页时的丢行成本,性能直接线性崩塌。
为什么 COUNT(*) 会重复且变慢
ThinkPHP 的 paginate() 默认生成两条 SQL:SELECT COUNT(*) ... 和 SELECT ... LIMIT offset, size。问题出在:
-
COUNT(*)若没走覆盖索引(比如带JOIN、GROUP BY或软删除未过滤),EXPLAIN显示type = ALL,扫描行数等于全表 - 深分页如
LIMIT 100000, 20,MySQL 必须定位并丢弃前 10 万行,IO + CPU 双重浪费 - TP 6+ 曾尝试用
SQL_CALC_FOUND_ROWS合并查询,但实测开销不比独立COUNT(*)小,且结果可能和实际 LIMIT 数据对不上
禁用 COUNT(*) 最快落地方式
多数场景根本不需要精确总页数,关掉就能立竿见影:
- 控制器中调用
paginate()时,第二个参数传false:$list = User::where('status', 1)->paginate(20, false) - 模板里必须用
{$list->render()},不能用{$list->show()}(simple 模式下会报错) - 判断是否有下一页,改用
$list->hasMore(),别调$list->lastPage()(它在 simple 模式下返回null) - 若需保留搜索参数(如
?keyword=abc&page=3),必须显式加query:paginate(20, false, ['query' => request()->only(['keyword', 'category_id'])])
缓存 COUNT(*) 要看数据变更节奏
缓存不是一配了事,关键在“什么时候能缓、怎么缓才不脏”:
- 低频变更数据(如后台用户总数、某类订单日汇总):用 Redis 缓存
COUNT(*)结果,过期时间设为 5–30 分钟;更新时走异步任务或事件监听刷新,别每次增删都同步INCR/DECR - 高频写入但精度要求不高(如文章浏览量):直接用 Redis
INCR计数,完全绕过数据库COUNT(*);注意高并发下必须用INCR,别先GET再SET,否则竞态 - 绝对不能缓存的场景:软删除未过滤、状态字段没进
WHERE条件、关联统计(如“每个分类下文章数”)——缓存键难设计,易脏,不如用withCount()或定时预计算
游标分页才是大数据量下的正解
当数据按时间或 ID 有序,且允许放弃任意页跳转时,游标分页是唯一能保持 O(log n) 查询稳定性的方案:
- 核心是记住上一页最后一条记录的排序字段值,例如
id = 100500,下一页查WHERE id > 100500 ORDER BY id ASC LIMIT 20 - 必须确保排序字段有索引(最好是主键或联合索引最左前缀),且
ORDER BY方向与游标条件一致(>对应ASC,对应 <code>DESC) - 封装成模型方法更可控:
public function cursorPaginate($lastId = 0, $size = 20, $where = []) { $query = $this->where($where); if ($lastId > 0) $query = $query->where('id', '>', $lastId); return $query->order('id ASC')->limit($size)->select(); } - 前端只能用「下一页」按钮,不能渲染页码栏;
paginate()的所有参数(如simple、query)在此模式下全部失效
真正容易被忽略的是:复合索引顺序必须严格匹配 WHERE + ORDER BY 字段,哪怕只差一个 DESC,MySQL 8.0 以下版本就可能退化为全表扫描——别只建索引,要验证 EXPLAIN 输出的 key 和 rows。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











