前言
上传文件失败是最典型的"改了没生效"现场。症状一般有这几种:页面上$_FILES是空数组,接口返回 500 或者干脆没有任何报错;浏览器直接显示413 Request Entity Too Large;小文件能传、超过 2MB 就失败;或者本地开发一切正常,一部署到服务器就传不上去。
之所以难查,是因为一个上传请求要穿过四层限制,而它们分散在四个不同的配置文件里:PHP 的php.ini、PHP-FPM 的进程池配置、Web 服务器(Nginx 或 Apache)配置,以及应用自己的校验逻辑。任何一层卡住,症状都可能长得差不多。更麻烦的是"你改的那个 php.ini 不一定是生效的那个"——CLI 的 php.ini 和 FPM 用的 php.ini 常常是两个文件。
本文按"先定位生效配置、再逐个改指令、最后过 Web 服务器这一关"的顺序,把upload_max_filesize、post_max_size、memory_limit这几个指令该写在哪讲清楚。关于版本:PHP 8.5 本身没有改动这几个指令的默认值与语义,标题里的 8.5 指的是运行版本,8.x 各版本做法一致(具体若有调整以官方 UPGRADING 文档为准)。文中诊断脚本最低要求 PHP 8.0(用到match表达式,PHP 8.0 引入)。
一、第一步:找到当前真正生效的 php.ini
不要猜,直接问 PHP 自己。写一个where.php丢到 Web 目录,然后通过浏览器或 curl 访问它(关键点:要用 Web 方式访问,不能用命令行跑,否则看到的是 CLI 的配置):
<?php declare(strict_types=1); header('Content-Type: text/plain; charset=utf-8'); printf("SAPI: %s\n", PHP_SAPI); printf("PHP 版本: %s\n", PHP_VERSION); printf("加载的 php.ini: %s\n", php_ini_loaded_file() ?: '(没有加载任何 php.ini)'); printf("额外扫描的配置目录: %s\n", php_ini_scanned_files() ?: '(无)'); foreach ([ 'file_uploads', 'upload_max_filesize', 'post_max_size', 'memory_limit', 'max_execution_time', 'max_input_time', 'upload_tmp_dir', 'max_file_uploads', ] as $key) { printf("%-20s = %s\n", $key, var_export(ini_get($key), true)); }命令行侧对比着跑一遍:
php -i | grep -E 'Loaded Configuration File|upload_max_filesize|post_max_size'两边结果不一致就是"改了没生效"的头号原因:php --ini显示的多半是 CLI 用的php.ini,而 Web 请求走的是 FPM 的另一个文件,路径通常长这样:
| 运行方式 | 典型 php.ini 路径 |
|---|---|
| CLI | /etc/php/8.5/cli/php.ini |
| PHP-FPM | /etc/php/8.5/fpm/php.ini |
| FPM 进程池覆盖 | /etc/php/8.5/fpm/pool.d/www.conf |
| Apache + mod_php | 与 CLI 相同,或发行版单独指定 |
还有一个容易忽略的地方:.user.ini。在 CGI/FastCGI(含 FPM)模式下,PHP 会从脚本所在目录逐级向上查找.user.ini,用其中的设置覆盖主配置。它只对"每个目录可设"(PHP_INI_PERDIR)和"运行时可变"(PHP_INI_ALL)两种指令生效,而upload_max_filesize和post_max_size恰好都是 PERDIR,所以它们可以写在.user.ini里。虚拟主机用户没有 php.ini 写权限时,这是唯一的自救入口。
; 放在站点根目录,例如 /var/www/site/.user.ini upload_max_filesize = 64M post_max_size = 64M改完不必重启 FPM,但要等user_ini.cache_ttl(默认 300 秒)到点,或者重启一下 PHP-FPM 让它立刻生效。
二、第二步:五个必须一起改的指令
单独把upload_max_filesize调大是很多人踩过的第一坑——因为post_max_size会先卡住。它们之间的约束关系是:post_max_size必须大于等于upload_max_filesize,memory_limit又要能容纳处理过程中的数据。
| 指令 | 默认值 | 作用 | 能否用ini_set()运行时改 |
|---|---|---|---|
file_uploads | On | 总开关,关掉则完全不能上传 | 否(系统级) |
upload_max_filesize | 2M | 单个上传文件的大小上限 | 否(每目录级) |
post_max_size | 8M | 整个 POST 请求体的上限(含表单字段) | 否(每目录级) |
memory_limit | 128M | 单请求可用内存上限 | 是 |
max_execution_time | 30 | 脚本最长执行秒数 | 是 |
max_input_time | -1 | 解析输入数据的秒数上限 | 否(每目录级) |
"能否用ini_set()改"这一列是整篇文章的关键:upload_max_filesize和post_max_size是PHP_INI_PERDIR级别,意思是它们在请求启动、解析输入之前就已经确定了,脚本运行时的ini_set()调用必然失败(返回false,且不会有任何提示)。所以:
// 这行不会报错,但什么也不会发生 ini_set('upload_max_filesize', '64M'); var_dump(ini_get('upload_max_filesize')); // 仍然是 "2M"必须在 php.ini、.user.ini或 FPM 池配置里改。一份够用的配置:
; php.ini file_uploads = On upload_max_filesize = 64M post_max_size = 64M memory_limit = 256M max_execution_time = 120 max_input_time = 120 upload_tmp_dir = /var/tmp/php-uploads注意memory_limit要留够余量:PHP 处理上传时,文件先落到临时目录,之后如果你的代码用file_get_contents()把它整个读进内存,内存占用会接近文件大小;再加上post_max_size允许的所有字段,memory_limit至少要比post_max_size大一截。upload_tmp_dir指向的目录必须有写权限,否则会得到UPLOAD_ERR_CANT_WRITE。
如果用的是 PHP-FPM,更推荐把这几行写进进程池,因为php_admin_value的优先级高于 php.ini,且不允许被.htaccess或ini_set()覆盖:
; /etc/php/8.5/fpm/pool.d/www.conf php_admin_value[upload_max_filesize] = 64M php_admin_value[post_max_size] = 64M php_admin_value[memory_limit] = 256M php_admin_value[upload_tmp_dir] = /var/tmp/php-uploads改完重启:
sudo systemctl restart php8.5-fpm三、第三步:别漏了 Web 服务器这一层
即使 PHP 全部改对了,Web 服务器仍可能先一步把请求拦下来。这是"配置文件改了三处还是传不上去"的常见原因。
Nginx的client_max_body_size默认只有1m,超了直接回413,请求根本到不了 PHP:
server { listen 80; server_name example.test; root /var/www/site/public; client_max_body_size 64m; location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.5-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }Apache侧有两处:LimitRequestBody(单位是字节,默认通常为 0 即不限制,但托管环境常被收紧)和 PHP 相关的php_value。这里有个大坑要提前说:.htaccess里的php_value upload_max_filesize 64M只在 mod_php 模式下有效。如果 PHP 跑在 FPM/FastCGI 上,这行会直接导致500 Internal Server Error,错误日志里写着Invalid command 'php_value'——原本只想调大上传限制,结果整站挂掉。
# 仅在 PHP 作为 Apache 模块(mod_php)运行时可用 php_value upload_max_filesize 64M php_value post_max_size 64M用 FPM 时,.htaccess里不要写php_value,改走.user.ini或池配置。
四、实战:诊断 + 处理脚本
下面这个脚本放在 Web 目录,用法是:直接用 curl 上传,然后看输出定位卡在哪一层。
<?php declare(strict_types=1); // 最低要求:PHP 8.0 // 保存为 /var/www/site/public/upload.php header('Content-Type: text/plain; charset=utf-8'); /** 把 "64M" 这类 ini 简写解析成字节数;-1 表示无限制 */ function iniBytes(string $key): int { $value = trim((string) ini_get($key)); if ($value === '' || $value === '-1') { return -1; } $unit = strtolower(substr($value, -1)); $num = (int) $value; return match ($unit) { 'g' => $num * 1024 * 1024 * 1024, 'm' => $num * 1024 * 1024, 'k' => $num * 1024, default => $num, }; } $errorText = [ UPLOAD_ERR_OK => '成功', UPLOAD_ERR_INI_SIZE => '超过 upload_max_filesize', UPLOAD_ERR_FORM_SIZE => '超过表单里声明的 MAX_FILE_SIZE', UPLOAD_ERR_PARTIAL => '只上传了一部分', UPLOAD_ERR_NO_FILE => '没有选择文件', UPLOAD_ERR_NO_TMP_DIR => '缺少临时目录', UPLOAD_ERR_CANT_WRITE => '写临时文件失败(检查目录权限)', UPLOAD_ERR_EXTENSION => '被某个扩展中止', ]; echo "== 当前限制 ==\n"; foreach (['upload_max_filesize', 'post_max_size', 'memory_limit', 'file_uploads'] as $k) { printf("%-22s %s\n", $k, (string) ini_get($k)); } printf("%-22s %d 字节\n", 'post_max_size(换算)', iniBytes('post_max_size')); echo "\n== 请求情况 ==\n"; $declared = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); printf("CONTENT_LENGTH: %d 字节\n", $declared); if ($_FILES === [] && $_POST === [] && $declared > 0) { // 这是最隐蔽的一种:请求体超过 post_max_size 时, // PHP 会把 $_POST 和 $_FILES 直接清空,且没有任何提示 fwrite(STDERR, "POST 数据被整体丢弃:CONTENT_LENGTH 可能是超过了 post_max_size(当前 {$declared} > " . iniBytes('post_max_size') . ")\n"); echo "结论:请求体被 post_max_size 拦下了,$_POST / $_FILES 为空是正常表现\n"; exit(1); } if (!isset($_FILES['file'])) { echo "结论:没有收到名为 file 的上传字段\n"; exit(1); } $f = $_FILES['file']; $err = (int) $f['error']; printf("字段 file: error=%d(%s), size=%d 字节\n", $err, $errorText[$err] ?? '未知', (int) $f['size']); if ($err !== UPLOAD_ERR_OK) { echo "结论:PHP 层失败,错误含义见上\n"; exit(1); } if (!is_uploaded_file($f['tmp_name'])) { // 防止有人伪造 tmp_name 去读服务器上的任意文件 echo "结论:tmp_name 不是合法上传文件,拒绝处理\n"; exit(1); } $dest = __DIR__ . '/uploads/' . bin2hex(random_bytes(8)) . '.bin'; $move = move_uploaded_file($f['tmp_name'], $dest); printf("move_uploaded_file: %s -> %s\n", $move ? '成功' : '失败', $move ? $dest : '(未移动)');用 curl 触发,这样能排除浏览器表单里MAX_FILE_SIZE隐藏字段的干扰:
# 生成一个 10MB 的测试文件 head -c 10485760 /dev/urandom > /tmp/big.bin # 上传并观察响应 # 不写协议时 curl 默认按 http 处理,把 127.0.0.1 换成本地站点地址即可 curl -i -F "file=@/tmp/big.bin" 127.0.0.1/upload.php如果 curl 拿到的响应里完全没有 PHP 的输出、只有一行413,说明卡在 Nginx 层(client_max_body_size);如果 PHP 输出了post_max_size 拦下了,说明卡在post_max_size;如果$_FILES里error=1,说明卡在upload_max_filesize。这三条分支能覆盖绝大多数情况。
五、几十 GB 的备份文件怎么办
上面调参数的办法有上限:把upload_max_filesize设成2G以上并非不能改,但会带来三个现实问题——请求占用 FPM 进程的时间极长(进程池容易被占满)、网络抖动导致整个请求重传、memory_limit与超时都得跟着放大。
工程上的正解是分片上传(chunked upload):前端把文件切成 5~10MB 的片,每片单独发一个请求,服务端按uploadId + 分片序号落盘,全部到齐后再合并。upload_max_filesize只需覆盖单个分片的大小,超时和内存压力都回到可控范围。PHP 侧的核心就是"临时分片目录 + 顺序拼接",不需要额外的扩展。
常见坑点
1. 只改了upload_max_filesize
; ❌ post_max_size 默认 8M,56M 的文件依然传不上去 upload_max_filesize = 64M; ✅ 两个一起改,且 post_max_size 不小于 upload_max_filesize upload_max_filesize = 64M post_max_size = 64M2. 用ini_set()在脚本里改
// ❌ 这两个指令是每目录级(PERDIR),运行时改不了,ini_set 返回 false 且无提示 ini_set('upload_max_filesize', '64M'); ini_set('post_max_size', '64M');// ✅ 只对运行时可变(PHP_INI_ALL)的指令用 ini_set ini_set('memory_limit', '256M'); ini_set('max_execution_time', '120'); // 上传相关的必须落到 php.ini / .user.ini / FPM 池配置3. 直接比较ini_get()的返回值
// ❌ ini_get 返回的是 "64M" 这样的字符串,字符串比较会得出荒谬结论 if ($_FILES['file']['size'] > ini_get('upload_max_filesize')) { exit('文件太大'); }// ✅ 解析成字节再比(见上文的 iniBytes()) if ($_FILES['file']['size'] > iniBytes('upload_max_filesize')) { exit('文件太大'); }4. 请求体超过post_max_size时以为"没收到文件"
// ❌ $_FILES 和 $_POST 都是空的,据此报"你没有选择文件",把用户引向错误方向 if ($_FILES === []) { exit('请选择文件'); }// ✅ 用 CONTENT_LENGTH 区分"真的没传"和"被整体丢弃" if ($_FILES === [] && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { exit('上传内容超出服务器允许的请求体大小'); }5. 在 FPM 环境下用.htaccess写php_value
# ❌ PHP 跑在 FPM 时,Apache 不认识 php_value,整站 500 php_value upload_max_filesize 64M; ✅ 改用 .user.ini(同目录,CGI/FastCGI 模式生效) upload_max_filesize = 64M post_max_size = 64M6. 只改 PHP 不管 Nginx,收到无解释的 413
# ❌ client_max_body_size 默认 1m,大于 1MB 的请求直接被 Nginx 拒掉,PHP 一行日志都没有 location ~ \.php$ { fastcgi_pass unix:/run/php/php8.5-fpm.sock; }# ✅ 在 server 或 http 块里同步放大 client_max_body_size 64m;7. 临时目录不可写
// ❌ 症状是上传一个看似正常的文件却拿到 error=7(UPLOAD_ERR_CANT_WRITE) // 常见原因是 open_basedir 限制了 sys_get_temp_dir(),或 /tmp 被 noexec 挂载# ✅ 显式指定一个 PHP 进程有写权限的目录,并确认磁盘有空间 php_admin_value[upload_tmp_dir] = /var/tmp/php-uploads8. 把['tmp_name']直接当真文件用
// ❌ 请求结束时临时文件就被删了;而且 tmp_name 由客户端可控,可能指向任意路径 copy($_FILES['file']['tmp_name'], $target);// ✅ 先判 is_uploaded_file,再用 move_uploaded_file 在请求内完成搬运 if (is_uploaded_file($_FILES['file']['tmp_name'])) { move_uploaded_file($_FILES['file']['tmp_name'], $target); }总结
| 层次 | 配置位置 | 关键项 | 典型症状 |
|---|---|---|---|
| PHP 单文件上限 | php.ini /.user.ini/ FPM 池 | upload_max_filesize | $_FILES['file']['error'] === 1 |
| PHP 请求体上限 | 同上 | post_max_size | $_POST与$_FILES同时为空 |
| PHP 资源限制 | php.ini(可用ini_set) | memory_limit、max_execution_time | 内存耗尽或超时中断 |
| 临时目录 | php.ini / FPM 池 | upload_tmp_dir | error === 7 |
| Web 服务器 | nginx.conf / Apache 配置 | client_max_body_size、LimitRequestBody | 413,PHP 无任何日志 |
| 超大文件 | 应用层 | 分片上传 + 合并 | 单请求超时、重传成本高 |
一句话结论:上传限制不是一个配置项,而是一条链——Web 服务器 → PHP 请求体 → PHP 单文件 → 临时目录。排查时永远从"当前生效的 php.ini 是哪个文件"开始,用CONTENT_LENGTH和UPLOAD_ERR_*错误码把链条上的断点定位出来,比反复重启服务试参数快得多。至于ini_set(),记住它对upload_max_filesize和post_max_size是无效的。