结论:ThinkPHP模型分页在大数据量下必须改用基于主键的游标分页,因默认paginate()触发全表COUNT和大offset LIMIT,导致IO与CPU双重浪费;应使用where('id', '>', last_id)配合有序索引实现毫秒级响应。

直接说结论:ThinkPHP 的模型分页在大数据量下默认会全表 COUNT,必须手动改写为基于主键/索引字段的游标分页(cursor-based pagination),否则 paginate() 一查就卡死。
为什么 paginate() 在百万级数据下会变慢
ThinkPHP 默认调用 count() 获取总条数,再用 LIMIT offset, size 跳过前 N 条——MySQL 在大 offset 下要扫描并丢弃大量行,IO 和 CPU 双重浪费。不是框架慢,是 SQL 本身反模式。
- 现象:
SELECT COUNT(*) FROM user WHERE status=1耗时 2s+,SELECT * FROM user WHERE status=1 LIMIT 100000, 20延迟飙升 - 本质:COUNT 全表扫描 + LIMIT 跳读,二者叠加放大性能衰减
- 兼容性:MySQL 5.7+、8.0 均存在,与 ThinkPHP 版本无关(6.x / 5.1 都一样)
用 where() + order() + limit() 手写游标分页
放弃 paginate(),改用基于有序主键(如 id)的“上次最后 ID”作为下一页起点,避免 COUNT 和大 offset。
- 前提:分页字段必须有索引(强烈建议是主键或联合索引最左前缀)
- 场景:用户列表、订单流、日志查询等“按时间/ID 递增”的典型翻页
- 示例(查下一页,上一页 last_id = 100500):
$list = Db::name('user') ->where('id', '>', 100500) ->where('status', 1) ->order('id ASC') ->limit(20) ->select(); - 注意:
order方向必须和游标条件一致(>对应ASC,对应DESC)
模型中封装游标分页方法避免重复写
把游标逻辑抽成模型方法,比每次手写 SQL 更可控,也方便统一加缓存或权限校验。
- 在
UserModel中添加: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(); } - 调用:
$users = (new UserModel())->cursorPaginate(100500, 20, ['status' => 1]); - 坑点:不能混用
paginate()的参数(如simple、query),游标分页无总页数,前端需用「下一页」按钮而非页码栏 - 性能影响:COUNT 消失,LIMIT 固定为 20 行扫描,响应稳定在毫秒级
什么时候还必须用 paginate()?怎么妥协
只有真需要显示「共 XX 条,第 X 页/共 Y 页」时才硬扛 COUNT,但可以做降级处理。
- 场景:后台管理页、数据导出预览、低频访问报表
- 折中方案:
count()加缓存(如 Redis 存 5 分钟),或用 MySQL 近似值:SELECT TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_NAME='user'(仅适用无频繁删改) - 严禁:在高并发接口中对热表执行
paginate(15),尤其带复杂JOIN或LIKE条件时,可能直接拖垮数据库 - 替代提示:前端显示「已加载 20 条,点击加载更多」比「第 1/5000 页」更真实、更可用
游标分页不是银弹——它要求排序字段严格唯一、不可空、有索引,且无法跳转任意页。但对绝大多数列表场景,这才是大数据量下真正能跑起来的分页。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!










