db::cursor()是唯一真正流式的解法,返回pdostatement逐行迭代,内存恒定几百kb;需关闭调试、禁用日志、手动控制输出流导出csv;游标分页用where('id','>',$lastid)避免offset性能陷阱。

直接用 select() 或 paginate() 处理几十万以上数据,PHP 进程几乎必然内存溢出——这不是调大 memory_limit 能解决的,得换掉“全量加载 + 数组拼装”这套逻辑。
为什么 paginate() 在大数据下必崩
ThinkPHP 的 paginate() 底层依赖 LIMIT offset, size,MySQL 执行时必须扫描并跳过前 offset 行。查第 5000 页(offset=499999)时,它真的一行行扫了 50 万行才取数据;同时默认还会执行 COUNT(*) 统计总数,千万级表上这句本身就会卡住、锁表甚至超时。
- 前端传个
page=10000,后端不校验就直接喂给paginate(),等于主动触发雪崩 -
memory_limit改到 2G 也没用:MySQL 返回的临时结果集 + TP 框架构建的模型对象数组,会把内存撑爆 - 调试模式(
app_debug=true)开着时,SQL 日志、错误堆栈、模板编译缓存会额外吃掉 30%+ 峰值内存
Db::cursor() 是唯一真正流式的解法
Db::cursor()(ThinkPHP 8.0+ / ThinkORM 4)返回的是原生 PDOStatement,配合 fetch() 是逐行迭代,内存恒定在几百 KB 级别,和总数据量无关。
- 写法示例:
$cursor = Db::name('order')->cursor(); while ($row = $cursor->fetch()) { /* 单行处理 */ } - 不能在
cursor()查询里用with()或关联预加载,会强制转成数组,彻底失去流式意义 - 如需关联数据,改用 ID 批量
whereIn('user_id', $userIds)查字典表,再 PHP 层合并 - 务必关闭调试、禁用日志写文件、清理 Schema 缓存,否则游标优势会被框架层开销抵消
导出场景必须绕过 Response 缓冲链
用 Response::create()->send() 导出百万行 CSV,框架会先把全部内容塞进响应缓冲区,内存照样炸。必须手动控制输出流。
- 先调
ini_set('output_buffering', 'off')和ini_set('zlib.output_compression', 'Off') - 用
fopen('php://output', 'wb')获取输出句柄,配合fputcsv()边查边写 - 每写一批(比如 100 行)就
fflush($fp),确保立即发往浏览器 - 响应头(
Content-Type、Content-Disposition)必须在任何输出前用header()发出,否则报headers already sent - 最后用
exit终止框架后续流程,避免中间件或视图层二次缓冲
游标分页比 chunk() 更稳,但有硬约束
所谓“游标分页”,本质是 where('id', '>', $lastId)->order('id ASC')->limit(20),它不依赖 offset,也不统计总数,性能与页码无关。
- 排序字段(如
id)必须有索引、唯一、非空;用created_at当游标字段容易因时间重复漏/重数据 - 不支持跳转任意页码,只适合“下一页”连续翻阅;要支持“上一页”,得用
where('id', ' 反向查再反转数组 -
chunk()不是游标:它仍是分批select(),每批结果完整加载进内存,适合后台任务,不适合导出 -
chunkById()看似智能,实则内部仍可能触发COUNT(*)和主键范围计算,大数据下依然慢
最容易被忽略的一点:游标分页的健壮性完全依赖排序字段的索引质量——没加索引的 where + order 查询,会退化成全表扫描,比 offset 还致命。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











