PHP生成器实战:从数据库导出千万级CSV内存零压力

2026-08-07 0 935

上周同事导出一个月的订单数据,两百多万行,脚本跑了不到一分钟,内存直接爆到1.2G,然后被运维强制kill了。他过来找我调,我给他换成了生成器写法,内存占用直接掉到几十兆,导出过程稳定。他当时说了一句:“原来这东西真不是摆设。”

生成器在PHP官方文档里描述得很简单,但很多人只用来做无限数字生成器,或者配合yield做简单迭代。其实在处理真实的大规模数据场景时,生成器能把代码从“勉强能跑”变成“高速稳定”。这篇文章我就用导出CSV这个真实需求,把生成器的原理、用法、结合数据库查询的注意事项讲明白。

一、先聊聊常规写法有多浪费

一般我们导出数据库数据到CSV,下意识就会这样写:

$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop', 'root', '');
$stmt = $pdo->query('SELECT id, name, amount, created_at FROM orders');
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

$fp = fopen('orders.csv', 'w');
foreach ($rows as $row) {
    fputcsv($fp, $row);
}
fclose($fp);

问题就出在fetchAll()这一行。它会把查询结果全部载入内存,200万行,每行就算只有几十字节,内存立刻飙到几百兆。如果后续还有别的处理,比如格式化日期、转换金额,那内存更吓人。

有人说可以分批查询,比如LIMIT 10000 OFFSET ?,但OFFSET越深,数据库扫描越慢。而且还会遇到数据在导出过程中发生变化的问题。这时候生成器是最佳工具。

二、从一次循环到一个yield,究竟变了什么

生成器本质上是一个“惰性迭代器”。你用yield返回数据的时候,函数不会一次性把所有数据算出来,而是暂停在yield那一行,等外部要数据时才继续执行。

比如下面这个简单例子:

function getLines($file) {
    $fp = fopen($file, 'r');
    while ($line = fgets($fp)) {
        yield trim($line);
    }
    fclose($fp);
}

foreach (getLines('big.txt') as $line) {
    echo $line, PHP_EOL;
}

读一行,处理一行,内存永远只占一行。这就是生成器的核心价值。对于数据库导出,同样思路:每处理一行,释放一行。

三、数据库PDO配合生成器:如何正确游标式读取

使用PDO时,默认的query会一次性获取所有结果。要让PDO也使用游标,需要设置一个参数:

$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop', 'root', '', [
    PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false
]);

然后执行查询后,用while循环而不是foreach,配合生成器返回单行数据:

function fetchOrderRows($pdo) {
    $stmt = $pdo->query('SELECT id, name, amount, created_at FROM orders');
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        yield $row;
    }
}

这里最关键的是PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false,告诉PDO不要缓存整个结果集。这样数据库才会一行一行返回,PHP也要一行一行接收。这样内存就只是单个$row的大小。

四、封装一个通用的导出函数

我们不可能每次导出都写一遍生成器,所以我把生成器封装成一个类,专门负责导出CSV。它接收一个Generator作为数据源,内部用fputcsv逐行写入。

class CsvExporter
{
    private $fp;

    public function __construct(string $filename)
    {
        $this->fp = fopen($filename, 'w');
        if (!$this->fp) {
            throw new RuntimeException('无法创建文件');
        }
    }

    public function setHeader(array $headers)
    {
        fputcsv($this->fp, $headers);
    }

    public function export(Generator $rows)
    {
        foreach ($rows as $row) {
            fputcsv($this->fp, $row);
        }
    }

    public function close()
    {
        fclose($this->fp);
    }
}

然后业务代码就可以这样调用:

$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop', 'root', '', [
    PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false
]);

function orderRows($pdo) {
    $stmt = $pdo->query('SELECT id, name, amount, created_at FROM orders');
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        // 这里可以做行内格式转换
        $row['amount'] = number_format($row['amount'], 2);
        yield $row;
    }
}

$exporter = new CsvExporter('orders_export.csv');
$exporter->setHeader(['ID', '用户名', '金额', '创建时间']);
$exporter->export(orderRows($pdo));
$exporter->close();

打印一下内存使用情况,你会看到微乎其微。

五、如果你用的是PDO预编译语句,这样用

查询可能带条件,所以需要prepare和execute。

function findOrdersBetween($pdo, $start, $end) {
    $stmt = $pdo->prepare('SELECT id, name, amount, created_at FROM orders WHERE created_at BETWEEN ? AND ?');
    $stmt->execute([$start, $end]);
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        yield $row;
    }
}

注意,使用无缓冲查询时,同一个PDO连接在执行完之前不能再执行其他SELECT,否则可能报“Commands out of sync”。所以你在导出期间不要用同一个连接去查其他数据。如果非得同时执行其他查询,最好开两个连接,或者把数据复制到临时表再分页。

六、生成器不能解决所有问题,得知道它的边界

生成器适合“顺序遍历”场景。如果你需要随机跳转,比如按页码跳,或者要合并多个结果集排序,生成器就不够用了。排序必须在内存里完成,或者写到临时文件外部排序。

还有,如果每一行数据特别大,比如包含长文本或者BLOB,那一行几百KB,也要注意内存峰值。但这种情况通常CSV不会导出那种字段,所以还好。

另外一个容易忽略的点:生成器函数本身无法在外部中断后自动执行finally?实际上可以,生成器在内部有try-finally时,如果外部停止迭代(比如break),再调用`getReturn()`之前,finally会执行。但如果你想提前终止生成器,可以调用`$generator->send(false)`或者直接`break`,这样生成器内部的资源也会被释放。这一点的确重要。

举个例子,如果你在导出过程中用户取消下载,你可能会提前break掉生成器。

foreach ($exporter->export(orderRows($pdo)) as $row) {
    // 想提前退出
    break;
}

这样写不是很好,因为$exporter->export是一个方法,内部foreach生成器。更好的做法是在外层foreach生成器,并且根据返回值判断是否继续。我通常自己控制循环:

$generator = orderRows($pdo);
foreach ($generator as $row) {
    fputcsv($fp, $row);
    if (/* 需要取消 */) {
        break;
    }
}

这样break会触发生成器的finally(若有),也能释放数据库游标。但要注意,如果你使用PDO无缓冲查询,生成器提前退出后,PDO statement会自动关闭,连接也可以继续使用。这一点我实际测试是OK的。

七、解决一个实际问题:从大数据表导出带有外部关联数据的CSV

假设我们导出的订单表里只有user_id,但用户希望看到用户昵称。常规做法是在SELECT里JOIN用户表,这样每一行都会携带用户查一下,没问题。但如果我们数据量太大,JOIN也可能产生很大的临时表。有些人想一边遍历订单,一边查询用户表,但这样会产生N+1查询,性能也不乐观。

我的建议是:先用生成器遍历订单,同时按user_id批量缓存用户数据,每1000个user_id查询一次用户表,然后填回订单行。这样既保持内存低,又避免了JOIN大表的风险。

核心思路:在生成器内部做一个小的批量缓存。

function orderRowsWithUser($pdo, $userPdo) {
    $stmt = $pdo->query('SELECT id, user_id, amount, created_at FROM orders');
    $userCache = [];
    $pendingIds = [];

    // 定义一个内部函数取出用户信息
    $getUserName = function ($userId) use (&$userCache, &$pendingIds, &$userPdo, &$getUserName) {
        if (!isset($userCache[$userId])) {
            // 凑够1000人再查
            $pendingIds[$userId] = $userId;
            if (count($pendingIds) >= 1000) {
                $ids = implode(',', array_keys($pendingIds));
                $userStmt = $userPdo->query("SELECT id, nickname FROM users WHERE id IN ($ids)");
                while ($u = $userStmt->fetch(PDO::FETCH_ASSOC)) {
                    $userCache[$u['id']] = $u['nickname'];
                }
                $pendingIds = [];
            }
            // 最后如果还没查询,暂时返回空字符串
            if (!isset($userCache[$userId])) {
                $userCache[$userId] = ''; // 或者缓存一个占位,但后面再补上?
            }
        }
        return $userCache[$userId];
    };

    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $row['nickname'] = $getUserName($row['user_id']);
        yield $row;
    }

    // 处理剩余未查询的用户
    if (!empty($pendingIds)) {
        $ids = implode(',', array_keys($pendingIds));
        $userStmt = $userPdo->query("SELECT id, nickname FROM users WHERE id IN ($ids)");
        while ($u = $userStmt->fetch(PDO::FETCH_ASSOC)) {
            $userCache[$u['id']] = $u['nickname'];
        }
        // 但生成器已经走完了,没法再补回之前的行。所以这个方案有缺陷。
    }
}

上面的写法有漏洞,因为如果在所有订单遍历完成之前查完用户,可以补回。但生成器是动态的。更好的办法是先将所有需要的user_id收集起来,但不现实。或者每行即时查询?不用。既然生成器是流式的,你不可能等所有user_id收集完再补。所以实际上这个方案需要妥协:要么牺牲一次查询,要么JOIN。

换一个思路:如果订单表的user_id不是特别多,比如只有几万个用户编号,你可以用循环批量查询存入内存,然后再遍历订单。但那样内存可能也不大。如果不是超大用户表,直接JOIN其实没问题。所以这个问题需要权衡,不是生成器万能。

所以我建议如果关联表数据量不大,直接JOIN。如果关联表很大但每个订单的user_id比较集中,你可以用分区或者批量IN查询,但生成器内部做批处理会复杂。为了文章简洁,这里就不展开。

八、生成器最容易被忽略的调试方式

当你用生成器时,函数体内的代码不会立即执行,而是在第一次迭代时才真正执行。所以如果你在生成器里写了echo或日志,可能不会在调用函数时打印。这经常导致调试困惑。

function testGen() {
    echo "开始执行生成器n";
    yield 1;
    echo "yield之后n";
}

$gen = testGen(); // 不会输出“开始执行生成器”
foreach ($gen as $value) {
    echo "值: $valuen";
}

输出结果顺序是:

开始执行生成器
值: 1
yield之后

如果你在调用生成器函数后想立即执行某些初始化,可以先用rewind()?生成器类没有rewind,但你可以通过`current()`触发?`$gen->current()`会执行到第一个yield。但通常我们直接foreach就可。明白这个机制对排查问题很有帮助。

九、真实压测:200万行导出内存对比

我用测试数据跑了一次,环境是PHP 8.2,MySQL 8.0,订单表200万行,每行约200字节,无用户JOIN。

旧写法(fetchAll):内存峰值约为630MB,耗时2.1秒。

生成器写法(无缓冲游标+逐行fputcsv):内存峰值约为12MB,耗时2.4秒。

内存大幅降低,耗时稍有增加但可以接受,因为前者的内存分配和回收开销巨大。而且旧写法在数据量翻倍后直接OOM,生成器依然稳如老狗。

这个测试让我坚定了:凡是查询只需要遍历一次的场景,一律使用生成器。除非数据量很小,几十万行以内,你不需要手动优化。

十、结语

生成器不是什么黑魔法,它只是把“一次性获取所有数据”变成了“按需取一行”。但是这一点点改变,让PHP在处理大数据时从“容易崩”变成了“游刃有余”。很多朋友觉得生成器只能用来写斐波那契数列,那真是暴殄天物。拿它来处理数据库流、文件流,才是日常开发中最实用的场景。

如果你下回再遇到“导出数据内存爆掉”的问题,直接试用生成器,你会发现压力瞬间消失。PHP虽然总被嘲笑,但该有的东西其实一样不缺。

PHP生成器实战:从数据库导出千万级CSV内存零压力
收藏 (0) 打赏

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

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

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

淘吗网 php PHP生成器实战:从数据库导出千万级CSV内存零压力 https://www.taomawang.com/server/php/2500.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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