PHP 文件系统操作:大文件处理 / 目录遍历 / 权限管理

wbcm
wbcm 见习用户见习用户
发布于 2026-10-02 20:15 ·66 浏览 ·2 回复

学完这篇,你能用 PHP 安全地读写几百 MB 的大文件、把任意目录树遍历干净、并把权限配到「能写但不能被当成脚本执行」——三者都是上传目录、附件目录最容易出事的地方。

第一步:先确认三个环境前提

在写任何文件代码前,先搞清楚这三件事,否则后面全是玄学问题:

  1. 内存上限:memory_limit,php -i | grep memory_limit 或 phpinfo() 里看。一次性读大文件超限就是在这里爆的。
  2. 执行时限:max_execution_time,Web 请求里默认常见 30 秒。处理大文件时脚本开头写 set_time_limit(0); 解除。
  3. 运行用户: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() 再校验前缀,防目录穿越。
  • 上传目录「可写但不可执行」,这是安全底线。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-681.html
转载请注明出处,版权归原作者所有。

全部回复 2

yipeng
yipeng 正式会员正式会员认证极客认证极客 1楼 2026-10-02 20:23

接着帖子里第三步往下补:目录遍历优先用 SPL 迭代器,权限管理的核心不是 chmod 而是「禁执行」。

目录遍历的三种正确姿势,别混用:

  • 单层、要完整条目 → scandir(),务必跳过 . 和 ..;
  • 递归整棵树 → RecursiveDirectoryIterator + RecursiveIteratorIterator,比手写递归稳,能顺手用 SKIP_DOTS 和 FilesystemIterator::UNIX_PATHS;
  • 只需要匹配特定后缀(比如清临时文件)→ glob() 或 glob('**/*', GLOB_BRACE) 足够。

手写递归最大的坑是符号链接死循环——目录里有个链回上层的 symlink,递归直接跑飞。用 is_link() 判断,或者 SPL 那边加 RecursiveIteratorIterator::CHILD_FIRST 配合自己过滤。另外任何路径都先过 realpath() 校验,防止 ../../etc 这类穿越。

权限管理部分,记住一句:上传目录要「能写」但绝不能「能被当脚本跑」。目录 755、文件 644,所有者是 PHP-FPM 运行用户(www/www-data),永远不要 777。更关键的是在 Web 服务器层直接断掉执行:

  • Nginx:location ~* ^/uploads/.*\.(php|phtml|phar)$ { deny all; },或者干脆给 uploads 单独一个 location 只做静态文件;
  • Apache:在该目录放 .htaccess 写 php_flag engine off(或 RemoveHandler .php)。

光靠上传白名单挡不住 .htaccess 被覆盖、或解析漏洞这类情况,所以「白名单 + 服务器层禁执行 + 目录不可解析」是三件套,缺一不可。

常见坑:file_get_contents 读大文件爆 memory_limit 时,报错信息常常是「Allowed memory size exhausted」,但真正元凶经常是 readfile 前的 ob_start 缓冲没清;下载大文件前那句 while (ob_get_level()) ob_end_clean(); 别忘了。

zero
zero 见习用户见习用户 #333 2楼 2026-10-02 20:32
yipeng:接着帖子里第三步往下补:**目录遍历优先用 SPL 迭代器,权限管理的核心不是 chmod 而是「禁执行」**。 **目录遍历的三种正确姿势**,别混用: -…

补充得很到位,尤其「禁执行比 chmod 更关键」这一点,很多人只盯着 777 却忘了服务器解析这一层。我顺着再加三个实际踩过的细节。

realpath 校验要防前缀绕过。 realpath() 对不存在的文件返回 false,所以必须在 fopen 之前校验,别在校验完再拼路径。而且不能简单 strpos($real, $base) === 0——/var/www/uploads-evil 会误判通过。正确做法是 base 先 realpath() 再用 DIRECTORY_SEPARATOR 结尾做前缀比较。

删目录树时 symlink 要先当文件处理。 RecursiveDirectoryIterator 默认不跟随链接(没加 FOLLOW_SYMLINKS),但 isDir() 对指向目录的 symlink 会返回 true,于是 rmdir 报错或删错。配合 CHILD_FIRST 时加一层:是链接就 unlink,别再往下走。

chmod 别写十进制。 chmod($f, 0644) 对,chmod($f, 644) 等于八进制 1204,权限直接乱掉;另外 PHP 进程的 umask 会削掉新建文件的位,需要固定权限就 chmod 显式设置,别指望 fopen 时给的 mode。

放回本社区的场景:Clara 的上传白名单和图片二次校验是 PHP 层兜底,服务器层禁执行就是第二道锁,两边都在才稳。真遇到上传失败,先看后台「系统设置→上传」里"允许的扩展名"和"附件允许的扩展名"这两个独立字段,再看 PHP 的 upload_max_filesize,最后确认 uploads 可写——页面会直接提示是哪一类。