不能用 fetch_all() 或循环 fetch() 全部存数组,因为会一次性将全部结果集加载进内存,100 万行 × 每行 2KB ≈ 2GB,直接触发内存耗尽错误;即使 while fetch(),若未禁用缓冲(如 PDO 默认 buffered),仍会全量缓存,必须配合游标分页、有序索引与 unbuffered 查询,通过 yield 实现边取边处理的真正流式读取。

直接结论:用 yield 逐行处理百万级数据库记录,内存可压到几 MB,但必须配合游标分页 + 有序索引,不能用 LIMIT OFFSET。
为什么不能用 fetch_all() 或循环 fetch() 全部存数组?
PHP 默认 PDO/MySQLi 的 fetch_all() 会把全部结果集一次性加载进内存。100 万行 × 每行平均 2KB ≈ 2GB 内存 —— 这不是“慢”,是直接 Fatal error: Allowed memory size of ... exhausted。
即使改用 while ($row = $stmt->fetch()),若底层驱动未启用 PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = false(MySQLi 默认 unbuffered),依然会缓冲全部结果。
真正安全的做法是:不缓存、不攒数组、不依赖 PHP 层分页,让数据库每次只吐出你需要的那一批。
立即学习“PHP免费学习笔记(深入)”;
如何用 yield 实现真正的流式读取?
核心不是“PHP 怎么循环”,而是“怎么让数据库持续推送、PHP 边取边交还控制权”。以下为最小可行结构:
<pre class="brush:php;toolbar:false;">function readLargeTable(\PDO $pdo, string $cursor = null): \Generator
{
$sql = "SELECT id, name, created_at FROM users WHERE 1=1";
$params = [];
if ($cursor !== null) {
// 游标必须是上一页最后一条的 (created_at, id) 组合值
$sql .= " AND (created_at, id) < (?, ?)";
$params = [$cursor['time'], $cursor['id']];
}
$sql .= " ORDER BY created_at DESC, id DESC LIMIT 2000";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
yield $row;
}
}
- 必须确保
ORDER BY created_at DESC, id DESC 和 <code>WHERE (created_at, id) < (?, ?)字段顺序、方向完全一致,否则索引失效 -
LIMIT值建议 1000–5000,太小增加查询次数,太大仍可能内存波动 - 游标值(如
$cursor['time'])必须来自上一页末条记录,不能前端传页码或字符串拼接 - 字段类型要严格校验:
$cursor['time']必须是合法Y-m-d H:i:s,$cursor['id']必须是 int,否则 SQL 注入或类型隐式转换导致漏数据
为什么一定要用游标分页而不是 LIMIT 1000000, 2000?
当 OFFSET 超过 10 万,MySQL 就得扫描并丢弃前 100 万行 —— 即使有索引,也是 type: index 全索引扫描,I/O 和 CPU 都爆炸。实测百万级表 LIMIT 999000, 2000 可能耗时 8 秒以上;而游标分页稳定在 20–50ms。
关键前提:
-
created_at字段必须有复合索引:INDEX idx_created_id (created_at DESC, id DESC) - 不能用
datetime字段单独做游标,因为存在时间相同的情况,必须加入id消除歧义 - 如果业务允许,优先用自增
id做游标(WHERE id < ? ORDER BY id DESC),最简单可靠
容易被忽略的底层细节
很多人写了 yield 却没生效,是因为没关掉 MySQL 的结果集缓冲:
- PDO 必须显式设置:
$pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false); - MySQLi 必须用
mysqli_stmt::get_result()之前调用mysqli_stmt::execute(),且连接需开启MYSQLI_CLIENT_FOUND_ROWS - PostgreSQL 更干净,默认就是 unbuffered,但要注意
PDO::ATTR_EMULATE_PREPARES = false,否则游标参数会被转成字符串丢失精度 - 生成器函数返回后,
$stmt和$pdo仍持有连接,记得在长期运行脚本中控制连接生命周期,避免Too many connections
游标分页 + yield 不是银弹:它要求排序字段绝对有序、不可空、有索引,且无法跳转任意页 —— 如果你真需要“跳到第 827 页”,那就老老实实加缓存或预计算总数+偏移优化,别硬扛。



















