根本原因是MySQL的OFFSET需扫描并丢弃前面所有行,导致I/O和CPU浪费;应改用基于主键或时间字段的游标分页,以“上一页最后ID”为起点,配合WHERE+ORDER BY+LIMIT实现高效分页。

ThinkPHP 模型分页用 offset 越往后越慢,根本原因是什么?
不是 ThinkPHP 的锅,是 MySQL 的 OFFSET 本身要扫描前面所有行。100 万条数据跳到第 10000 页(offset 99990),MySQL 得先读完近百万行再扔掉前 99990 条——CPU 和 I/O 都在白忙。
ThinkPHP 默认的 paginate() 在底层调用 limit + offset,只要传了 page 参数,就绕不开这个问题。
用主键/时间字段游标分页替代 offset,怎么写?
核心思路:不依赖页码,改用“上一页最后一条记录的 id 或 create_time”作为查询起点,每次只查下一批。
- 必须有唯一、有序、非空的字段(推荐自增
id或带索引的created_at) - 查询方向要固定(比如始终按
id DESC,翻页时用WHERE id ) - ThinkPHP 6.x 可直接拼
where+limit,别碰paginate()
示例(查比上一页最后 id=12345 更小的 20 条):
立即学习“PHP免费学习笔记(深入)”;
$list = Db::name('article')
->where('id', '<', 12345)
->order('id DESC')
->limit(20)
->select();
模型里怎么封装游标分页逻辑,避免重复写?
直接在模型里加一个方法,把游标值、方向、数量收口,比在控制器里拼 SQL 更安全可控。
- 不要在模型里硬编码表名或字段,用
$this->getPk()和$this->getTable() - 游标值必须校验类型(比如
id是整数,created_at是时间戳或日期字符串) - 返回结果里要附带下一页游标(即当前批次最小的
id),前端下次请求带上它
简单示意(以 id 降序为例):
public function cursorPaginate($cursor = null, $limit = 20, $sortField = 'id') {
$query = $this->order($sortField . ' DESC');
if ($cursor !== null) {
$query->where($sortField, '<', $cursor);
}
$data = $query->limit($limit)->select();
$nextCursor = !empty($data) ? end($data)->$sortField : null;
return ['data' => $data, 'next_cursor' => $nextCursor];
}
哪些场景不能用游标分页,必须回退到传统分页?
游标分页不是银弹。遇到这些情况,强行用反而出问题:
- 需要跳转任意页码(比如用户手动输“第 382 页”)——游标只能前后滚动,不支持随机跳
- 数据高频更新(尤其删除或
id重排),导致游标“漏数据”或“重复” - 排序字段有大量重复值(如按
status分页),WHERE status 会多查或少查 - MySQL 版本低于 5.7(部分优化器对游标写法不友好),建议先 explain 看执行计划
真要支持跳页又数据量大,得结合“分段缓存 + ID 映射”或者改用 Elasticsearch,但那就不是 ThinkPHP 模型层能解决的事了。
游标分页的关键不是写法多巧妙,而是你敢不敢在业务里放弃“页码”这个概念——很多所谓“大数据分页”问题,本质是产品设计没想清楚要不要让用户翻到第 1000 页。



















