把多个小文件合并成一个大文件,是PHP项目里经常能碰到的需求。典型场景包括:分片上传完成后需要把所有分片按顺序拼回原始文件、日志采集系统中要把零散的日志文件归档成一个大文件、批量导出的数据分卷需要合并后再供用户下载。这类需求的核心思路其实非常朴素:创建一个目标文件,以追加模式打开它,然后依次读取每个小文件的内容写入进去。下面我们从基础的fopen追加模式讲起,一步步扩展到健壮的、能处理大文件的完整方案。

fopen追加模式的基础原理与简单实现
PHP中fopen函数打开文件时的模式参数决定了后续读写行为,其中a模式(append,追加)是文件合并的关键。以a模式打开文件时,文件指针会被定位到文件末尾,之后所有的fwrite操作都会把内容写到已有内容的后面,而不会覆盖原有数据。如果目标文件不存在,PHP会自动创建它,这一点对合并场景非常友好——不需要提前判断文件是否存在。
需要注意a模式和w模式的区别:w模式每次打开都会把文件清空,只适合写入单次内容;而a模式可以反复打开追加,即使在同一次请求中关闭再重新打开,之前写入的数据依然保留。合并多个文件时,也可以选择只打开一次目标文件、循环写入所有源文件内容,最后统一关闭,这样效率更高,减少了重复打开文件的系统开销。
先看一个最简单的实现,读取两个小文件合并到一个新文件中:
<?php
// 以追加模式打开目标文件,不存在则自动创建
$target = fopen('merged.txt', 'a');
// 打开第一个源文件并写入
$src1 = fopen('part1.txt', 'r');
stream_copy_to_stream($src1, $target);
fclose($src1);
// 打开第二个源文件并写入
$src2 = fopen('part2.txt', 'r');
stream_copy_to_stream($src2, $target);
fclose($src2);
// 关闭目标文件,确保数据落盘
fclose($target);
echo '合并完成';
上面代码中用到了stream_copy_to_stream,它是PHP内置的流拷贝函数,可以把一个流的内容直接复制到另一个流,内部会自动分块处理,比手动循环更简洁。如果希望更直观地体现fread加fwrite的配合,也可以写成手动循环的形式,下一节会详细展开。
分块读取逐个写入:大文件场景下的正确姿势
很多初学者合并文件时会图省事,直接用file_get_contents把整个源文件读进内存,再用file_put_contents追加写入。这种写法在小文件场景没问题,但一旦源文件达到几百MB甚至几个GB,内存就会瞬间被撑爆,因为file_get_contents是一次性读取,整个文件内容都会驻留内存。PHP默认的memory_limit通常是128M,大文件直接读取会触发Allowed memory size exhausted的致命错误。
正确的做法是分块读取:每次只从源文件读取固定大小的数据块(比如4096字节或1MB),写入目标文件后再读下一块,直到源文件读完。这样无论文件多大,内存占用始终只等于一个数据块的大小。具体实现需要配合fread和feof函数,feof用来判断文件指针是否已经到达末尾,循环读取直到它返回true为止。
下面是带错误处理的分块合并版本,适合在生产环境使用:
<?php
/**
* 将多个文件合并为一个文件
* @param array $files 源文件路径数组,按合并顺序排列
* @param string $targetFile 目标文件路径
* @param int $chunkSize 每次读取的字节数,默认1MB
* @return bool 合并是否成功
*/
function mergeFiles(array $files, $targetFile, $chunkSize = 1048576)
{
// 以追加模式打开目标文件
$target = fopen($targetFile, 'a');
if ($target === false) {
die("无法打开目标文件: {$targetFile}");
}
// 如果目标文件已有内容,先清空避免重复合并
ftruncate($target, 0);
foreach ($files as $file) {
if (!is_file($file)) {
echo "跳过不存在的文件: {$file}";
continue;
}
$source = fopen($file, 'r');
if ($source === false) {
echo "无法读取文件: {$file}";
continue;
}
// 循环分块读取,每次最多读chunkSize字节
while (!feof($source)) {
$chunk = fread($source, $chunkSize);
if ($chunk !== false && $chunk !== '') {
fwrite($target, $chunk);
}
}
fclose($source);
}
fclose($target);
return true;
}
// 使用示例:按顺序合并三个分片
$chunks = ['chunk_0.tmp', 'chunk_1.tmp', 'chunk_2.tmp'];
mergeFiles($chunks, 'final_video.mp4');
这里有一个容易被忽略的细节:代码中在追加打开之后调用了ftruncate($target, 0)把文件截断为空。为什么要这样做?因为合并任务可能被重复执行,如果目标文件上次已经合并过一次内容,直接追加会导致文件内容翻倍、顺序错乱。截断后再追加,既保留了追加模式的写入逻辑,又保证了幂等性。另外注意fread的返回值可能为空字符串,这在网络流中很常见,判断条件中加上空字符串检查可以避免无意义的写入。
批量扫描目录合并与合并顺序的控制
分片上传场景中,分片文件的命名通常带有序号,比如chunk_0、chunk_1、chunk_2。合并前必须保证按序号顺序处理,否则合并出来的文件就是乱的。直接用scandir或glob扫描目录得到的顺序是文件系统返回的顺序,不一定是数字升序(比如chunk_10会排在chunk_2前面),所以需要手动排序。
处理思路是:先扫描目录拿到所有分片文件名,再用usort配合自定义比较函数按文件名中的数字部分排序,最后传入合并函数。代码如下:
<?php
// 扫描分片目录
$dir = './uploads/chunks/';
$allFiles = glob($dir . 'chunk_*.tmp');
// 按文件名中的数字升序排序,保证chunk_2排在chunk_10之前
usort($allFiles, function ($a, $b) {
preg_match('/(\d+)\.tmp$/', $a, $ma);
preg_match('/(\d+)\.tmp$/', $b, $mb);
return intval($ma[1]) - intval($mb[1]);
});
echo '找到分片数量: ' . count($allFiles);
// 执行合并
mergeFiles($allFiles, './uploads/final.zip');
// 合并完成后可选择删除分片,释放磁盘空间
foreach ($allFiles as $f) {
unlink($f);
}
除了排序,还有两点值得注意。第一,合并二进制文件(图片、视频、压缩包)和文本文件在代码层面没有任何区别,因为fread和fwrite都是二进制安全的,不会对内容做任何转换,不用担心换行符被改写的问题。第二,如果分片数量特别多(上千个),频繁开关文件句柄会有一定开销,但比起一次性读入内存的风险,分块流式处理依然是更稳妥的选择。此外,合并完成后建议对目标文件做一次完整性校验,常见做法是合并前记录各分片的md5值,合并后对大文件整体或分段计算md5进行比对,确保传输和合并过程中没有数据损坏。
总结一下,PHP合并文件的核心就是fopen追加模式加分块读写:追加模式让多个文件的写入可以无缝衔接,分块读取让内存占用与文件体积解耦,排序保证分片顺序正确,错误处理让代码在文件缺失、权限不足时依然可控。掌握了这几个要点,无论是日志归档、分片重组还是批量数据导出,都可以用同一套思路轻松应对。