上个月接手了一个老系统的日志分析脚本,功能很简单:统计一个约5GB的日志文件里某个错误码出现的次数。脚本原本是用PHP写的,我本地一运行,直接报“内存耗尽”。看着那个巨大的 file() 函数,我下意识地把文件整个载入了内存,结果不用想,5GB文本塞进内存谁也不够。后来换了用 fgets() 一行行读,虽然能跑,但代码又长又不优雅。直到我认真用了PHP的生成器,才发现这事原来可以这么优雅。
一、最初的写法:数组咔咔一顿装
不信你看,老代码大概长这样:
$logs = file('/var/log/errors.log'); // 5GB文本直接全装进内存
$count = 0;
foreach ($logs as $line) {
if (strpos($line, 'ERROR_CODE_503') !== false) {
$count++;
}
}
echo $count;
这段代码在文件小的时候没啥问题,一旦文件上了GB,PHP直接罢工。因为 file() 会把每一行作为一个数组元素,整个数组都塞进内存。5GB的文件,PHP内存占用至少10GB,因为每行还要有数组结构开销。
二、聪明的程序员用fgets,但代码很丑
为了避免内存爆炸,常规做法是用 fopen 逐行读取:
$handle = fopen('/var/log/errors.log', 'r');
$count = 0;
if ($handle) {
while (($line = fgets($handle)) !== false) {
if (strpos($line, 'ERROR_CODE_503') !== false) {
$count++;
}
}
fclose($handle);
}
echo $count;
这确实能解决内存问题,但你看这代码,又是while又是判断,还得手动关文件。如果业务复杂点,比如要同时读两个文件,然后交叉匹配,那写法会更痛苦。
三、生成器:用yield返回一行,用完就忘
PHP生成器允许你写一个普通的函数,但内部用 yield 一次返回一个值。调用这个函数时,它不会立即执行整个函数体,而是返回一个生成器对象。每foreach一次,生成器就执行到下一个 yield,并且在下一次迭代前暂停。说白了,它让你写代码像写数组一样简单,但底层却是一行行读取的。
上面那个日志统计功能,用生成器可以这样改写:
function readLines(string $filePath) {
$handle = fopen($filePath, 'r');
if (!$handle) {
throw new RuntimeException('无法打开文件');
}
while (($line = fgets($handle)) !== false) {
yield $line; // 每次产生一行
}
fclose($handle);
}
$count = 0;
foreach (readLines('/var/log/errors.log') as $line) {
if (strpos($line, 'ERROR_CODE_503') !== false) {
$count++;
}
}
echo $count;
是不是看着跟数组遍历一样清爽?关键内存占用只有一行字符串的大小。5GB的文件,内存依然是几十MB而已。
四、实战:从数据库导出CSV,百万行也不卡
我之前还遇到一个需求:把数据库某张表里的100万条记录导出成CSV。一开始直接查出来放在内存里,然后fputcsv,结果自然又是内存爆掉。后来用生成器配合数据库游标,完美解决。
假设用的是PDO,且MySQL驱动支持 MYSQL_ATTR_USE_BUFFERED_QUERY = false 来使用游标查询:
function fetchRows(PDO $pdo, string $sql, array $params = []) {
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield $row; // 一次yield一行
}
$stmt->closeCursor();
}
// 打开输出流
$output = fopen('php://output', 'w');
fputcsv($output, ['id', 'name', 'email']);
// 从数据库逐行读取并写入CSV
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'pass', [
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false,
]);
foreach (fetchRows($pdo, "SELECT id, name, email FROM users WHERE id > ?", [0]) as $row) {
fputcsv($output, $row);
}
fclose($output);
这个例子里,数据库查询不会一次性把全部结果拉到内存,而是每次取一行。生成器再把这一行交给fputcsv,整个脚本的内存占用始终维持在一个很小的范围。
五、生成器还可以传送值,不只是单方向
很多人以为生成器只能让foreach调用,其实它还能接受外部发送的值。比如你需要一个“可暂停的计数器”,或者实现简单的协程调度。但咱们这个场景用不上,不过既然写了,就贴一个小例子:
function wizard() {
echo "进入函数n";
$name = yield "你叫什么?n";
echo "收到名字: $namen";
$age = yield "你多大了?n";
echo "收到年龄: $agen";
}
$gen = wizard();
echo $gen->current(); // 输出“你叫什么?”
$gen->send("张三"); // 发送值给yield,继续执行到下一个yield
echo $gen->current(); // 输出“你多大了?”
$gen->send("25"); // 发送年龄,然后执行完毕
不过说真的,这个特性在复杂协程里才有用,处理大文件通常只用单向迭代就行。
六、注意几个坑
- 生成器函数里的变量会保持状态,所以你可以写一个计数器或复杂状态机,但要注意别意外保留大数组在局部变量中。
- 生成器只能遍历一次,不能像数组那样来回循环。如果你需要重复读取同一个文件,就重新调用函数生成一个新生成器。
- 如果提前break,函数里的finally代码块依然会执行。所以关闭文件流的操作可以放在finally里更安全。
- 生成器不是异步的,它依然按顺序执行,但它的优势在于延迟执行和内存节省。
拿一行行读文件来说,我习惯把fclose放在finally里,这样即使foreach中间break,文件也会被关闭。改进一下:
function readLines(string $filePath) {
$handle = fopen($filePath, 'r');
if (!$handle) {
throw new RuntimeException('无法打开文件');
}
try {
while (($line = fgets($handle)) !== false) {
yield $line;
}
} finally {
fclose($handle);
}
}
这样代码更健壮。
七、性能到底怎么样
我拿一个1GB的日志文件做了对比实验。用file()直接内存耗尽。用fgets手动循环,耗时约9秒。用生成器封装后再迭代,耗时约9.2秒,差异很小,但代码可读性和复用性彻底不一样。生成器几乎不会减慢执行速度,因为它底层还是同样的fgets循环。
更重要的是,生成器把“怎么读取”和“怎么处理”分开了。我可以写一个通用的readLines,然后用来统计错误、筛选IP、做数据清洗等等,每个地方只需要foreach,非常灵活。
八、最后再说一个真实案例:合并多个大文件
我还用生成器做过多文件合并。比如有三个日志文件,需要按时间顺序合并成一个。每个文件都很大,不能一次性读入。我可以为每个文件创建一个生成器,然后通过循环从三个生成器中取最小时间戳的那一行输出。这样内存照样稳。
具体实现可能涉及迭代器聚合,但原生生成器配合 yield from 也能简化操作:
function readLines(string $filePath) {
// ...同前
}
function mergeLines(array $filePaths) {
$gens = array_map('readLines', $filePaths);
// 简单的轮流读取(实际场景需要排序)
foreach ($gens as $i => $gen) {
foreach ($gen as $line) {
yield "{$i}:{$line}";
}
}
}
虽然不是最快,但胜在简洁。
结语
PHP生成器不是什么新东西,但我发现很多业务开发同事很少用它,一旦遇到大文件就直接考虑换语言或者改命令行。其实PHP足够搞定,只要你愿意用生成器去控制内存。现在我已经把这几个生成器函数封装到公司公共库里了,人手一份,再也没听说谁处理日志把机器搞挂。
试试在你的项目里用 yield 改写一处大循环,相信我,你会爱上这种省内存的感觉。

