ThinkPHP 6.x 不支持原生 cursor 查询,因其 Db 和 Model 层未内置 cursor 方法;所谓“游标查询”实为基于主键或时间戳的分页逻辑,或极少见且难维护的 PDO 游标手动控制。

为什么 cursor 不是 ThinkPHP 原生支持的查询方式
ThinkPHP 6.x 的 Db 和 Model 层没有内置 cursor 方法,所谓“游标查询”是开发者对底层 PDO 游标(PDO::CURSOR_SCROLL)或基于自增 ID/时间戳分页逻辑的误称。真实场景中,TP 用户想解决的是“查百万级数据不 OOM”,但直接用 select() 全捞进内存,PHP 进程立刻被 kill。
真正能落地的方案只有两种:基于主键/时间字段的「游标分页」(推荐),或手动控制 PDO 游标(极少见、难维护)。
- TP 官方文档里搜不到
cursor相关方法,所有自称 “TP 支持 cursor 查询” 的文章,实际都是手写where('id', '>', $lastId) - PDO 游标(
PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL)在 TP 中无法透出控制权,且 MySQL 对其支持有限,fetch()仍会把整结果集载入内存 - 别被 ORM 的“优雅”迷惑——
chunk()是批量拉取,不是游标;它仍会重复执行 SQL,且无法跳过已处理数据
用 where + order + limit 模拟游标分页
这是最稳、最易调试的方式:用上一页最后一条记录的排序字段值作为下一页起点,避免 offset 越大越慢的问题,也彻底规避内存堆积。
假设表 user_log 有自增 id,按时间倒序查:
立即学习“PHP免费学习笔记(深入)”;
// 第一页:取最新 100 条
$firstPage = Db::name('user_log')
->order('id DESC')
->limit(100)
->select();
// 记住最后一条的 id(注意:必须是排序字段!)
$lastId = $firstPage->last()['id'] ?? 0;
// 下一页:只查 id 小于 $lastId 的最新 100 条
$nextPage = Db::name('user_log')
->where('id', '<', $lastId) // 关键!不是 offset,是条件过滤
->order('id DESC')
->limit(100)
->select();
- 排序字段必须有索引,否则
where + order会全表扫描 - 不能用
created_at当游标字段——如果存在相同时间戳,会漏数据或重复;优先选id或id + created_at复合判断 - TP 的
paginate()不适用此场景,它依赖count()和offset,海量数据下 count 本身就会卡死
chunk 看似省事,但容易踩内存和事务坑
chunk 是 TP 提供的“伪流式”遍历接口,底层仍是分批 select,每批结果仍完整加载进 PHP 内存。它适合“边查边处理”,但不适合“查完再导出”这类场景。
Db::name('big_table')->chunk(500, function ($rows) {
foreach ($rows as $row) {
// 处理单条,但 $rows 是 500 条全在内存里
process($row);
}
});
- 如果
process()里做了 DB 写操作,且没手动 commit,可能触发长事务,锁表风险陡增 -
chunk默认按id ASC分批,无法指定排序;若需倒序处理,得自己写循环 +where控制 - 批次大小设太大(如 5000),单次内存占用仍可能爆;设太小(如 10),IO 次数飙升,整体耗时反而更长
- TP 6.0.9+ 修复了
chunk在关联查询下的 ID 重复问题,但老版本慎用
真正要防 OOM,得从 PHP 配置和查询源头下手
光靠 PHP 层“技巧”救不了设计缺陷。如果单次查询返回几十万行,说明业务逻辑或前端交互就有问题——谁真需要一次性看 20 万条日志?
- MySQL 层加
SQL_NO_CACHE(TP 可通过query()手动拼)避免查询缓存拖慢首次响应,但这治标不治本 - PHP
memory_limit设再高也没用,因为数据一进来就占满;应该让每次请求只拿必要字段:field('id,name,created_at'),别用* - 考虑用
SELECT ... INTO OUTFILE让 MySQL 直接写磁盘,TP 只负责触发和校验文件生成结果,彻底绕过 PHP 内存 - 如果必须导出 Excel,别用 PHPExcel/PhpSpreadsheet 全读进内存——改用
spout流式写,配合游标分页逐批 fetch + write
游标不是魔法,它只是把“一次扛全部”换成“多次扛一点”,而每次扛多少、扛什么字段、扛完怎么用,才是决定内存是否爆炸的关键。别迷信某个函数名,盯紧你的 EXPLAIN 和 memory_get_usage() 输出。



















