学完这篇,你能用 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()` 再校验前缀,防目录穿越。
- 上传目录「可写但不可执行」,这是安全底线。