ThinkPHP paginate()在大数据量下卡死的根本原因是自动COUNT(*)查询,需禁用或改用游标分页;禁用方法为paginate(15, false),游标分页则用WHERE id > last_id替代OFFSET,配合索引可实现毫秒级响应。

ThinkPHP 的 paginate() 在大数据量下卡死,根本原因就是那个自动触发的 COUNT(*) 查询——它不是“慢一点”,而是随着数据增长呈线性甚至指数级恶化;必须绕开它,而不是调优它。
为什么 COUNT(*) 在分页里特别慢
MySQL 执行 COUNT(*) 时,并不会真的只数行数:当 WHERE 条件无法走覆盖索引,或涉及函数(如 DATE(created_at))、范围过大(如 status != 'deleted'),InnoDB 就得逐行扫描所有匹配记录。500 万行表 + 模糊状态条件,一次 COUNT 耗时 3 秒以上很常见,比取数据还慢。
ThinkPHP 默认 paginate(15) 会先执行这条 COUNT,再执行 LIMIT offset, 15;而大 offset 本身也要跳过大量行——双重浪费。
- EXPLAIN 显示
type = ALL或rows接近总行数,基本可判定是COUNT拖垮的 - 即使加了索引,
COUNT(*)也未必能用上;但COUNT(id)(id 为主键且非空)可以走主键索引,快得多 - 后台管理页若真需要「共 XXXX 页」,建议把
COUNT拆到定时任务里写进 Redis,接口只读缓存
禁用自动 COUNT 的两种实操方式
不改业务逻辑的前提下,最快见效就是关掉框架自动生成的 COUNT 查询。
立即学习“PHP免费学习笔记(深入)”;
- 用
->paginate(15, false):第二参数设为false,只查数据,不查总数,返回对象里$list->lastPage()会失效,但$list->hasMore()仍可用(靠是否查到满额数据判断下一页) - 用
->paginate(15, false, ['query' => request()->param()]):第三个参数传分页链接所需参数,避免翻页时丢失搜索条件 - 如果前端必须显示「第 X 页 / 共 Y 页」,别在每次请求里硬算,改用 Redis 缓存一个近似值,比如每 10 分钟用
SHOW TABLE STATUS更新一次,或监听新增/删除事件主动增减计数
游标分页:彻底摆脱 COUNT 和 OFFSET
适用于日志、订单流、消息列表等天然有序、用户只点「下一页」的场景。核心是用 WHERE id > last_id 替代 LIMIT offset, size,让查询永远只扫固定行数。
- 必须保证排序字段有索引,且方向一致:比如
ORDER BY id ASC就配WHERE id > last_id;若倒序则用WHERE id - 示例:
Db::name('order')->where('id', '>', $lastId)->order('id ASC')->limit(20)->select(),响应稳定在毫秒级 - 不要手动拼 SQL 字符串传
$lastId,必须用参数绑定,否则 SQL 注入风险极高 - 把游标逻辑封装进模型方法,比如
cursorPaginate($lastId, $size = 20),方便统一加权限校验、字段裁剪、缓存控制
索引和字段裁剪:游标分页生效的前提
游标分页快,前提是数据库能用上索引;否则 WHERE id > ? 还是全表扫。
- 确认主键
id是PRIMARY KEY且类型为整型(不要用 UUID 或字符串主键做游标) - 时间戳字段做游标时,必须建索引,且避免
WHERE DATE(created_at) = ?这类无法走索引的写法 - 关联查询慎用
with():100 条主记录触发 100 次子查询,内存暴涨,EXPLAIN 看不到 JOIN 类型;真要联查,用join()显式写出 - 用
field('id, user_id, title')明确指定字段,禁止SELECT *,尤其在分页查几千条时,字段越多,网络和内存开销越大
真正难的不是选哪种分页方式,而是判断业务到底需不需要「跳转任意页」——如果只是滚动加载,游标分页加合理索引,基本一劳永逸;如果非要支持「跳到第 892 页」,就得接受缓存总数、限制最大页码(如 if ($page > 2000) throw new HttpException(400))、或接受那几秒等待。没有银弹,只有权衡。



















