PHP 大文件上传:内存占用、超时与最佳实践

在 PHP + Nginx 环境下支持 100MB~1GB 的大文件上传时,很多人会陷入一个误区:认为 PHP 必须拥有与文件大小相当的内存才能完成上传,于是不断调大 memory_limit,结果反而导致服务器内存耗尽。实际上,PHP 处理上传文件的机制并非如此。本文将从底层原理出发,分析 504、内存耗尽等问题的真正原因,并给出经过验证的配置方案和更优的分块上传实践。


一、PHP 处理大文件上传的底层机制

  1. 请求链路

当用户通过浏览器上传文件时,一个典型的请求链路如下:

复制代码
浏览器
  │  multipart/form-data(原始字节流)
  ▼
Nginx
  │  FastCGI 协议转发
  ▼
PHP-FPM
  │  解析 POST 数据
  ▼
PHP 脚本($_FILES、move_uploaded_file)

关键在于:文件内容在 PHP 内核解析阶段就被写入临时文件,而不是读入 PHP 内存。

  1. PHP 对 multipart/form-data 的流式解析

对于 multipart/form-data 请求,PHP 内部的 rfc1867 解析器会:

· 逐块读取 POST 数据;

· 遇到文件字段时,在 upload_tmp_dir(或系统临时目录)创建一个临时文件;

· 将文件数据流式写入该临时文件;

· 内部缓冲区通常只有 8~64KB,不会随文件大小线性增长。

因此,上传一个 1GB 的文件,PHP 内核不会占用 1GB 内存,内存中只有:

· 内部缓冲区:几十 KB;

· $_POST 中的非文件字段;

· $_FILES 数组的元数据(文件名、大小、临时路径等);

· SAPI 层的一些请求结构。

  1. move_uploaded_file() 不读入内存

move_uploaded_file(tmp_name, dest) 本质上是在文件系统层面移动文件:

· 如果临时文件与目标目录在同一分区,它通常只是 rename();

· 如果跨分区,则会进行流式复制,但同样不会把整个文件读入 PHP 内存。

所以,简单地上传并移动文件,内存占用与文件大小没有直接关系。

  1. 为什么会出现内存耗尽?

如果上传阶段本身不会导致 Allowed memory size exhausted,那问题多半出在应用层后续处理:

· 使用 file_get_contents($file) 读取整个文件;

· 使用 imagecreatefromjpeg() 等 GD/Imagick 函数处理图片;

· 将文件内容 serialize 或 json_encode 存入数据库;

· 第三方库内部一次性读入文件。

这些操作才会让内存占用与文件大小成正比。因此,对于大文件,必须避免全量读入内存,改用流式/分块处理。


二、问题根源分析

  1. 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 的主要原因。

  1. 内存占用上升的原因

PHP-FPM worker 进程在处理上传时,内存占用会有少量增加(内部缓冲区、请求结构等),但不会随文件大小线性增长。你观察到的"内存明显上升"可能是:

· 多个上传请求同时进行,每个进程占用少量内存,累计起来被观察到;

· PHP-FPM 动态模式下 pm.max_children 设置过高,空闲进程仍占用基础内存;

· 应用层在移动文件后进行了其他操作(如读入内存、调用扩展处理)。

  1. 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。


四、配置优化方案

  1. 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。

  1. 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 左右,预留足够内存给系统和其他服务。

  1. 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 磁盘空间足够。

  1. 磁盘空间

大文件上传过程中,可能同时存在:

· 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

五、应用层优化:流式处理

  1. 直接移动文件
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]);
  1. 使用流式函数校验文件

不要使用 file_get_contents 读取大文件。以下函数都是流式处理,内存占用低:

php 复制代码
// 计算哈希
$hash = hash_file('sha256', $dest);

// 检测 MIME 类型
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $dest);

// 获取文件大小
$size = filesize($dest);
  1. 分块读取和处理

如果必须处理文件内容,使用 fopen 和 fread 逐块读取:

php 复制代码
$handle = fopen($dest, 'rb');
while (!feof($handle)) {
    $chunk = fread($handle, 1024 * 1024); // 1MB
    // 处理 $chunk
}
fclose($handle);

六、分块上传:大文件上传的最佳实践

对于 100MB~1GB 的文件,即使调大各种超时,单次上传仍然存在风险:

· 网络波动导致整个请求失败;

· 服务器资源长时间占用;

· 用户体验差(没有进度条,无法断点续传)。

分块上传是目前大文件上传的主流方案。它的核心思想是:前端将文件切成小块(如 5MB),逐块上传,后端接收分块并最终合并。每个请求只处理几 MB,内存占用小,超时风险低,还能实现断点续传和并发上传。

  1. 前端实现思路

使用原生 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 等已经实现了分块、断点续传、并发控制等功能,可以直接使用。

  1. 后端接口设计

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]);
  1. 分块上传带来的配置简化

使用分块上传后,单次请求大小只需略大于分块大小(如 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。这样服务器攻击面更小,安全性更高。


七、监控与排查

  1. 确认配置生效
bash 复制代码
php -i | grep -E 'upload_max_filesize|post_max_size|max_input_time|memory_limit'

如果使用 PHP-FPM,还可以在 PHP 脚本中查看:

php 复制代码
phpinfo();
  1. 查看日志

· PHP-FPM slowlog:/var/log/php-fpm/slow.log,分析慢请求发生在哪个阶段;

· PHP-FPM error log:查找内存耗尽、输入超时等错误;

· Nginx error log:查找 504、upstream timed out 等关键词。

  1. 磁盘空间监控

上传过程中使用 df -h 监控 /data/php_upload_tmp 和 /data/nginx_tmp 所在分区,确保没有写满。

  1. 压力测试

使用 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

用户体验 无进度条 可显示进度

关键点回顾:

  1. PHP 上传文件时不会将整个文件读入内存,memory_limit 不必等于文件大小;
  2. max_input_time 默认 60 秒是导致上传大文件 504 的常见原因,必须调大;
  3. post_max_size 要大于 upload_max_filesize;
  4. Nginx 的 fastcgi_read_timeout、client_body_timeout、fastcgi_send_timeout 需要足够大;
  5. 关闭 fastcgi_request_buffering 可以减少磁盘缓冲和延迟;
  6. 应用层避免 file_get_contents 全量读取大文件,使用流式函数;
  7. 对于 100MB~1GB 的文件,强烈建议使用分块上传,从根本上解决超时、内存、断点续传等问题。

按照以上方案调整后,单次上传大文件可以正常工作;如果业务允许引入前端分块上传,则能将服务器压力和用户体验提升到更优水平。

相关推荐
国际云,接待2 小时前
云服务器 SSH 被扫爆了怎么办:安全组、密钥登录与 Fail2ban 的完整加固清单
服务器·安全·ssh
m0_527034332 小时前
异步任务审核系统设计:消息队列、超时重试与失败补偿
java·大数据·开发语言
闲云野鹤在人间2 小时前
项目实战:LNMP-电商平台-ECshop
linux·运维·nginx·apache·lvs
吹什么轩2 小时前
linux网络:UDP套接字的业务实现:字典
linux·运维·udp
迷途之人不知返2 小时前
gcc编译器,以及源文件的翻译
linux
一朵好运莲2 小时前
智能体使用 Chrome DevTools MCP 调试浏览器
开发语言·javascript·react.js
秋田君2 小时前
QT_JSON文件操作
开发语言·qt·json
老赵的博客2 小时前
可维护性 可扩展性 可复用性
开发语言·c++
sibylyue3 小时前
工作流表单和流程设计前端
开发语言·javascript·开源