学完这篇,你能用 PHP 安全地读写几百 MB 的大文件、把任意目录树遍历干净、并把权限配到「能写但不能被当成脚本执行」——三者都是上传目录、附件目录最容易出事的地方。
第一步:先确认三个环境前提
在写任何文件代码前,先搞清楚这三件事,否则后面全是玄学问题:
- 内存上限:
memory_limit,php -i | grep memory_limit 或 phpinfo() 里看。一次性读大文件超限就是在这里爆的。
- 执行时限:
max_execution_time,Web 请求里默认常见 30 秒。处理大文件时脚本开头写 set_time_limit(0); 解除。
- 运行用户:PHP-FPM 通常以
www / www-data 跑。谁运行,谁就是新建文件的所有者。用 ps aux | grep php-fpm 确认。
注意:set_time_limit(0) 只对当前脚本生效。真正耗时几分钟以上的任务(批量转码、全站索引)应该交给计划任务或 CLI,别挂在 Web 请求里。
第二步:读大文件——永远别用 file_get_contents
file_get_contents() 会把整个文件塞进内存,读 500MB 文件基本等于自杀。三种替代方案按场景选:
逐行读(日志、文本):
$fp = fopen($path, 'rb');
if (!$fp) { exit('无法打开'); }
while (($line = fgets($fp)) !== false) {
// 处理 $line
}
fclose($fp);
定长块读(二进制、任意大小):
$fp = fopen($path, 'rb');
while (!feof($fp)) {
$chunk = fread($fp, 1024 * 64); // 64KB 一块
// 处理 $chunk
}
fclose($fp);
下载大文件(避免撑爆内存):
while (ob_get_level()) { ob_end_clean(); } // 关掉所有输出缓冲
header('Content-Type: application/octet-stream');
header('Content-Length: ' . filesize($path));
readfile($path); // 或 fpassthru($fp)
写大文件同理:用 fopen($path, 'ab') + 循环 fwrite() + fflush(),不要用字符串一路 file_put_contents($path, $str, FILE_APPEND) 拼几十万次。
第三步:目录遍历——三种写法别用混
只看一层、要全部条目 → scandir(),记住必须过滤 . 和 ..:
foreach (scandir($dir) as $name) {
if ($name === '.' || $name === '..') continue;
$full = $dir . DIRECTORY_SEPARATOR . $name;
if (is_file($full)) { /* ... */ }
}
按扩展名筛选一层 → glob($dir . '/*.jpg')。
递归整棵树 → RecursiveDirectoryIterator + RecursiveIteratorIterator:
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $file) {
if ($file->isFile()) {
echo $file->getPathname(), ' ', $file->getSize(), " bytes\n";
}
}
要跳过某些目录(比如缓存、.git),用 RecursiveCallbackFilterIterator 在回调里 return false。
注意:遍历路径如果来自用户输入,先用 realpath() 解析并确认它在你允许的根目录之内,否则 ../../ 就能把整台服务器读一遍。永远不要直接拼接用户传来的路径。
第四步:权限管理——属主、模式、可写性
- 先判断再操作:
is_writable() / is_readable() 比直接 fopen 报错友好。
- 模式选择:普通文件
0644,目录 0755;需要同组写入的上传目录用 0775。不要用 0777。
- 目录的执行位:目录的
x 表示「可进入」,少了它连 cd 都进不去,遍历直接失败。
- 改属主:
chown($path, 'www') 需要 root 权限,PHP 里通常做不到,得在面板或命令行处理。
注意:用 root 解压或上传的文件,属主是 root,PHP-FPM 的 www 用户写不进去。这是「上传提示目录不可写」最常见的原因——改成 chown -R www:www uploads 即可。同时上传目录应关闭脚本执行(Nginx 里对该目录 location 禁止 .php),防止有人传个伪装图片当后门跑。
第五步:两个能直接抄的组合函数
// 递归算目录总大小
function dir_size(string $dir): int {
$total = 0;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
if ($f->isFile()) $total += $f->getSize();
}
return $total;
}
// 安全删除目录(先确认在允许范围内)
function rm_dir(string $dir, string $allowRoot): bool {
$real = realpath($dir);
if ($real === false || strpos($real, realpath($allowRoot)) !== 0) return false;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
$f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
}
return rmdir($real);
}
CHILD_FIRST 确保先删子项再删父目录,否则 rmdir 一定报「目录非空」。
小结
- 大文件一律
fopen + 分块/逐行,禁用 file_get_contents;输出用 readfile() 并先清空输出缓冲。
- 一层用
scandir/glob,递归用 RecursiveIteratorIterator,记得 SKIP_DOTS。
- 权限三要素:属主是运行用户、文件
0644 目录 0755、目录必须有执行位。
- 所有用户提供的路径先
realpath() 再校验前缀,防目录穿越。
- 上传目录「可写但不可执行」,这是安全底线。