必须改用游标分页,因mysql的limit offset在数据量大时需扫描丢弃前n行,导致深分页性能急剧下降;where('id', '>', $lastid)->limit(20)可彻底规避offset缺陷。

ThinkPHP 默认的 paginate() 在数据量超 10 万时,第 500 页起就会明显变慢——这不是你代码写错了,是 MySQL 的 LIMIT offset, size 本身有性能缺陷。必须手动切到游标分页。
为什么不能继续用 paginate() 做深分页
ThinkPHP 的 paginate(20) 默认会干两件事:先执行 COUNT(*) 算总条数,再执行 LIMIT 9980, 20 跳过前 9980 行。MySQL 在大 offset 下必须扫描并丢弃所有前面的行,IO 和 CPU 双重浪费。
- 即使加了索引,
ORDER BY created_at DESC LIMIT 9980, 20仍要定位到第 9981 条,无法跳过 -
appends()、simple()这些参数对性能没帮助,它们只影响渲染逻辑,不改变 SQL 底层行为 - 缓存
COUNT结果只能缓解总数查询,解决不了LIMIT offset的扫描问题
where('id', '>', $lastId)->limit(20) 是最简可行方案
适用于「按 ID 或时间递增/递减」的列表(如订单、日志、用户注册流),核心是用上一页最后一条的主键值作为下一页起点,彻底绕开 offset。
- 确保
id字段有主键索引(默认就有),且单调递增;若用created_at,需保证其值唯一或加id作二级条件 - 排序方向必须和 where 条件一致:
ORDER BY id ASC对应where('id', '>', $lastId);DESC就用' - 首次请求不传
$lastId,直接查前 20 条:Db::name('order')->order('id DESC')->limit(20)->select() - 返回结果里取
end($list)['id']作为下一页的$lastId,前端只提供「下一页」按钮,不渲染页码栏
把游标逻辑封装成模型方法更安全
每次手写 where + limit + order 容易漏索引、错方向、混用 ASC/DESC,抽成模型方法能统一控制。
- 在模型里加一个
cursorPaginate()方法,接收$lastId、$limit = 20、$direction = 'DESC' - 内部强制校验
$lastId类型(整型)、非空,并自动拼接where和order - 可在此处统一加权限过滤(如只查当前用户数据)、字段裁剪(
field('id,title,created_at')) - 避免在控制器里拼 SQL 字符串,防止注入风险;也方便后续加 Redis 缓存或埋点统计
前端和业务约束比技术实现更容易翻车
游标分页不是“换个函数就行”,它改变了交互范式和数据语义。
- 不能跳转任意页(比如输入“第 123 页”),只能线性翻页;如果业务强依赖页码,得降级:前 100 页用游标,之后用带缓存的
paginate(20, false)+ 估算总数 - 数据实时增删会影响游标稳定性:新插入记录可能插在中间,导致漏数据或重复;建议配合
created_at+id复合游标(where(['created_at', ') - 别在同一个接口里混用
paginate()和游标逻辑,容易因query参数污染导致分页错乱
真正卡住人的从来不是怎么写那几行 SQL,而是想清楚:这个列表到底允不允许跳页?最后一条数据被删除后,下一页该从哪开始?这些业务边界不厘清,代码写得再规范也会出问题。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











