PHP 的 PDO 扩展在执行查询时,默认会把所有结果行一次性读入客户端内存,这种模式便于快速统计行数或反复遍历,但面对数十万甚至上百万行的结果集时,很容易把 PHP 进程内存撑爆。如果希望用更低的内存开销处理大批量数据,就需要把 PDO 切换到游标模式,让数据库服务器按需逐行发送记录。本文会从底层行为差异、典型业务场景和工程注意事项三个角度,分析 PDO 游标的使用方法。

一、缓冲查询与游标模式的行为差异
PDO 在 MySQL 驱动下默认启用缓冲查询,也就是说,调用 query 或 execute 之后,驱动会立即把整个结果集从数据库服务器读取到 PHP 进程的内存中。这样做的好处很明显:你可以随时使用 rowCount 获取总行数,也可以多次遍历结果集,甚至回退到第一行重新读取。但坏处同样明显,当结果集包含几十万条记录时,每条记录的字段值会被转换成 PHP 变量,内存占用可能从几十 MB 飙升到几百 MB,在内存限制为 128M 或 256M 的常见服务器上,脚本会直接报错中断。
游标模式的思路则截然不同。通过设置 PDO::MYSQL_ATTR_USE_BUFFERED_QUERY 为 false,驱动不再预取全部数据,而是向服务器发送一次查询请求,然后每次调用 fetch 时才从网络缓冲区读取一行数据。这样一来,PHP 进程在任何时刻只保留当前处理的行,内存开销几乎不随结果集大小增长。需要注意的是,游标模式下的结果集只能顺序向前读取,无法使用 rowCount 获得准确行数,也无法回退或随机访问某一行,这是换取低内存必须接受的限制。
<?php
$dsn = 'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4';
$pdo = new PDO($dsn, 'root', 'password');
// 默认缓冲查询:一次性载入全部结果
$stmt = $pdo->query('SELECT id, name, email FROM users');
echo $stmt->rowCount(); // 可以立即得到总行数
// 切换到游标模式:逐行读取
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query('SELECT id, name, email FROM users');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// 一次只处理一行
printf("%d %s\n", $row['id'], $row['name']);
}
$stmt->closeCursor();
?>
从代码中可以直观看到,缓冲查询在 query 执行后就可以访问 rowCount,而游标模式则必须不断调用 fetch 直到返回 false。如果结果集非常大,前一种写法可能撑爆内存,后一种写法却可以持续运行几个小时而不出现内存压力。
二、游标适用的典型业务场景
第一个典型场景是数据导出。假设需要把一张千万级用户表导出为 CSV 文件,如果使用默认缓冲查询,PHP 会尝试把千万行数据全部加载到内存,这显然不可行。而使用游标模式,可以边读边写文件,内存占用稳定在几 KB 到几十 KB 级别,即使数据量再大也不会崩溃。下面是一个导出 CSV 的示例,读者可以直接替换成自己的表名和字段。
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test;charset=utf8mb4', 'root', 'password');
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$fp = fopen('users_export.csv', 'w');
$stmt = $pdo->query('SELECT id, name, email FROM users ORDER BY id');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
fputcsv($fp, $row);
}
fclose($fp);
$stmt->closeCursor();
?>
第二个场景是流式处理或实时推送。例如需要从数据库中读取待发送的消息,并通过 WebSocket 或消息队列逐条推送给客户端。如果一次性取出所有待发送记录,不仅内存占用高,而且发送过程中数据库状态可能发生变化,导致重复或遗漏。使用游标模式可以做到读取一条、处理一条、确认一条,整个过程类似于管道流水线,实时性更好,资源消耗也更可控。
第三个场景是数据清洗或迁移。比如要把 A 库中的订单数据经过业务规则转换后写入 B 库,规则可能涉及外部 API 调用或复杂计算,无法用一条 SQL 完成。此时如果先用缓冲查询把全部数据读入内存,再逐条处理,容易在转换阶段耗尽内存。游标模式让源数据的读取保持惰性,每处理完一行就释放一行,配合事务批量提交,能够以极低的内存完成大规模迁移任务。
三、游标模式中常见的坑与规避方法
游标模式最大的坑是:在未读完当前结果集之前,不能在同一个连接上发起新的查询。因为 MySQL 协议在同一连接上不支持同时存在两个未完成的结果集,如果游标还在读取状态,你却执行了另一个 query,PDO 会抛出类似“Cannot execute queries while other unbuffered queries are active”的异常。规避方法很直接,要么把当前游标读完,要么在处理完必要的行之后立即调用 closeCursor 主动释放结果集。
另一个容易忽略的问题是事务与游标的相互作用。如果开启了事务并在游标遍历过程中执行更新操作,数据库可能会持有大量行锁,长时间不提交会导致锁等待甚至死锁。建议的做法是:把游标读取和后续的写操作拆分成小批次,每处理完一批就提交事务,或者干脆使用独立连接分别处理读和写。对于需要长时间运行的脚本,还要注意数据库连接超时设置,必要时在脚本中定期重连或发送心跳查询。
不同数据库驱动对游标的支持程度也不一样。PDO 的 MySQL 驱动使用 PDO::MYSQL_ATTR_USE_BUFFERED_QUERY 控制开关,而 PostgreSQL 驱动默认就使用游标逐行读取,无需额外设置。如果你编写的代码需要在多种数据库之间切换,最好在连接初始化时统一检测驱动类型,再进行相应配置,避免产生移植性问题。
四、游标模式与其他大数据处理方案的对比
除了 PDO 游标,开发中还有一种常见做法是使用 limit 配合 offset 进行分页读取。这种方式适合数据量不大且需要随机跳转的场景,但当 offset 达到几十万甚至上百万时,数据库需要扫描并丢弃前面所有行,查询性能会急剧下降。游标模式则没有 offset 带来的额外开销,它只依赖结果集的内部指针顺序前进,因此更适合一次性顺序处理全部数据的场景。
有些开发者会使用 ORM 框架自带的分块方法,比如 Laravel 的 chunk 或 chunkById,这些方法底层依然依赖分页查询,只是把分页细节封装了起来。它们在小批量数据下非常方便,但在结果集超大或者需要长时间流式处理时,仍不如 PDO 游标来得直接和高效。原生 SQL 游标则是在数据库服务端维护游标状态,需要编写存储过程或使用特定语法,复杂度更高,通常只在复杂数据库逻辑中使用。
综合来看,如果你的任务满足“结果集很大、只需要顺序读取一次、每行处理逻辑相对独立”这三个条件,PDO 游标是最简单也最可靠的方案。它的实现成本低,不需要引入额外扩展,配合 closeCursor 和合理的错误处理,就能稳定完成数据导出、流式分析、批量迁移等工作。理解了游标模式的适用边界,你在面对大数据量查询时就能做出更合适的选择。