LIMIT 100000, 20 慢是因为MySQL需扫描并跳过前100000行,IO和CPU双重浪费;优化方案是用子查询先取ID再JOIN查详情,并建合适覆盖索引,ThinkPHP中可封装游标分页替代offset分页。

为什么 LIMIT 100000, 20 会慢到超时
MySQL 执行 LIMIT offset, size 时,并不会“跳”到第 offset 行,而是从索引头开始逐行扫描、计数、跳过前 offset 条,再取 size 条。哪怕你只查 10 条,LIMIT 5000000, 10 也要扫描并丢弃 500 万行——IO 和 CPU 双重浪费。在 ThinkPHP 5.1 中,paginate() 默认生成的就是这类 SQL,数据量一过百万,首页可能还快,翻到第 500 页就卡死。
用子查询先捞 ID,再 JOIN 查详情
核心思路是把“扫描全表找数据”拆成两步:第一步用主键(或有序索引字段)快速定位目标 ID,第二步只查这几十个 ID 对应的完整记录。MySQL 能直接走主键索引,跳过中间所有无效行。
- 确保分页字段(如
id)是主键或有单列/复合索引,且ORDER BY方向与索引顺序一致(如ORDER BY id DESC对应INDEX idx_id (id DESC)) - 手写 SQL 示例(以查第 500001–500010 条为例):
SELECT t.* FROM article t INNER JOIN ( SELECT id FROM article WHERE status = 1 ORDER BY id DESC LIMIT 500000, 10 ) tmp ON t.id = tmp.id;
- 在 ThinkPHP 5.1 中不建议直接拼 SQL,而应改用
Db::query()或封装模型方法调用该逻辑,避免 ORM 自动注入干扰
覆盖索引让子查询更快
子查询 SELECT id FROM ... 如果能完全走索引不回表,性能会再上一个台阶。主键本身就是聚簇索引,天然满足;但若你按 create_time 分页,就得建覆盖索引,比如:ALTER TABLE article ADD INDEX idx_status_ctime_id (status, create_time DESC, id);
- 这个索引能让
WHERE status = ? ORDER BY create_time DESC直接定位,且id字段已包含在内,子查询无需访问主表数据页 - 用
EXPLAIN验证子查询是否走了该索引:检查type是range或ref,key显示索引名,Extra不含Using filesort或Using temporary - 注意字段顺序:等值条件(
status)必须在前,范围/排序字段(create_time)在后,被 SELECT 的id放最后——这是联合索引生效的关键
ThinkPHP 5.1 中怎么落地而不破环原有结构
别在控制器里硬塞原生 SQL,也别强行给 paginate() 打补丁。最稳的方式是封装一个轻量游标查询方法,替代默认分页入口。
立即学习“PHP免费学习笔记(深入)”;
- 在模型中新增方法,例如:
public function cursorPage($lastId = 0, $size = 15, $where = []) { $query = $this->where($where); if ($lastId > 0) { $query = $query->where('id', '>', $lastId); } return $query->order('id ASC')->limit($size)->select(); } - 前端传参只需带
last_id=100500,后端直接调用,无COUNT、无OFFSET、响应稳定在毫秒级 - 关键约束不能漏:分页字段必须
NOT NULL;ORDER BY和WHERE条件必须能命中同一索引;前端必须放弃「跳转任意页码」,改用「下一页」按钮驱动
真正难的不是写对那几行 SQL,而是说服产品接受「不支持跳页」——因为深分页本身是个伪需求,用户极少真去翻到第 1000 页,强制支持只会拖垮整个数据库。



















