#PHPGenerators生成器实战:用yield处理百万级数据流(内存友好迭代)去年我接手一个老系统的"订单导出"功能,运营同学要导出过去一整年的订单做对账。我那时候图省事,直接写了一句`SELECT*FROMor…
PHP Generators 生成器实战:用 yield 处理百万级数据流(内存友好迭代)
PHP Generators 生成器实战:用 yield 处理百万级数据流(内存友好迭代)
去年我接手一个老系统的"订单导出"功能,运营同学要导出过去一整年的订单做对账。我那时候图省事,直接写了一句SELECT * FROM orders,然后fetchAll()把所有行塞进一个数组,再一行行fputcsv 写文件。本地拿 200 条数据测,丝般顺滑,我甚至还自我感动了一下,觉得自己这代码写得又短又清楚。
上线第一天,运营点了"导出全年",8 秒钟后 PHP 进程直接被 OOM 杀了,监控告警在群里炸了一屏。我盯着那条 PHP Fatal error: Allowed memory size of 134217728 bytes exhausted 看了半天——128M 内存,根本装不下 80 多万条订单。那一夜我几乎把咖啡喝成了主食,才真正去翻 PHP 手册里那个我一直忽略的词:Generator(生成器),和它那个看起来很不起眼的yield关键字。其实早有人在我们技术群里安利过,我当时还回了句"小数据用不上,大数据我分个页不就完了",现在想想脸疼。分页确实能解一时之急,可一旦遇到"不分页就导出全量""日志实时流式解析""递归遍历整棵目录树"这类场景,分页要么做不了,要么深分页越翻越慢,这时候生成器才是正解。
说实话,yield 这玩意儿 2013 年 PHP 5.5 就进了语言,比我不少同事入行都早。但它一直不温不火,我猜大部分人和我一样——"能跑就不改"。直到那次 OOM 把我按在地上摩擦,我才真正理解它有多香:它能在处理百万级数据的时候,把内存占用从"按数据量线性爆炸"压到"几乎恒定"。
那晚之后我养成了个习惯:凡是看到array_map、大for循环里往数组塞东西、或者SELECT后一把fetchAll,我都会下意识多想一秒——这数据会不会很大?大了之后内存扛不扛得住?就是这一秒的犹豫,后来替我省了不止一次凌晨三点的告警电话。
这篇文章不是来背概念的,而是把我踩过的坑、想通的原理、以及后来真正用在生产环境里的写法,原原本本讲给你。你会看到为什么range()会吃内存、yield 到底改了什么、怎么用生成器把"读大文件 / 导大表"这件事做得既稳又省。
一、先别背定义:生成器到底是个啥
我一开始也被"生成器实现了 Iterator 接口""惰性求值"这些词劝退。后来我换了个最朴素的理解:
普通函数用return一口气把所有结果算完、打包成一个数组还给你;生成器用yield一次只给一个值,你要下一个,它才现算下一个。函数执行到yield就"暂停",把当前算出的值交出去,等外面再要,它才从暂停的地方继续往下跑。
这就是为什么它能省内存——它压根没把"全部结果"建出来过。下面这段代码是我当年改的第一版,把 PHP 自带的 range()重写成生成器版本,你一眼就能看出区别:
php<?php// 普通写法:一次性把 100 万个整数塞进数组,内存直接起飞function makeRangeArray(int $end): array{ $data = []; for ($i = 0; $i < $end; $i++) { $data[] = $i; // 每轮都往数组里追加,内存随 $end 线性增长 } return $data; // 返回的是完整数组}// 生成器写法:用 yield 逐个"吐"值,函数返回的是一个 Generator 对象function makeRangeGenerator(int $end): Generator{ for ($i = 0; $i < $end; $i++) { yield $i; // 每次只产出一个值,函数在此"暂停",内存几乎恒定 }}// 调用方式一模一样,但底层天差地别foreach (makeRangeGenerator(1_000_000) as $n) { // 每次循环 PHP 才临时算一个值给你,用完即弃}

注意一个细节:生成器函数一旦出现yield,它的返回值就不再是普通数组,而是一个Generator对象。这个对象你不能new,只能调用函数拿到,而且它实现了Iterator接口——所以你才能直接foreach它。如果你var_dump它,看到的只是一堆内部结构,而不是数据本身,因为数据根本还没被算出来。
可能有兄弟会问:我自己写个类实现Iterator接口,不也能惰性迭代吗?确实能,但你要手写rewind() / valid() / current() / key() / next()五个方法,样板代码一大堆,逻辑稍微复杂点就绕晕了。生成器最香的地方,就是用yield 这一个关键字,把这套 Iterator 样板全免了——你写的是普通函数的逻辑,PHP 在底层自动帮你生成那个实现了 Iterator 的 Generator 对象。
说白了,生成器就是把"迭代逻辑"和"数据生产"合二为一。你不用先生产完再迭代,而是边生产边迭代,生产多少完全由消费方(也就是foreach)的节奏决定。消费方要得快,它就产得快;消费方慢悠悠,它就原地待命。这种"拉动式"(pull-based)的模型,正是它内存恒定的根本。我刚接触时总忍不住把它和 Python 的 generator、JS 的 async iterator 类比,后来发现它们的内核都是同一个思想:别提前把东西全准备好,要多少给多少。
想彻底搞清楚生成器协议的同学,建议直接啃官方文档:PHP 手册 · 生成器总览,比我这白话版权威多了。
二、为什么它能省内存:O(1) 的秘密
我当年最不服气的一点是:不就是换个写法吗,凭啥内存差这么多?后来我拿memory_get_usage()实打实打了一行日志,才闭嘴。
原理其实不复杂。普通函数里$data[] = $i 是真正在堆上建了一个百万长度的数组,PHP 得给它分配几十 MB 连续内存;而生成器根本不建这个数组,它只在内部保存"当前循环变量 $i 和函数执行状态",每要一个值就临时算一个、交一个、扔一个。数学上,普通写法是 O(n) 内存,生成器是 O(1)。
打个比方:普通函数像一次性把一整桶水倒进浴缸再慢慢用,桶越大浴缸越满;生成器像接了根水管,你要多少它放多少,浴缸里永远只留你手里那一杯。数据量翻十倍百倍,水管粗细不变,内存自然稳。
光说没意思,上代码对比一下(这里用的是memory_get_usage(true),按内存块取整,数值更直观):
php<?phpfunction memoryUsage(string $label): void{ // memory_get_usage() 返回当前脚本已分配的内存字节数 $mb = memory_get_usage(true) / 1024 / 1024; echo sprintf("%s: %.2f MB\n", $label, $mb);}memoryUsage('开始'); // 例如:开始: 0.50 MB// 普通数组:100 万整数全部驻留内存$arr = makeRangeArray(1_000_000);memoryUsage('普通数组 100 万'); // 例如:普通数组 100 万: 32.00 MBunset($arr); // 释放数组,避免干扰后续测量// 生成器:无论生成多少,内存几乎不涨foreach (makeRangeGenerator(1_000_000) as $n) { // 边消费边丢弃,内存始终只有一个整数在游走}memoryUsage('生成器 100 万'); // 例如:生成器 100 万: 0.50 MB
跑出来大概是普通数组 30 多 MB、生成器还停在 0.5 MB 左右。这就是"内存友好迭代"最直白的体现:数据规模涨 100 倍,生成器的内存开销几乎不动。如果你要印证自己项目的性能,也可以顺手用前端那套思路——Performance API 实战:用 Navigation/Resource Timing 做前端性能监控(Web Vitals)讲的是怎么量化前端指标,咱们后端这边就是memory_get_peak_usage()一把梭,先量再优化,别凭感觉。
你可能会好奇:函数执行到一半怎么还能"暂停"?其实 PHP 引擎在编译生成器函数时,会给每条 yield生成一个独立的"断点"。每次foreach向生成器要下一个值,引擎就从上次停下的yield恢复执行,把局部变量、循环计数器的状态原样搬回来,跑到下一个yield再交还控制权。整个过程对写代码的人来说是完全透明的——你写的还是普通的for/while,但底层已经变成了一个状态机。
三、实战一:超大文件逐行读,内存稳如老狗
我后来复盘那次 OOM,根因其实有两个,第一个就是"读文件用错了姿势"。很多兄弟处理日志、CSV、Excel 导出时,习惯 file()或file_get_contents() 一把梭把整个文件读进数组/字符串。文件小没问题,一旦遇上 20GB 的 access.log,恭喜,内存直接爆。
生成器在这里是天选之子。核心就一句:用fgets()每次只读一行,配合yield把"单行"吐给调用方。内存里同一时刻只有一行字符串,怎么读都不会超。我后来还拿它做过 CSV 批量导入,几十 G 的客户数据,内存稳得跟没事人一样。
php<?php/** * 逐行读取超大日志文件(如 20GB 的 access.log) * 关键:用 fgets() 每次只读一行,配合 yield 把"单行"吐给调用方 * 内存里同一时刻只有一行字符串,不会把整个文件拖进内存 */function readLines(string $filePath): Generator{ $handle = fopen($filePath, 'rb'); if ($handle === false) { throw new RuntimeException("无法打开文件:{$filePath}"); } try { while (($line = fgets($handle)) !== false) { yield rtrim($line,"\n"); // 每读到一行就产出,函数暂停等待下次要值 } } finally { fclose($handle); // 用 finally 确保句柄一定被关掉 }}// 调用:像遍历普通数组一样遍历它,内存稳如老狗foreach (readLines('/var/log/nginx/access.log') as $line) { if (str_contains($line, ' 500 ')) { // 实时统计 5xx,边读边处理,不用一次读全 }}
这里有个我踩过的小坑:文件句柄一定要关。早期我图快没写fclose(),高并发下 fd 被吃得死死的,后来养成习惯用 try/finally 包住,省心。另外如果追求更"面向对象"的写法,PHP 标准库里的 SplFileObject也能逐行迭代,本质上和这个生成器是一回事。
顺带一提,这种"逐行产出"的写法还有个隐藏好处:它能把一段超大任务切成可中断的小步。比如你要处理一个 10GB 的文件,中途进程要被重启,用生成器你可以很自然地把"已处理到第几行"这个状态落盘,重启后从断点续跑,而不是每次都从头再来。这是把整个文件读进数组的写法永远给不了你的弹性。
这个"边读边处理"的范式,跟现代前端用流处理大响应的思路一模一样——Fetch + AbortController + Streams 实战:现代请求的中断与流式读取(进度/取消)里讲的Streams 也是"按需拉取、用一点处理一点",只不过一个在 PHP 服务端、一个在浏览器端。理解了生成器,你再看 JS 的 ReadableStream会瞬间通透。
四、实战二:百万级数据库导出,别再用 fetchAll
OOM 的第二个根因,是数据库查询。我那次就是把整张订单表 fetchAll()拉进内存。正确的姿势是游标式单行读取 + yield:每fetch一行就yield 出去,调用方处理完这一行,下一行才被取出来,上一行立刻能被 GC 回收。内存里永远只有"当前这一行"。
php<?php/** * 把大表查询结果流式产出,避免 fetchAll() 一次性把 80 万行装进内存 * 关键点:关闭 PDO 缓冲查询 + 单行 fetch + yield */function streamQuery(PDO $pdo, string $sql, array $params = []): Generator{ // 关闭客户端缓冲,让结果集随取随用,而不是先全部缓存到 PHP 内存 $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false); $stmt = $pdo->prepare($sql); $stmt->execute($params); // fetch(PDO::FETCH_ASSOC) 每次只拿一行,yield 出去后这行即可被回收 while (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) { yield $row; }}// 实战:导出百万订单,内存始终只有"当前这一行"$out = fopen('orders.csv', 'w');foreach (streamQuery($pdo, 'SELECT * FROM orders WHERE created_at >= ?', [$start]) as $order) { fputcsv($out, $order); // 处理完一行释放一行}fclose($out);
划重点:PDO::MYSQL_ATTR_USE_BUFFERED_QUERY设为false 这一步很关键。MySQL 默认会先把结果集缓存到客户端(PHP 内存)里,你不关它,生成器再努力也救不了内存。关掉之后才是真正的"流式"。MySQLi 那边对应的是 MYSQLI_USE_RESULT,思路一致。很多新手会问:既然缓冲查询这么坑,PHP 为啥默认开着?因为它能让 rowCount()、fetchAll()立刻可用,对中小结果集更方便。代价就是大结果集会把内存撑爆。所以结论很明确:只有当你确定要流式处理大结果时,才去关掉它。
不过要泼盆冷水:生成器解决的是"内存",不是"速度"和"并发"。如果你真正卡在"计算太重、主线程被拖死",那得把活儿丢给后台线程——这事儿生成器干不了,得看Web Workers 实战:把计算密集型任务丢给后台线程(SharedWorker 与多线程通信)。生成器和 Worker 是两条不同的救赎路线:一个省内存,一个省 CPU 阻塞,别混为一谈。
顺便分享个真实收益:那次 OOM 之后,我把导出功能改成 streamQuery+fputcsv 的生成器写法,运营再点"导出全年",内存曲线平得像条直线,8 秒变 3 秒,关键是再也没把进程搞垮过。后来这个套路被我们组复制到了"全量订单同步给风控""历史日志归档"好几个任务上,统一的好处是:再也不用为每个大任务单独去调 memory_limit了。
五、yield from:把生成器拆开拼起来
业务再复杂一点,就会遇到嵌套结构。比如一个用户有多笔订单,每笔订单又有多个商品,我想把它们"拍平"成一个流来逐个处理。如果手写一堆foreach 嵌套,代码又丑又难维护。PHP 7 给了个语法糖:yield from,意思是"把另一个可遍历对象/生成器委托出去,键值原样透传"。
php<?php/** * yield from:把另一个可遍历/生成器"委托"出去,键值原样透传 * 典型场景:把嵌套结构(订单->商品)拍平成一个流 */function getUserOrders(int $userId): Generator{ $orders = loadOrdersFromDb($userId); // 假设返回二维数组 [订单 => [商品...]] foreach ($orders as $order) { yield $order; // 先产出订单本身 yield from $order['items']; // 再把订单里的商品逐个委托产出 }}// 调用方拿到的是一个"扁平"的流:订单、商品、订单、商品……foreach (getUserOrders(42) as $item) { // $item 可能是订单数组,也可能是商品数组,按类型分别处理}
yield from妙就妙在它能把多个生成器"串"成一条流水线。配合它,你还能把"取数据→过滤→转换→输出"拆成好几个生成器函数,像搭积木一样拼起来,全程不落中间数组。比如先yield from 数据源生成器,再在外层套一个过滤生成器、一个转换生成器,每个都是 O(1) 内存。这跟那种"每个步骤都先 array_filter出一个新数组"的写法比,内存友好到不知道哪里去了。我后来用这套思路重构过一个"多店铺订单聚合"的脚本:原来三层foreach 嵌套导出,内存直接 200MB 起跳;改成 yield from 拍平后,不管聚合多少店铺,内存都稳在 2MB 上下,跑得比改之前还快——因为少了大量中间数组的分配和销毁开销。
六、send():生成器还能"反向收值"
很多人以为生成器只能"往外吐",其实它还能"往里收"。Generator::send()可以让调用方反向往生成器里塞一个值——这个值会作为yield 表达式的返回值,赋给生成器内部的变量。这俩一进一出,生成器就有了双向通信能力,也是 PHP 协程的雏形。
php<?php/** * send():生成器不仅能"产出"值,还能"接收"外部传进来的值 * 这让生成器具备了双向通信能力(协程的雏形) */function numberPrinter(): Generator{ echo"打印机已启动\n"; while (true) { // $text 接收调用方通过 send() 传进来的值;首次进入时 yield 右侧为 null $text = yield; echo"收到:{$text}\n"; }}$printer = numberPrinter();$printer->send('Hello'); // 输出:打印机已启动 / 收到:Hello$printer->send('World'); // 输出:收到:World
第一次send('Hello')时,生成器从开头跑到第一个yield并暂停,同时把'Hello'注入成$text;下一次send('World') 从暂停点继续。这套"你给我一个、我还你一个"的机制,正是后来 Swoole 协程、以及很多任务调度框架的底子。顺便提一句,throw()还能往生成器里抛异常,Generator 的 API 比表面看着要灵活。
说个题外话,跨语言看"怎么优雅地给代码加元数据/重构",PHP 8 的 Attributes 和 JS 的 Decorators 思路很像——JavaScript Decorators 装饰器实战:用 @ 重构类与方法(TC39 Stage 3)里用@ 重构类方法,咱们 PHP 这边用 #[Attribute]给生成器或处理器打标签做路由,异曲同工。也正因为有send()和throw() 这对双向通道,生成器才被视为 PHP 协程的"最小骨架"——Swoole 早期版本里,协程的调度本质上就是建立在 yield让出执行权这套机制之上的。所以今天你花十分钟搞懂生成器,等于提前给以后理解协程铺好了路。
七、踩坑合集(都是血泪)
生成器好用,但坑也不少,我挑几个最容易栽的:
1. 生成器是"一次性"的,不能倒带。你foreach跑完一遍,它就"耗尽"了,rewind()会直接抛异常。想再遍历?只能重新调用一次函数拿个新对象。我当年写单测就栽过:同一个$gen用了两次,第二次啥也没有,排查半天才反应过来。
2. 别手贱用 iterator_to_array()。 这函数是把生成器"全量消费"成数组——那你前面费劲省的内存,全白省了,还顺手 OOM。它名字诱惑性极强,记住:用生成器就是为了不落地成数组。
3. 不能随机访问。生成器没有下标,你没法$gen[100] 直接跳到第 100 个。需要随机访问的场景(比如要前后翻页),老老实实用数组或游标,别硬上生成器。
4.return的微妙限制。 PHP 7.1 之前,生成器里写 return 123; 直接编译报错;7.1+ 允许 return,但那个值不会出现在foreach里,只能靠$gen->getReturn() 单独取,适合返回"总数 / 状态码"这类元信息。想用返回值当数据流的,想都别想。
5. 隐式全量消费。json_encode()、array_values()、count()(部分实现)这些会悄悄把生成器跑完,内存瞬间回到解放前。见到它们要对生成器保持警惕。
6. 异常要自己兜。生成器函数里抛的异常会冒到foreach调用处,如果你在yield前开了资源(比如文件句柄、数据库连接),记得用try/finally 关掉,别像我当年那样 fd 泄漏查到头秃。
7. 生成器里别做重活还想快。 我见过有人以为用了生成器查询就变快了,结果还是慢得想砸键盘。记住:生成器省的是内存,不是时间。读大文件、查大表该花的 IO 一点没少。真要提速得靠索引、分页、并行,别把生成器当银弹。
八、什么时候该用,什么时候别凑热闹
我现在的决策树很简单:
- 该用:数据量未知或超大(大文件、大表导出、日志流、API 分页全量拉取)、且只需要"顺序消费一遍"的场景。生成器是性价比最高的选择。
- 别用:需要随机访问 / 多次遍历同一份数据、数据量本来就小(几千行以内,直接数组反而更直观)、或者瓶颈在 CPU 计算而非内存——后者请出门左转找 Worker 或协程。
再补一句大实话:生成器不是炫技,它最适合的永远是"我不确定数据有多大,但我得稳稳地处理完"这种场景。如果你的数据量拍胸脯保证不超过一万行,老老实实写数组,可读性反而更高,别为了"显得高级"硬套生成器。我给自己列过一张速查表:CSV / Excel 批量导入导出、超大日志逐行扫描、数据库大表游标遍历、第三方 API 分页全量拉取、目录树递归遍历、实时日志 tail 监听——这些全是生成器的主场;而需要排序、分组、随机跳读、反复遍历的场景,老老实实落数组或用游标,别跟生成器较劲。
还有,如果你的项目还在用一堆魔法常量当状态码、当类型字典,我强烈建议顺手升级一下写法——PHP 8.1 Enums 枚举实战:用 backed enum 取代常量字典(方法/接口/Switch 重构)里那种enum Status: string 的 backed enum,配合生成器返回的每行数据,代码可读性和类型安全都能上一个台阶。再搭配 PHP 8 的 Attributes 给处理器打标签,整条数据流水线写起来既清爽又不容易出错。
小结
回过头看,那次 OOM 反而是件好事——逼我把 yield从"听说过"变成了"离不开"。一句话总结它的价值:生成器不改你写循环的方式,只改数据存在内存里的形态;它用"按需产出"把 O(n) 的内存压成 O(1),让你处理百万级数据流时,内存始终稳稳当当。
下次再有人问"PHP 怎么处理大文件 / 大表导出",别再 file()和fetchAll()一把梭了。写一个yield生成器,内存友好、代码清爽,运维同学也会少找你麻烦。毕竟,能不半夜被告警叫起来,就是技术最大的浪漫。
如果你愿意,可以把今天这套思路沿用到更多地方:API 需要全量拉取第三方分页数据时,用生成器把"翻页 + 产出"封装成一个流;ETL 管道里每一环都是生成器,整条链路零中间数组;甚至写单元测试造大量假数据时,生成器也能让内存纹丝不动。工具很小,但用对了地方,它就是那种"用了就回不去"的东西。
标签:php, 后端, 开发语言描述:本文以作者一次真实 OOM 事故切入,讲透 PHP 生成器(Generator)与 yield 关键字:从"普通数组 vs 生成器"的内存差异、O(1) 惰性求值原理,到超大文件逐行读取、百万级数据库流式导出、yield from 委托与 send() 双向通信等生产级写法,并附 7 个常见踩坑。帮你用"按需产出"把百万级数据流的内存占用从线性爆炸压到几乎恒定,写出内存友好的迭代代码。

发表评论发布评论后审核成功,即可显示在列表中...