
PHP处理千万级数据实战:从文件读取到数据库写入的高性能优化全攻略
在日常开发中,我们经常需要处理大规模数据,比如导入百万级用户信息、分析亿级日志、或者迁移历史订单。很多开发者第一反应是用PHP写个脚本,然后直接file_get_contents或者fetchAll,结果跑着跑着就报“内存耗尽”错误。这是因为PHP默认的内存限制通常只有128M或256M,而千万行数据轻松就能达到几个G,全量加载必然崩溃。
要解决这个问题,核心思路很简单:化整为零。不要让PHP一次吃掉整个大象,而是一口一口地吃。具体来说,就是把“全量加载”变成“流式处理”和“分治策略”。下面我会从文件读取、数据库查询、数据写入、运行环境四个方面,手把手教你写出能扛住千万级数据的PHP脚本。
一、读取大文件:用生成器实现流式迭代,告别内存爆炸
1.1 为什么不能用file_get_contents
很多新手喜欢这样写:
$content = file_get_contents('huge_file.csv');
$lines = explode("\n", $content);这种写法会把整个文件内容读进内存。假设文件有2GB,那么PHP进程就会占用至少2GB内存,加上变量复制开销,很可能超过4GB。在共享主机或云服务器上,这几乎必死无疑。
正确的做法是使用文件指针逐行读取。PHP提供了fopen+fgets的组合,每次只读取一行到内存,处理完就丢弃,内存占用始终只有几十KB。
1.2 生成器的妙用
光用fgets还不够优雅,因为我们需要把读取逻辑封装起来,方便复用。这时候生成器(Generator)就派上用场了。生成器是一种特殊的函数,它使用yield关键字返回值,而不是return。每次调用生成器时,它只执行到下一个yield,然后暂停,下次继续。这样就能做到“按需迭代”,不一次性把所有数据加载到内存。
下面是一个通用的文件行读取生成器:
function getLines(string $filePath): Generator
{
$handle = fopen($filePath, 'r');
if (!$handle) {
throw new RuntimeException("无法打开文件: {$filePath}");
}
try {
while (($line = fgets($handle)) !== false) {
yield rtrim($line, "\n\r"); // 去掉换行符
}
} finally {
fclose($handle);
}
}使用的时候只需要一个foreach:
foreach (getLines('huge_data.csv') as $lineNum => $line) {
$fields = str_getcsv($line);
// 在这里处理每一行数据
processRow($fields);
}为什么这样能省内存?因为生成器内部维护了一个状态,每次yield后,局部变量会被保留,但之前产生的值已经被销毁。fgets每次只返回一行字符串,处理完后就可以被垃圾回收。所以无论文件多大,PHP的内存占用始终只有一行数据的大小加上少量框架开销。
1.3 实际案例:处理5GB的CSV文件
我曾经需要把一个5GB的用户行为日志导入数据库。如果用file函数,直接报Allowed memory size exhausted。改用生成器后,脚本跑了大概15分钟,内存峰值一直稳定在20MB左右。关键代码如下:
$generator = getLines('user_logs.csv');
$batch = [];
$count = 0;
foreach ($generator as $line) {
$data = str_getcsv($line);
$batch[] = [
'user_id' => $data[0],
'action' => $data[1],
'time' => $data[2]
];
$count++;
if ($count % 5000 == 0) {
batchInsert($batch); // 批量写入
$batch = [];
}
}
if (!empty($batch)) {
batchInsert($batch);
}这里还结合了批量写入,后面会详细讲。
二、数据库查询:游标模式与分块查询,两种姿势任你选
从数据库里读取千万行数据同样不能直接用fetchAll。否则MySQL会把所有结果发送给PHP,PHP再一股脑塞进内存,一样会炸。有两种主流方案:游标模式和主键分块查询。
2.1 PDO无缓冲查询(游标模式)
默认情况下,PDO使用缓冲查询,即fetchAll时会一次性拉回所有结果。我们可以关闭缓冲,让PHP每次只从MySQL服务器取一行。设置方法很简单:
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test', 'user', 'pass');
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query('SELECT * FROM huge_table');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// 逐行处理,内存占用极低
processRow($row);
}优点:代码简单,适合一次性全表扫描。
缺点:查询期间会长时间持有MySQL连接,如果处理逻辑很慢,可能导致锁等待或连接超时。另外,不支持在同一个连接上执行其他查询,直到游标关闭。
2.2 主键分块查询(推荐)
更稳妥的做法是根据自增主键ID分块查询。每次只查一小批,比如1000条,处理完后再查下一批。这样即使处理时间长,也不会长期占用数据库资源,而且天然支持断点续传——如果脚本中途挂了,记录下最后处理的ID,下次从这个ID继续即可。
示例代码:
$lastId = 0;
$pageSize = 2000;
while (true) {
$stmt = $pdo->prepare(
'SELECT * FROM huge_table WHERE id > :lastId ORDER BY id ASC LIMIT :limit'
);
$stmt->bindValue(':lastId', $lastId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($rows)) {
break; // 没有更多数据了
}
foreach ($rows as $row) {
processRow($row);
}
$lastId = (int) end($rows)['id']; // 更新边界
}为什么要用ORDER BY id ASC?保证每次取的都是递增的ID,不会漏掉或重复。为什么不直接用OFFSET?因为OFFSET在大偏移量下性能急剧下降,而基于索引的WHERE id > xxx可以走主键索引,非常快。
2.3 两种方案的取舍
- 如果你只是简单导出数据,处理速度很快(比如几毫秒一行),可以用游标模式。
- 如果你的处理逻辑很复杂(比如调用外部API、生成PDF),或者需要支持中断恢复,强烈推荐主键分块查询。
三、数据写入:批量拼接与事务提交,速度提升百倍
写入千万行数据时,最忌讳的就是逐条INSERT。每条INSERT都需要一次网络往返和一次事务提交,1000万次就是1000万次IO,性能惨不忍睹。
3.1 批量INSERT的原理
MySQL支持一条INSERT语句插入多行数据:
INSERT INTO table (col1, col2) VALUES (v1, v2), (v3, v4), ...;这样一次请求就能插入几百甚至几千行,网络开销和SQL解析次数大大减少。配合事务,还能进一步降低磁盘刷写频率。
3.2 实现批量写入的PHP代码
我们需要动态构建SQL语句。注意要使用预处理语句防止SQL注入,但预处理语句不能直接绑定可变数量的参数,所以我们需要手动拼接占位符并传入参数数组。
下面是一个通用的批量插入函数:
function batchInsert(PDO $pdo, string $table, array $columns, array $rows): void
{
if (empty($rows)) return;
$columnCount = count($columns);
$placeholders = [];
$values = [];
foreach ($rows as $row) {
$rowPlaceholders = [];
foreach ($columns as $col) {
$rowPlaceholders[] = '?';
$values[] = $row[$col] ?? null;
}
$placeholders[] = '(' . implode(',', $rowPlaceholders) . ')';
}
$sql = sprintf(
'INSERT INTO %s (%s) VALUES %s',
$table,
implode(',', $columns),
implode(',', $placeholders)
);
$stmt = $pdo->prepare($sql);
$stmt->execute($values);
}然后在主循环中每积累一定数量就调用一次:
$pdo->beginTransaction();
$batch = [];
$batchSize = 1000;
foreach ($dataGenerator as $item) {
$batch[] = $item;
if (count($batch) >= $batchSize) {
batchInsert($pdo, 'target_table', ['col1', 'col2', 'col3'], $batch);
$pdo->commit();
$pdo->beginTransaction(); // 开启新事务
$batch = [];
}
}
// 处理剩余不足一批的数据
if (!empty($batch)) {
batchInsert($pdo, 'target_table', ['col1', 'col2', 'col3'], $batch);
$pdo->commit();
}为什么每批都要提交事务?因为事务太大也会占用大量内存和锁资源。一般建议每1000~5000行提交一次,具体数字可以根据字段数量和服务器性能调整。
3.3 性能对比
实测数据:往一张10个字段的表里插入100万行。
- 逐条INSERT:大约需要300秒(5分钟)
- 批量INSERT(每批1000行):大约需要8秒
- 批量+事务(每批1000行提交一次):大约需要5秒
可见优化效果极其明显。
四、运行环境与架构优化:别让脚本死在半路上
4.1 必须使用CLI模式
千万级数据处理绝对不能通过Web浏览器触发。Web服务器(如Apache/Nginx)通常有超时设置(如30秒),而且浏览器也可能超时。正确的做法是使用PHP CLI(命令行接口)运行脚本:
php /path/to/script.phpCLI模式没有执行时间限制(除非你在php.ini里设置了max_execution_time),而且输出可以直接打印到终端方便调试。
4.2 显式取消时间限制
虽然CLI默认不限时,但有些环境可能继承了配置。最好在脚本开头加上:
set_time_limit(0);这样PHP就不会因为执行时间过长而被杀死。
4.3 内存监控与垃圾回收
尽管流式处理已经控制了内存,但长时间运行的脚本可能会因为循环中创建的对象没有被及时释放而导致内存缓慢增长。比如在循环里使用了闭包、匿名类或大量临时变量。建议每隔一段时间调用gc_collect_cycles()强制回收循环引用:
$counter = 0;
foreach ($bigData as $item) {
// 处理...
$counter++;
if ($counter % 10000 == 0) {
gc_collect_cycles();
}
}另外,可以在循环中定期输出内存占用,便于观察:
echo memory_get_usage(true) / 1024 / 1024 . " MB\n";4.4 水平扩展:当单机不够用时
如果数据量达到数十亿行,单机处理可能需要几天甚至几周。这时就要考虑分布式架构了。常见的做法是:
- 数据分片:按照某个字段(如用户ID哈希)将数据切分成多个小文件。
- 消息队列:将每个小文件的处理任务发布到消息队列(如RabbitMQ、Kafka)。
- 多消费者:启动多台服务器的PHP消费者,从队列中领取任务并行处理。
这样就能把处理时间从小时级降到分钟级。当然,这属于更高级的架构,大多数场景下单机优化已经足够。
总结
处理千万级数据并没有想象中那么可怕,只要遵循“化整为零”的原则,PHP完全可以胜任。记住四个关键点:
- 读取文件:用生成器逐行读取,绝不一次加载全部。
- 查询数据库:用游标模式或主键分块查询,避免全量拉取。
- 写入数据:批量INSERT加事务提交,减少网络开销。
- 运行环境:使用CLI模式,取消时间限制,适当回收内存。
掌握了这些技巧,你就能写出稳定高效的PHP数据处理脚本,再也不用担心内存溢出了。