上周同事导出一个月的订单数据,两百多万行,脚本跑了不到一分钟,内存直接爆到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虽然总被嘲笑,但该有的东西其实一样不缺。

