TP的paginate()在千万级表分页时会OOM,因其先COUNT(*)再LIMIT OFFSET,导致全表扫描、内存中缓存大量中间数据及模型实例;推荐改用cursorPaginate()或严格实施三道防线。

直接说结论:TP 的 paginate() 在超大表(比如千万级)分页时,不是慢,而是会 OOM——因为 COUNT(*) + 全量 SELECT 两轮操作把内存吃光,尤其当每页数据本身也带关联、聚合或模板渲染时。
为什么 paginate() 在大数据量下会爆内存
TP 的 paginate() 默认行为是「先 COUNT(*) 算总数,再 LIMIT OFFSET 查数据」。问题出在两个地方:
- COUNT(*) 本身在无索引或复杂 WHERE 条件下可能扫描全表,MySQL 缓存结果集进 PHP 内存(PDO 默认
PDO::ATTR_DEFAULT_FETCH_MODE = PDO::FETCH_ASSOC,每行都是数组) - OFFSET 越大,MySQL 越要跳过前面所有行——它仍需读取、丢弃,这部分中间数据虽不返回,但 TP 的查询构造器和模型实例化过程仍会临时持有引用,尤其开启
app_debug = true时 SQL 日志、参数绑定、对象元数据全堆在内存里 - 如果分页结果里还用了
with('profile')或append(['full_name']),每个模型又触发额外查询或计算,内存占用呈倍数增长
用 cursorPaginate() 替代 paginate() 是最有效解法
TP6.1+ 原生支持游标分页,它不依赖 COUNT 和 OFFSET,而是用上一页最后一条记录的主键值(如 id)作为下一页起点,彻底绕过深度偏移问题:
- 必须确保排序字段有索引(通常是
id或created_at),且不能用ORDER BY RAND() - 调用方式:
Db::name('orders')->where('status', 1)->order('id desc')->cursorPaginate(50) - 返回结果不含
total和last_page,前端需改用「上一页/下一页」按钮,禁用跳转页码输入框 - 注意:游标值(
cursor)需从上一页响应中提取并透传,不要自己拼接?cursor=12345——TP 会自动处理编码和校验
实在要用 paginate()?必须加三道防线
如果你的业务强依赖总条数显示(比如后台管理页),又无法立刻切游标,至少做到这三点:
立即学习“PHP免费学习笔记(深入)”;
- 关掉调试:
app_debug = false,否则 SQL 日志、模板编译缓存、错误堆栈全驻留内存 - 显式指定 COUNT 字段:
paginate(20, false, ['query' => request()->param(), 'count' => Db::name('logs')->where('level', 'error')->count('id')]),避免 TP 自动执行 COUNT(*) - 查完立刻释放模型集合:
$list = UserModel::where('status', 1)->paginate(100); unset($list->items);—— 因为$list->items是完整数据数组,而模板渲染后它就无用了;TP 不会自动 GC 这个引用
导出分页数据时最容易被忽略的陷阱
后台常有「导出当前页」或「导出全部」功能,这里极易 OOM:
- 「导出当前页」看似安全,但如果模板里用了
{:dump($list)}或未关闭的think\facade\Log::info($list),整个分页集合会被序列化进日志缓冲区 - 「导出全部」绝对不能用
paginate()->items()拼接——应改用chunk(500, function($rows) { /* 写入 CSV / Excel 流 */ }),配合fputcsv()或xmlwriter_open_uri('php://output')直写输出流 - Nginx 默认开启
fastcgi_buffering on,会把 chunk 输出攒满再发,导致浏览器卡住、内存持续堆积;必须在 location 块中加fastcgi_buffering off;
真正危险的从来不是 memory_limit 设得太小,而是你认为「只是分页而已」,却没意识到 TP 的模型层、查询构造器、模板引擎、日志系统在背后悄悄把每一条数据都复制了三四份——直到某天用户翻到第 10 万页,服务器才发出第一声闷响。



















