limit 100000,20 越来越慢是因为mysql需真实扫描前100020行再丢弃前100000行,导致io和cpu开销陡增;游标分页通过where条件替代offset可彻底解决该问题。

为什么 limit 100000,20 会越来越慢
MySQL 在深分页时不是跳过前 N 行再取数据,而是真实扫描前 N+20 行,然后丢弃前 N 行。当 offset 达到十万级,即使有索引,B+ 树也要反复回表或遍历索引页,IO 和 CPU 开销陡增。ThinkPHP 默认的 paginate() 在没干预时就生成这种 SQL。
- 典型现象:
SELECT * FROM user ORDER BY id DESC LIMIT 200000,20执行时间从 50ms 涨到 2s+ - 问题不在 ThinkPHP 本身,而在它默认依赖 MySQL 的
LIMIT offset,row模式 - 只要排序字段有重复值(比如多个用户同一天注册),
ORDER BY created_at DESC+LIMIT还可能漏数据或翻页错乱
用游标分页替代 offset:ThinkPHP 8.0+ 的 cursorPaginate()
cursorPaginate() 不依赖行号偏移,而是基于上一页最后一条记录的排序字段值做条件查询,例如 WHERE id 。这能跳过全量扫描,基本恒定响应时间。
- 必须确保排序字段(如
id)唯一且非空,否则游标值不唯一会导致分页断裂 - 调用时要显式指定排序字段:
UserModel::cursorPaginate(20, ['*'], 'page', null, ['id' => 'desc']) - 前端传参不再是
?page=500,而是?cursor=123456,后端需校验该 cursor 值是否真实存在(防止恶意构造) - 不支持跳转任意页,只适合“下一页/上一页”场景;如果业务强依赖跳页(如搜索结果页码输入),得另配缓存或异步预计算
手动改写 SQL 绕过 paginate():适用于老版本或复杂 JOIN 场景
ThinkPHP 6/7 没内置 cursorPaginate(),或者你的分页逻辑涉及多表关联、子查询,这时得自己拼 WHERE 条件 + 控制 LIMIT。
- 先查出上一页末尾的排序值:
$lastId = $list->last()->id ?? 0 - 下一页查询改用:
UserModel::where('id', 'order('id desc')->limit(20)->select() - 注意:不能混用
ORDER BY ... DESC和WHERE id > ?,方向必须一致;升序就用>+ASC,降序就用+ <code>DESC - JOIN 场景下,游标字段必须来自主表,且关联字段要有索引,否则
WHERE条件无法高效下推
别忽略索引和字段选择:游标分页也救不了烂 SQL
游标分页只是换了一种查询方式,不代表可以不关心执行计划。如果排序字段没索引,或者 SELECT * 拉回大量无用字段,性能照样崩。
- 强制给游标字段加联合索引,例如:
ALTER TABLE user ADD INDEX idx_status_id (status, id)(如果你按 status 分组再按 id 排序) - 避免在游标分页中使用
count()—— 游标模式本身不提供总条数,硬查会触发全表扫描;前端显示“共 XXX 条”应走缓存或近似值估算 - ThinkPHP 的
with()关联预加载在游标分页里要小心:它会在主查询后额外发 N 条 SQL,N 是当前页条数,容易放大数据库压力;优先考虑 JOIN 或延迟加载
真正卡住的从来不是框架封装,而是没想清楚“我到底要分页什么”——是用户需要精确跳到第 1000 页?还是只需要稳定快速地往下翻?后者用游标,前者得接受代价或换方案。字段索引、排序唯一性、关联方式,这三个点漏掉任何一个,优化就白做。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











