PHP生成器实战:一行行读文件,内存占用直接降99%

2026-08-19 0 817

上个月接手了一个老系统的日志分析脚本,功能很简单:统计一个约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 改写一处大循环,相信我,你会爱上这种省内存的感觉。

PHP生成器实战:一行行读文件,内存占用直接降99%
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP生成器实战:一行行读文件,内存占用直接降99% https://www.taomawang.com/server/php/2570.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务