在 PHP + Nginx 环境下支持 100MB~1GB 的大文件上传时,很多人会陷入一个误区:认为 PHP 必须拥有与文件大小相当的内存才能完成上传,于是不断调大 memory_limit,结果反而导致服务器内存耗尽。实际上,PHP 处理上传文件的机制并非如此。本文将从底层原理出发,分析 504、内存耗尽等问题的真正原因,并给出经过验证的配置方案和更优的分块上传实践。
一、PHP 处理大文件上传的底层机制
- 请求链路
当用户通过浏览器上传文件时,一个典型的请求链路如下:
浏览器
│ multipart/form-data(原始字节流)
▼
Nginx
│ FastCGI 协议转发
▼
PHP-FPM
│ 解析 POST 数据
▼
PHP 脚本($_FILES、move_uploaded_file)
关键在于:文件内容在 PHP 内核解析阶段就被写入临时文件,而不是读入 PHP 内存。
- PHP 对 multipart/form-data 的流式解析
对于 multipart/form-data 请求,PHP 内部的 rfc1867 解析器会:
· 逐块读取 POST 数据;
· 遇到文件字段时,在 upload_tmp_dir(或系统临时目录)创建一个临时文件;
· 将文件数据流式写入该临时文件;
· 内部缓冲区通常只有 8~64KB,不会随文件大小线性增长。
因此,上传一个 1GB 的文件,PHP 内核不会占用 1GB 内存,内存中只有:
· 内部缓冲区:几十 KB;
· $_POST 中的非文件字段;
· $_FILES 数组的元数据(文件名、大小、临时路径等);
· SAPI 层的一些请求结构。
- move_uploaded_file() 不读入内存
move_uploaded_file(tmp_name, dest) 本质上是在文件系统层面移动文件:
· 如果临时文件与目标目录在同一分区,它通常只是 rename();
· 如果跨分区,则会进行流式复制,但同样不会把整个文件读入 PHP 内存。
所以,简单地上传并移动文件,内存占用与文件大小没有直接关系。
- 为什么会出现内存耗尽?
如果上传阶段本身不会导致 Allowed memory size exhausted,那问题多半出在应用层后续处理:
· 使用 file_get_contents($file) 读取整个文件;
· 使用 imagecreatefromjpeg() 等 GD/Imagick 函数处理图片;
· 将文件内容 serialize 或 json_encode 存入数据库;
· 第三方库内部一次性读入文件。
这些操作才会让内存占用与文件大小成正比。因此,对于大文件,必须避免全量读入内存,改用流式/分块处理。
二、问题根源分析
- 504 Gateway Timeout 的可能原因
504 表示 Nginx 作为网关,在等待 PHP-FPM 响应时超时。结合你的配置,可能的原因有:
a. max_input_time 默认值被忽略
这是最常见的问题。PHP 的 max_input_time 默认是 60 秒,它限制的是解析输入数据(POST、GET、文件上传)的最长时间。
你只设置了 max_execution_time = 300,但 max_execution_time 只影响脚本执行阶段,不包括上传解析阶段。
当上传 1GB 文件耗时超过 60 秒时,PHP 会在解析阶段超时退出,导致 FastCGI 连接异常关闭,Nginx 返回 502/504。
b. fastcgi_read_timeout = 300 不够
fastcgi_read_timeout 是 Nginx 等待 FastCGI 后端响应的超时。如果 PHP-FPM 处理请求(包括上传解析 + 脚本执行)超过 300 秒,Nginx 就会返回 504。
c. request_terminate_timeout = 300 可能杀死进程
PHP-FPM 的 request_terminate_timeout 会终止执行时间超过指定秒数的请求。它从请求开始算起,包括上传解析时间。如果上传耗时超过 300 秒,PHP-FPM 会直接杀掉 worker 进程,同样导致 504。
d. Nginx 默认缓冲带来的延迟
默认情况下,Nginx 会先将整个请求体缓冲到磁盘(client_body_temp_path),再转发给 PHP-FPM。这虽然不占用 PHP 内存,但增加了整体延迟:
客户端上传完成 → Nginx 开始转发 → PHP-FPM 解析 → 脚本执行。
如果客户端上传速度很慢(例如 10Mbps 带宽上传 1GB 需要约 13 分钟),即使服务器处理很快,总时间也可能远超 300 秒。
e. 客户端或网络超时
浏览器或 CDN/代理也可能有自己的超时时间,但通常不是服务器 504 的主要原因。
- 内存占用上升的原因
PHP-FPM worker 进程在处理上传时,内存占用会有少量增加(内部缓冲区、请求结构等),但不会随文件大小线性增长。你观察到的"内存明显上升"可能是:
· 多个上传请求同时进行,每个进程占用少量内存,累计起来被观察到;
· PHP-FPM 动态模式下 pm.max_children 设置过高,空闲进程仍占用基础内存;
· 应用层在移动文件后进行了其他操作(如读入内存、调用扩展处理)。
- Allowed memory size exhausted 的来源
如前所述,上传本身不会因 memory_limit=256M 而耗尽。该错误通常来自:
· file_get_contents($file) 读取大文件;
· 图片处理扩展(GD/Imagick)解压大图;
· 在处理上传文件时,某些框架或库将文件内容读入内存。
三、是否一定要调大 memory_limit?
结论:不需要为了上传 1GB 文件而把 memory_limit 调到 1GB 以上。
memory_limit 限制的是 PHP 脚本可分配的堆内存,不包含上传文件占用的磁盘临时文件。正确配置下,上传 1GB 文件时,PHP 内存占用通常只有几 MB 到几十 MB。
你当前 memory_limit = 256M 已经足够。只有在以下场景才需要调整:
· 脚本确实需要将大文件读入内存处理(不推荐,应改为流式);
· 处理图片、视频等需要大量内存的操作;
· 使用某些框架或 ORM 导致大量对象分配。
对于这些场景,更好的做法是优化代码逻辑,采用流式处理,而不是盲目调大 memory_limit。否则多个并发请求可能导致服务器 OOM。
四、配置优化方案
- PHP 配置(php.ini 或 PHP-FPM 池配置)
ini
; 单个文件大小限制
upload_max_filesize = 1024M
; 整个 POST 请求大小,必须大于 upload_max_filesize
; multipart 请求包含 boundary、头部等额外开销
post_max_size = 1100M
; 脚本执行时间,仅影响执行阶段
max_execution_time = 600
; 关键:解析输入数据(包括上传)的最长时间,默认 60 秒
max_input_time = 600
; 内存限制保持 256M 即可
memory_limit = 256M
; 单次请求最多允许上传的文件数量
max_file_uploads = 20
; 上传临时目录,确保有足够空间
upload_tmp_dir = /data/php_upload_tmp
注意:
· post_max_size 必须大于 upload_max_filesize,否则上传接近限制的文件会失败;
· max_input_time 是你之前忽略的关键配置;
· upload_tmp_dir 所在分区应有足够空间(至少 1GB 以上),并且定期清理;
· 如果通过 PHP-FPM 池配置覆盖,使用 php_admin_value...,修改后需重启 PHP-FPM。
- PHP-FPM 配置
ini
pm = dynamic
pm.max_children = 30 ; 根据服务器内存调整
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500 ; 定期回收进程,防止内存泄漏
; 请求终止超时,包括上传解析时间
request_terminate_timeout = 600
request_slowlog_timeout = 10s
slowlog = /var/log/php-fpm/slow.log
内存估算:
你的服务器可用内存约 3.5GB。每个 PHP-FPM worker 基础内存约 30~80MB(取决于加载的扩展和框架),上传时略有增加。
如果设置 pm.max_children = 50,极端情况下 50 个进程可能占用 2.5~4GB,接近可用内存上限,容易 OOM。建议降低到 30 左右,预留足够内存给系统和其他服务。
- Nginx 配置
nginx
http {
# 客户端请求体大小限制,应 >= post_max_size
client_max_body_size 1100m;
# 读取客户端请求体超时
client_body_timeout 600s;
# 请求体缓冲区大小,超过则写入临时文件
client_body_buffer_size 1m;
# Nginx 请求体临时目录
client_body_temp_path /data/nginx_tmp 1 2;
# FastCGI 相关超时
fastcgi_read_timeout 600s; # 等待 PHP-FPM 响应
fastcgi_send_timeout 600s; # 向 PHP-FPM 发送请求体
# 关键:关闭 FastCGI 请求体缓冲
# 让 Nginx 边接收客户端上传边转发给 PHP-FPM,减少磁盘缓冲和延迟
fastcgi_request_buffering off;
}
说明:
· fastcgi_request_buffering off 是 Nginx 1.7+ 支持的指令。关闭后,Nginx 不再等待整个请求体接收完毕,而是立即转发给 PHP-FPM。这样可以减少 Nginx 磁盘临时文件占用,缩短链路时间。
· 如果使用该指令,确保 PHP-FPM 支持流式请求体(默认支持),并且 fastcgi_send_timeout 足够大。
· 如果关闭缓冲后遇到问题,可以恢复为 on,但需确保 client_body_temp_path 磁盘空间足够。
- 磁盘空间
大文件上传过程中,可能同时存在:
· Nginx 的请求体临时文件(如果 fastcgi_request_buffering on);
· PHP 的上传临时文件(upload_tmp_dir 中)。
因此,磁盘空间至少需要预留 2 倍于最大上传文件的大小(例如 1GB 上传需要 2GB 空闲空间)。
同时建议设置定时任务清理超过一定时间的临时文件:
bash
# 示例:清理 24 小时前的 PHP 上传临时文件
find /data/php_upload_tmp -type f -mtime +1 -delete
五、应用层优化:流式处理
- 直接移动文件
php
<?php
$file = $_FILES['upload'] ?? null;
if (!$file || !is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException('Invalid upload');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload error: ' . $file['error']);
}
$dest = '/data/uploads/' . bin2hex(random_bytes(16)) . '_' . basename($file['name']);
if (!move_uploaded_file($file['tmp_name'], $dest)) {
throw new RuntimeException('Failed to move uploaded file');
}
echo json_encode(['status' => 'ok', 'path' => $dest]);
- 使用流式函数校验文件
不要使用 file_get_contents 读取大文件。以下函数都是流式处理,内存占用低:
php
// 计算哈希
$hash = hash_file('sha256', $dest);
// 检测 MIME 类型
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $dest);
// 获取文件大小
$size = filesize($dest);
- 分块读取和处理
如果必须处理文件内容,使用 fopen 和 fread 逐块读取:
php
$handle = fopen($dest, 'rb');
while (!feof($handle)) {
$chunk = fread($handle, 1024 * 1024); // 1MB
// 处理 $chunk
}
fclose($handle);
六、分块上传:大文件上传的最佳实践
对于 100MB~1GB 的文件,即使调大各种超时,单次上传仍然存在风险:
· 网络波动导致整个请求失败;
· 服务器资源长时间占用;
· 用户体验差(没有进度条,无法断点续传)。
分块上传是目前大文件上传的主流方案。它的核心思想是:前端将文件切成小块(如 5MB),逐块上传,后端接收分块并最终合并。每个请求只处理几 MB,内存占用小,超时风险低,还能实现断点续传和并发上传。
- 前端实现思路
使用原生 JS 即可实现基本分块上传:
javascript
async function uploadFile(file) {
const chunkSize = 5 * 1024 * 1024; // 5MB
const totalChunks = Math.ceil(file.size / chunkSize);
// 1. 初始化上传,获取 upload_id
const initRes = await fetch('/upload/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_name: file.name,
file_size: file.size,
total_chunks: totalChunks
})
});
const { upload_id } = await initRes.json();
// 2. 并发上传分块
const concurrency = 3;
let index = 0;
async function worker() {
while (index < totalChunks) {
const i = index++;
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('upload_id', upload_id);
formData.append('chunk_index', i);
formData.append('chunk', chunk);
await fetch('/upload/chunk', {
method: 'POST',
body: formData
});
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
// 3. 合并分块
await fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upload_id })
});
}
成熟的前端库如 Uppy、Resumable.js、Plupload、WebUploader 等已经实现了分块、断点续传、并发控制等功能,可以直接使用。
- 后端接口设计
a. 初始化上传
创建上传会话,返回 upload_id,记录文件元数据。
php
<?php
// upload_init.php
$data = json_decode(file_get_contents('php://input'), true);
$uploadId = bin2hex(random_bytes(16));
$chunkDir = '/data/chunks/' . $uploadId;
mkdir($chunkDir, 0750, true);
file_put_contents($chunkDir . '/meta.json', json_encode([
'file_name' => basename($data['file_name']),
'file_size' => (int)$data['file_size'],
'total_chunks' => (int)$data['total_chunks']
]));
echo json_encode(['upload_id' => $uploadId]);
b. 上传分块
接收每个分块,保存为独立文件。此时每个分块很小,upload_max_filesize 只需略大于分块大小。
php
<?php
// upload_chunk.php
$uploadId = preg_replace('/[^a-f0-9]/', '', $_POST['upload_id'] ?? '');
$index = (int)($_POST['chunk_index'] ?? -1);
$chunkDir = '/data/chunks/' . $uploadId;
if (!is_dir($chunkDir)) {
http_response_code(404);
exit(json_encode(['error' => 'Upload not found']));
}
$file = $_FILES['chunk'] ?? null;
if (!$file || !is_uploaded_file($file['tmp_name'])) {
http_response_code(400);
exit(json_encode(['error' => 'Invalid chunk']));
}
$chunkPath = sprintf('%s/%06d', $chunkDir, $index);
if (!move_uploaded_file($file['tmp_name'], $chunkPath)) {
http_response_code(500);
exit(json_encode(['error' => 'Failed to save chunk']));
}
echo json_encode(['ok' => true, 'index' => $index]);
c. 合并分块
合并时使用 stream_copy_to_stream 流式复制,避免内存占用。
php
<?php
// upload_merge.php
set_time_limit(0); // 合并大文件可能需要一些时间
$data = json_decode(file_get_contents('php://input'), true);
$uploadId = preg_replace('/[^a-f0-9]/', '', $data['upload_id'] ?? '');
$chunkDir = '/data/chunks/' . $uploadId;
$metaFile = $chunkDir . '/meta.json';
if (!is_file($metaFile)) {
http_response_code(404);
exit(json_encode(['error' => 'Upload not found']));
}
$meta = json_decode(file_get_contents($metaFile), true);
$target = '/data/uploads/' . $meta['file_name'];
$fp = fopen($target, 'wb');
if (!$fp) {
http_response_code(500);
exit(json_encode(['error' => 'Cannot open target file']));
}
for ($i = 0; $i < $meta['total_chunks']; $i++) {
$chunkPath = sprintf('%s/%06d', $chunkDir, $i);
if (!is_file($chunkPath)) {
fclose($fp);
http_response_code(400);
exit(json_encode(['error' => "Missing chunk $i"]));
}
$chunk = fopen($chunkPath, 'rb');
stream_copy_to_stream($chunk, $fp);
fclose($chunk);
}
fclose($fp);
// 可选:校验文件哈希,成功后清理分块目录
array_map('unlink', glob($chunkDir . '/*'));
rmdir($chunkDir);
echo json_encode(['ok' => true, 'path' => $target]);
- 分块上传带来的配置简化
使用分块上传后,单次请求大小只需略大于分块大小(如 5MB),因此可以大幅降低服务器限制:
ini
upload_max_filesize = 20M
post_max_size = 25M
max_input_time = 60
max_execution_time = 60
memory_limit = 256M
Nginx 的 client_max_body_size 也可以设置为 25m。这样服务器攻击面更小,安全性更高。
七、监控与排查
- 确认配置生效
bash
php -i | grep -E 'upload_max_filesize|post_max_size|max_input_time|memory_limit'
如果使用 PHP-FPM,还可以在 PHP 脚本中查看:
php
phpinfo();
- 查看日志
· PHP-FPM slowlog:/var/log/php-fpm/slow.log,分析慢请求发生在哪个阶段;
· PHP-FPM error log:查找内存耗尽、输入超时等错误;
· Nginx error log:查找 504、upstream timed out 等关键词。
- 磁盘空间监控
上传过程中使用 df -h 监控 /data/php_upload_tmp 和 /data/nginx_tmp 所在分区,确保没有写满。
- 压力测试
使用 curl 模拟上传大文件,观察服务器行为:
bash
dd if=/dev/zero of=test_1g.bin bs=1M count=1000
curl -F "upload=@test_1g.bin" http://your-server/upload.php -v
注意:curl 本身会占用内存读取文件,测试时留意客户端资源。
八、总结
场景 单次大文件上传 分块上传
内存占用 低(流式写临时文件) 极低(每块 5MB)
超时风险 高,需调大超时 低,单块处理快
断点续传 不支持 支持
并发上传 难以控制 可并发多块
服务器安全 需开放大 body 限制 可限制小 body
用户体验 无进度条 可显示进度
关键点回顾:
- PHP 上传文件时不会将整个文件读入内存,memory_limit 不必等于文件大小;
- max_input_time 默认 60 秒是导致上传大文件 504 的常见原因,必须调大;
- post_max_size 要大于 upload_max_filesize;
- Nginx 的 fastcgi_read_timeout、client_body_timeout、fastcgi_send_timeout 需要足够大;
- 关闭 fastcgi_request_buffering 可以减少磁盘缓冲和延迟;
- 应用层避免 file_get_contents 全量读取大文件,使用流式函数;
- 对于 100MB~1GB 的文件,强烈建议使用分块上传,从根本上解决超时、内存、断点续传等问题。
按照以上方案调整后,单次上传大文件可以正常工作;如果业务允许引入前端分块上传,则能将服务器压力和用户体验提升到更优水平。