ThinkPHP百万级数据分页慢的核心是默认paginate()执行COUNT(*)+LIMIT offset,size,导致MySQL全表扫描并丢弃前N行;应禁用总数统计启用simple分页、建立覆盖WHERE和ORDER BY的联合索引、或改用基于主键的游标分页。

百万级数据下,ThinkPHP 默认 paginate() 会迅速变慢甚至崩溃,核心问题不在框架本身,而在它生成的 SQL:先 COUNT(*) 全表统计,再用 LIMIT offset, size 跳读——MySQL 必须扫描并丢弃前 N 行。第 500 页(offset=9990)尚可忍受,到第 10000 页(offset=199980)时,响应常超 5 秒、CPU 拉满、PHP 内存溢出。优化不是调参数,而是换逻辑。
关掉 totalCount,启用 simple 分页
若前端不强依赖“共 XX 条”或跳转任意页码(如仅需上/下一页),这是最快见效的一步:
- 控制器中调用:
$list = Db::name('order')->where('user_id', $uid)->paginate(20, false); - 模板中渲染:
{$list->simple()->appends(request()->param())->render()} -
appends()必须显式加,否则翻页会丢失搜索参数(如?keyword=test&page=2变成只有page=3) - 效果:跳过
COUNT(*),只查数据 + 判断是否有下一页,响应从秒级降至毫秒级
建对联合索引,让 LIMIT 起点能直接定位
即使关了 COUNT,如果 MySQL 无法用索引快速定位 LIMIT 的起始位置,仍要全表扫描前 N 行。关键不是“有没有索引”,而是“索引是否覆盖 WHERE + ORDER BY”:
- 例如查询:
SELECT * FROM order WHERE user_id = 123 ORDER BY create_time DESC LIMIT 20 - 执行
EXPLAIN确认type不是ALL,key显示实际索引名 - 建联合索引:
ALTER TABLE `order` ADD INDEX idx_uid_ctime (user_id, create_time DESC);(MySQL 8.0+ 支持降序索引) - 字段必须
NOT NULL,否则 MySQL 可能放弃使用该索引
彻底抛弃 offset,改用游标分页
当数据稳定在百万级以上,且用户行为是连续浏览(如后台订单流、日志列表),游标分页是唯一可靠方案:
立即学习“PHP免费学习笔记(深入)”;
- 原理:用上一页最后一条记录的主键(如
id)作为下一页起点,避免跳读 - 示例(升序):
Db::name('user')->where('id', '>', $lastId)->where('status', 1)->order('id ASC')->limit(20)->select() - 方向必须严格一致:
>对应ASC,<对应DESC,写反即退化为全表扫描 - 建议封装进模型方法,统一加权限校验和缓存逻辑,避免重复手写
导出类场景:用 chunk() 替代 select + foreach
导出 Excel 或生成报表时,常见错误是先 select() 全量数据再拼装——10 万行以上必炸内存:
- 改用
chunk(500, function ($rows) { /* 处理每批500条 */ });,内存恒定可控 - 关联字典数据提前缓存到 Redis,避免 chunk 循环内反复查库
- 导出接口只返回 task_id,后端用队列分批拉取、写入临时文件,最后合并
- 导出过程完全避开
COUNT(*),用户只需知道“正在生成”,无需精确总数



















