根本原因是paginate()基于offset+limit,大偏移量时mysql必须扫描并丢弃大量行,即使有索引也无法跳过前n行,导致io和cpu双重浪费;count(*)在无覆盖索引时还会触发全表扫描。

ThinkPHP 的 paginate() 在百万级以上数据时会明显变慢,根本原因不是框架问题,而是它默认执行 COUNT(*) + LIMIT offset, size,MySQL 在大 offset 下必须扫描并丢弃大量行——IO 和 CPU 双重浪费。必须按场景选策略,不能只依赖一个方法。
为什么 paginate() 越往后越卡,且无法靠加索引彻底解决
即使给 status 和 id 加了联合索引,LIMIT 100000, 20 仍要定位到第 100001 行才开始取,MySQL 无法跳过前 10 万行。而 COUNT(*) 在无覆盖索引时会触发全表扫描。两者叠加,响应从毫秒级升至秒级是常态。
-
COUNT(*)慢:WHERE 条件字段没走索引,或 InnoDB 未启用innodb_stats_on_metadata=OFF导致统计不准、强制扫表 -
LIMIT offset, size慢:本质是“跳读”,和索引是否生效无关;只要offset > 10000,性能就不可控 - 加索引只能缓解 WHERE 过滤部分,对 COUNT 和 OFFSET 无实质帮助
用 simple 模式跳过 COUNT,但不解决深度翻页问题
设置 simple => true 可跳过 COUNT(*),改查 LIMIT size + 1 判断下一页是否存在,适合仅需「上一页/下一页」的列表页。
- 调用方式:
->paginate(['list_rows' => 20, 'simple' => true]) - 返回对象不带总页数,
render()只输出前后按钮,无页码栏 - 仍用
OFFSET,所以第 5000 页依然慢——它只是把“卡在 COUNT”变成“卡在 OFFSET” - 适合后台低频访问、用户不会手动输页码的场景
真正高效的方案:游标分页(cursor-based pagination)
放弃页码概念,用上一页最后一条记录的排序字段值(如 id 或 create_time)作为下一页起点,SQL 变成 WHERE id > ? ORDER BY id ASC LIMIT 20,全程走主键索引,毫秒级响应。
- 必须确保排序字段非 NULL、严格单调、有索引(
id最稳妥;create_time需搭配id防重复,如ORDER BY create_time DESC, id DESC) - ThinkPHP 6.0+ 原生支持:
->order('id DESC')->cursorPaginate(20),首次请求不传cursor,后续请求带?cursor=100500 - 模型中封装更安全:
public function cursorPaginate($cursor = null, $size = 20) { $q = $this->order('id DESC'); if ($cursor !== null) $q->where('id', 'limit($size)->select(); } - 前端必须用「下一页」按钮,不能跳页;数据删除后可能漏显示,需业务层权衡
什么时候还不得不硬扛 paginate()?怎么最小化伤害
只有真需要显示「共 12847 条,第 3 / 共 643 页」时才用传统分页,但可降级处理:
- 缓存
COUNT结果:用 Redis 存user:count:status_1,定时任务或写操作后更新,避免每次查 - 估算代替精确:对千万级表,用
SHOW TABLE STATUS LIKE 'user'的Rows字段(误差约 10%~20%,但快百倍) - 限制最大页码:
if ($page > 1000) $page = 1000;,防止用户输入恶意大页码拖垮数据库 - 搜索场景下,优先用 Elasticsearch 或 MySQL 全文索引替代模糊
LIKE+ 分页
游标分页的陷阱不在代码,而在字段语义——create_time 看似合理,但高并发下同一毫秒插入多条,WHERE create_time > '2026-05-14 20:00:00' 就会漏掉同秒内其他记录。最稳的永远是自增 id。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











