PHP 生成器 yield 实战:用 50 行代码处理百万级数据不爆内存

阿乐
阿乐 星耀SVIP管理员 黑卡会员
发布于 2026-09-14 15:07 ·1 浏览 ·0 回复

结论先行:PHP 处理百万级数据爆内存,根源几乎从来不是"数据太多",而是"你先把结果集全部变成了数组"。把 `fetchAll()` 换成生成器 `yield` 逐行产出,内存占用就从 O(N) 降到 O(1),百万行全程稳定在几 MB,核心代码不到 50 行。

为什么一定会爆:数组是全量驻留

结论:一条 SQL 查回 100 万行,`fetchAll()` 会把 100 万行在 PHP 进程里实例化成完整数组,内存按"行数 × 每行开销"线性上涨。

PHP 数组不是紧凑内存块,每个元素都要 zval + hashtable bucket,实际开销常在 100~200 字节/个,字符串内容还另算。一张每行 500 字节的表,100 万行光原始数据就 500MB,加上数组结构轻松冲破 1GB,默认 `memory_limit=128M` 直接 `Fatal error: Allowed memory size exhausted`。

更隐蔽的是 MySQL 侧:PDO 默认开启客户端缓冲(buffered query),`query()` 执行那一刻,整个结果集就已经拷进 PHP 内存了,你 `fetch()` 得再慢也救不回来。

yield 的本质:执行到 yield 就暂停

结论:生成器(Generator)是一个"可暂停的函数",每次 `yield` 只交出一个值就挂起,下次迭代再恢复,全程不存在一个装着全部数据的数组。

`yield` 返回的 Generator 对象实现了 Iterator 接口,`foreach` 每要一个值,函数体才往下跑一段。所以内存占用与数据总量无关,只与你自己一次攒了多少有关。

一个典型伪优化:在生成器里写 `yield from $pdo->query($sql)->fetchAll();`——`fetchAll()` 已经把全量数组建好了,等于白写。

实战:50 行流式处理百万行

结论:真正流式需要两个条件同时成立——PDO 关闭客户端缓冲,消费端逐行处理不累积。

<?php
$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false, // 关键:关闭客户端缓冲
]);

// 生成器:按批量吐数据,函数执行到 yield 就暂停
function rows(PDO $pdo, string $sql, int $chunk = 1000): Generator {
    $stmt = $pdo->query($sql);
    $buf  = [];
    while ($row = $stmt->fetch()) {
        $buf[] = $row;
        if (count($buf) >= $chunk) { yield $buf; $buf = []; }
    }
    if ($buf) yield $buf;
}

$fh = fopen('export.csv', 'w');
$total = 0;
foreach (rows($pdo, 'SELECT id,title,content FROM posts ORDER BY id') as $batch) {
    foreach ($batch as $row) {
        fputcsv($fh, $row);
        $total++;
    }
    echo "已处理 {$total} 行, 峰值内存 ", round(memory_get_peak_usage(true)/1048576, 1), " MB\n";
}
fclose($fh);
echo "完成,共 {$total} 行\n";

用 `php -d memory_limit=64M export.php` 跑一遍,百万行也能过,就是最好的验收。

四个必踩的坑

结论:生成器不是"更省内存的数组",它的限制必须提前知道。

  1. 不能当数组用:`count($gen)`、`$gen[0]`、`array_map` 都不支持;需要总数就在遍历时自己累加。
  2. 只能遍历一次:第二次 `foreach` 会抛 `Cannot traverse an already closed generator`,要复用就重新调用生成函数。
  3. 遍历中别用同一连接再查询:unbuffered query 没读完时发新查询会报 `Cannot execute queries while other unbuffered queries are active`。生成器里做 N+1 查询是最常见的翻车点,要么先读完,要么另开连接。
  4. 别指望变快:生成器换的是空间,每次 resume 有调度开销,总耗时通常比 `fetchAll()` 略高几个百分点,用时间换空间是划算的。

补充一个细节:生成器可以 `return $x`,但 `foreach` 拿不到,只能用 `$gen->getReturn()` 在遍历结束后取。

什么时候不该用

结论:数据量小(几千行以内)、需要多次遍历、需要随机访问或 count 时,直接用数组更省事。

生成器的适用场景很明确:数据量远大于内存、只需顺序单次遍历、边读边写。导出 CSV、批量改状态、全量重建索引、群发通知都属于这类"扫全表干一件事"的任务。像 Clara BBS 这类无需 Composer、无需命令行的轻量 PHP 系统,插件里跑全量任务时同样适用(生成器语法 PHP 5.5+ 即可,其要求的 PHP 7.4-8.5 完全支持),但它的后台计划任务属于懒触发,别指望它替你扛住内存问题。

一句话收束:爆内存几乎从来不是"数据太多",而是"你把数据全装进了数组"。`fetchAll()` 换成 `yield`,再配上 `PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false`,百万行与几千行的内存曲线几乎一样平;剩下要做的,只是别在遍历里图省事再查一次库。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-339.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~