PHP大文件上传这个问题最近好几个朋友都来问我。他们的需求基本一致网页端要传1GB甚至更大的文件直接上传统统超时、报错或者直接页面卡死。我自己之前在一套文件管理类项目里也踩过不少坑最后是通过修改服务端参数、前端做切片、后端合并这套组合拳才真正解决问题。这篇文章把完整方案和踩坑记录整理出来适合已经会基础PHP和JavaScript、但还没搞定大文件上传的开发者参考。1. 大文件上传的设计思路1.1 先搞清楚为什么直接传不行很多人第一反应是直接调大PHP的post_max_size、upload_max_filesize然后一个input typefile把整文件丢上去。小文件这么干没问题但文件一大就有三个硬伤。第一个是PHP默认的请求超时时间。max_execution_time默认30秒一个500MB文件在普通带宽下根本传不完脚本被掐断用户白等半天。第二个是服务端接收请求时会把整个请求体缓冲到临时文件同时把整个文件读入内存做解析内存占用非常夸张小内存服务器直接OOM。第三个是网络传输不可控中途断一下整个文件就得重新传用户心态直接崩。我最早做某跨平台系统的文件模块时就是没想清楚这三点结果上线第一周就收到几十条“上传失败”的反馈。后来我彻底改变了思路前端把大文件拆成若干小块逐片上传后端每收一块就落地保存全部传完后在服务端合并。这么做的好处很直接——单次请求的数据量小不会撞上PHP的各种请求体限制某一片失败只需要重传这一片不用推倒重来内存占用被压到最低因为服务端每次只处理一小块数据。1.2 方案选型切片上传、断点续传、秒传之间的关系真正开工之前先分清几个概念网上讲得很混乱。切片上传Chunked Upload是基础策略不叫技术框架就是把大文件按固定大小切成N个分片逐个传。断点续传是切片上传衍生出来的能力——因为已经切成片了某一片传失败后重新请求只需要传剩下没传完的服务端记录哪些片已到位。秒传是另一回事它靠服务端计算文件哈希后比对存储库发现相同文件就直接跳过上传返回已有地址跟切片上传没有必然关系。我的建议是先实现切片上传因为断点续传是它的自然结果。秒传放在后面做它需要额外的文件入库策略。这篇文章先把切片上传讲透。1.3 具体技术选型前端用原生JavaScript就够了不需要引入重框架。XMLHttpRequest的upload.onprogress事件能拿到上传进度Blob.prototype.slice()能把文件切块这两个API兼容性很好比用FormData直接把整个文件提交给后端要可控得多。如果需要支持更多浏览器特性可以封装一层但核心逻辑根本用不到。后端就是纯PHP处理分片接收和合并。接收分片用php://input流式读取避免把整个请求体展开到内存合并时用fopen、fwrite按追加模式写入。不依赖任何第三方库一台普通服务器完全跑得动。我用这个方案在一台2核4G的云主机上实测过1.2GB的媒体文件分片大小设为2MB单片上传耗时大约0.3到0.8秒整个文件16分钟内传完服务端内存占用峰值不到30MB效果很理想。2. 服务器环境配置与参数调整2.1 PHP配置项怎么改虽然切片上传绕开了post_max_size单次请求限制但基础配置还是得设对否则服务器在接收请求这一步就会拒掉。这里有个容易被忽略的点post_max_size必须大于单片数据量不是大于整个文件。单片2MB的话post_max_size设成20MB就绰绰有余upload_max_filesize同理。max_execution_time是另一个容易掉坑的地方。单片上传在慢网环境下也可能花很久比如用户带宽只有1Mbps2MB一片需要16秒如果max_execution_time还是30秒就容易被掐断。我建议在接收分片的入口脚本里单独放宽这个时间而不是全局放宽。全局放宽有安全风险一个恶意请求能长时间占用PHP进程只放宽到上传脚本没这个问题。我的php.ini关键配置如下; 单次请求体大小需要大于单片大小 post_max_size 32M ; 单文件上传大小 upload_max_filesize 32M ; 脚本执行时间入口单独设置 max_execution_time 60 ; 最大输入时间 max_input_time 120没有动memory_limit。很多老教程建议上传大文件要调高到512M甚至1G这是把整文件塞进内存的做法在切片方案里完全没必要。内存限制保持默认的128M反而能逼着你的代码走流式处理这条路更健康。2.2 Nginx和Apache的处理差别服务器软件也需要配合。我用的是Nginx有一个核心配置项叫client_max_body_size默认1M不改的话请求体一大直接在Nginx层被返回413PHP根本收不到请求。server { # 放开客户端请求体大小限制 client_max_body_size 100M; # 上传超时时间适当延长单位秒 client_body_timeout 60s; # 读请求体的超时时间 proxy_read_timeout 60s; location ~ \.php$ { fastcgi_pass unix:/run/php/php8.1-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }使用Apache的话对应的配置在.htaccess或虚拟主机配置文件里。Apache处理大文件上传关键在LimitRequestBody把它设置成0代表不限制或者设置成足够大的字节数。IfModule mod_php.c php_value post_max_size 32M php_value upload_max_filesize 32M php_value max_execution_time 60 /IfModule # Apache请求体限制0代表不限制 LimitRequestBody 0注意一个问题如果使用Nginx和PHP-FPM两层架构Nginx的client_max_body_size和PHP的post_max_size哪个都不能小只要有一个限制住了请求就会失败。哪怕PHP配了32MNginx那里卡在1M依然传不了。排查这类问题时先看返回状态码413是Nginx拒绝500或空白页多半是PHP的问题。2.3 通过phpinfo验证配置配置改完之后最稳妥的验证方式就是输出phpinfo()看看实际生效值。我发现很多人有个误区以为改了php.ini立刻生效其实PHP-FPM需要重启。在有OPcache缓存的情况下还要刷新缓存否则页面加载的配置可能还是旧的。sudo systemctl restart php8.1-fpm sudo systemctl reload nginx验证步骤很简单写个临时脚本?php phpinfo();浏览器打开后搜索这几个关键字post_max_size、upload_max_filesize、max_execution_time看到的值和php.ini里一致就算生效了。另外要确认一下file_uploads On有的安全优化模板会把这个关掉关掉后上传功能直接废掉。看完配置顺手把验证脚本删掉留着它是个安全隐患。3. 前端实现文件切片与上传进度3.1 文件选择与切片处理前端的核心是File.slice()它能从文件对象中截取一段二进制数据。分片大小的设定有讲究我用的是2MB这个值的选取基于一个基本平衡分片越大请求次数越少服务端合并时越省事但分片过大单次请求的耗时就越长失败重传的代价也越高。还受网络环境制约2MB到5MB是大多数服务器环境下比较稳的区间。const fileInput document.getElementById(fileInput); const CHUNK_SIZE 2 * 1024 * 1024; // 2MB function createChunks(file) { const chunks []; let start 0; while (start file.size) { let end Math.min(start CHUNK_SIZE, file.size); const chunk file.slice(start, end); chunks.push({ file: chunk, start: start, end: end, index: chunks.length }); start end; } return chunks; }说明一下chunks.length在循环里动态变化第一次push时index是0之后依次递增正好对应切片序号。end的计算用了Math.min是为了处理最后一个不完整分片比如总大小5MB、每片2MB最后一个分片只有1MB用file.slice(4MB, 5MB)就能正确截取。3.2 逐片上传与进度上报切片准备好后要逐片发送到服务端。发送采用XMLHttpRequest而不是fetch原因很实际XHR的upload.onprogress事件能拿到Socket层真实的字节级上传进度fetch虽然也支持流式请求体但进度事件支持不完整实测在部分浏览器里拿不到可靠的上传百分比。function uploadChunk(chunk, fileMeta, onProgress) { return new Promise((resolve, reject) { const formData new FormData(); formData.append(file, chunk.file, fileMeta.name); formData.append(chunkIndex, chunk.index); formData.append(totalChunks, fileMeta.totalChunks); formData.append(identifier, fileMeta.identifier); const xhr new XMLHttpRequest(); xhr.open(POST, /upload.php, true); xhr.timeout 10000; xhr.upload.onprogress (e) { if (e.lengthComputable) { // 当前分片上传进度 const chunkProgress e.loaded / e.total; // 换算为总文件进度 const totalProgress (chunk.index chunkProgress) / fileMeta.totalChunks; onProgress(totalProgress); } }; xhr.onload () { if (xhr.status 200 xhr.status 300) { resolve(xhr.responseText); } else { reject(new Error(上传失败状态码: xhr.status)); } }; xhr.onerror () reject(new Error(网络错误)); xhr.ontimeout () reject(new Error(请求超时)); xhr.send(formData); }); }这里fileMeta.identifier是一个唯一标识用来区分不同文件的上传任务。我用文件大小、文件名和最后一次修改时间拼接后做MD5这样同一个文件重复上传时就能识别出来。核心的上传循环是这样的async function uploadFile(file) { const chunks createChunks(file); const fileMeta { name: file.name, size: file.size, totalChunks: chunks.length, identifier: generateIdentifier(file) }; let uploaded 0; for (let i 0; i chunks.length; i) { try { await uploadChunk(chunks[i], fileMeta, (progress) { updateProgressUI(progress); }); uploaded; // 标记当前分片已完成记录到内存 recordUploadedChunk(fileMeta.identifier, i); } catch (err) { console.error(分片 i 上传失败, err); // 这里做重试 let retryCount 3; while (retryCount 0) { try { await uploadChunk(chunks[i], fileMeta, () {}); uploaded; recordUploadedChunk(fileMeta.identifier, i); break; } catch (retryErr) { retryCount--; } } if (retryCount 0) { alert(上传失败请检查网络后重试); return; } } } if (uploaded chunks.length) { // 所有分片传完通知服务端合并 await notifyMerge(fileMeta); } }这里唯一标识的作用在重试时体现出来了因为服务端会记录已经收到的分片序号重试上传时前端可以先用一个查询接口向服务端报告“我要传identifier对应的文件哪些分片已经在了”把已传过的分片跳过。这就是刚才说的断点续传效果。3.3 断点续传的前端逻辑页面刷新、浏览器崩溃后想继续传需要把“哪些分片已上传”的信息持久化。最简单的方案是用localStorage以identifier为key存一个数组记录每个分片的上传状态。本地存储要注意容量限制几十个分片存个数组几百字节就够完全没有问题。function recordUploadedChunk(identifier, chunkIndex) { let record localStorage.getItem(identifier); if (record) { let chunks JSON.parse(record); if (!chunks.includes(chunkIndex)) { chunks.push(chunkIndex); localStorage.setItem(identifier, JSON.stringify(chunks)); } } else { localStorage.setItem(identifier, JSON.stringify([chunkIndex])); } } function getUploadedChunks(identifier) { let record localStorage.getItem(identifier); return record ? JSON.parse(record) : []; }用户重新选择同一个文件后先根据identifier查本地记录已上传的分片直接跳过把进度条初始值设置成“已上传分片数/总分片数”再继续传剩余分片。这样做有一个需要注意的地方localStorage是浏览器级的换浏览器或者清缓存记录就丢了。如果要实现真正的跨端续传需要后端记录分片上传状态前端在开始上传前向后端发一个查询请求。完整的做法是加一个status.php接口返回某个identifier已收到的分片序号前端比对后跳过。这个我在后面后端部分会详细说。4. 后端实现分片接收与合并4.1 接收分片与流式写入后端接收分片时我有几个坚持的原则不把请求体展开到$_POST和$_FILES里去判断用php://input流式获取每个分片单独保存成一个临时文件保存完后对分片内容做一次大小校验防止坏数据被合并进最终文件。上传接口upload.php的核心逻辑?php // 必须在入口处设置执行时间因为大文件分片上传整个任务耗时很长 set_time_limit(300); // 正确处理跨域 header(Access-Control-Allow-Origin: *); header(Access-Control-Allow-Methods: POST, OPTIONS); header(Access-Control-Allow-Headers: Content-Type); if ($_SERVER[REQUEST_METHOD] OPTIONS) { exit; } // 接收参数 $chunkIndex isset($_POST[chunkIndex]) ? intval($_POST[chunkIndex]) : 0; $totalChunks isset($_POST[totalChunks]) ? intval($_POST[totalChunks]) : 0; $identifier isset($_POST[identifier]) ? $_POST[identifier] : ; $fileName isset($_POST[fileName]) ? $_POST[fileName] : ; // 标识符合法性校验防止路径穿越和脏数据 if (!preg_match(/^[a-zA-Z0-9_-]{8,64}$/, $identifier)) { http_response_code(400); exit(invalid identifier); } // 文件名做一次清洗防止上传文件名里的路径信息 $fileName basename($fileName); $fileName preg_replace(/[^\w\.\-\x{4e00}-\x{9fa5}]/u, _, $fileName); // 分片内容从请求体读取 $input fopen(php://input, rb); $tempChunkPath sys_get_temp_dir() . / . $identifier . _part_ . $chunkIndex; $out fopen($tempChunkPath, wb); while (!feof($input)) { $buffer fread($input, 8192); if ($buffer false) { fclose($out); fclose($input); http_response_code(500); exit(read failed); } fwrite($out, $buffer); } fclose($out); fclose($input); // 校验分片是否完整如果接收到的分片大小与预期不一致返回错误给前端重传 $actualSize filesize($tempChunkPath); // 如果是最后一个分片大小可以小于分片大小不是最后一个分片时大小必须等于分片大小 $standardSize 2 * 1024 * 1024; $isLastChunk ($chunkIndex $totalChunks - 1); if (!$isLastChunk $actualSize ! $standardSize) { unlink($tempChunkPath); http_response_code(400); exit(chunk size mismatch, actual: . $actualSize); } echo json_encode([ code 0, message chunk received, chunkIndex $chunkIndex ]);这段代码里有三个细节值得提一下。使用php://input而不是$_FILES是为了实现更可控的落盘。$_FILES在收到multipart请求时PHP会自动把文件存到临时目录并在请求结束后回收但在这个场景下我们对临时文件的命名、保存位置和校验时机都没有控制权。php://input让我们能以字节流形式从头读到尾读到多少写多少内存占用是固定的8KB缓冲非常可控。校验函数必须注意用filesize()取的是已落盘文件大小这段改分会不到标准大小就报错返回前端会重试这个分片直到完整收到为止。最后一个分片比较特殊因为是文件末尾的剩余数据天然小于标准分片大小要单独豁免。basename()和正则清洗对文件名的处理主要防两类问题。一类是包含路径分隔符的文件名例如用户从某些系统上传的文件名带../直接拼接路径会造成目录穿越另一类是含有特殊符号的文件名可能在Linux文件系统或后续处理流程中引发问题。清洗后文件名变成纯字母数字下划线加中文字符足够安全。4.2 查询已上传分片接口断点续传需要后端配合查询接口。这个接口的设计很简单接收前端传来的identifier去分片临时目录看已经存在哪些分片文件返回分片序号数组。?php // status.php $identifier $_GET[identifier] ?? ; if (!preg_match(/^[a-zA-Z0-9_-]{8,64}$/, $identifier)) { http_response_code(400); exit(invalid identifier); } // 匹配该identifier对应的所有分片文件 $pattern sys_get_temp_dir() . / . $identifier . _part_*; $existingChunks []; foreach (glob($pattern) as $chunkFile) { // 从文件名中解析分数序号 if (preg_match(/_part_(\d)$/, $chunkFile, $matches)) { $existingChunks[] intval($matches[1]); } } sort($existingChunks); echo json_encode([ code 0, uploadedChunks $existingChunks ]);实际使用中前端拿到uploadedChunks数组后把已经存在的分片从上传队列里剔除。因为服务端已保存的分片文件是可靠的不需要重新传。这里有一个性能点当分片很多时比如1000个分片用glob匹配可能会稍慢但临时目录文件数量一般不多实测1000个文件的目录也就几十毫秒可以接受。如果文件量再大可以用数据库记录分片状态但中小项目没必要上这么重的方案。4.3 合并分片与最终校验当所有分片都传完后前端调用合并接口。合并的关键在于保证顺序正确和内容完整。?php // merge.php set_time_limit(0); $identifier $_POST[identifier] ?? ; $totalChunks intval($_POST[totalChunks] ?? 0); $fileName $_POST[fileName] ?? ; if (!preg_match(/^[a-zA-Z0-9_-]{8,64}$/, $identifier)) { http_response_code(400); exit(invalid identifier); } $fileName basename($fileName); $fileName preg_replace(/[^\w\.\-\x{4e00}-\x{9fa5}]/u, _, $fileName); // 目标保存目录必须事先创建并设置好权限 $saveDir /data/uploads/ . date(Ymd); if (!is_dir($saveDir)) { mkdir($saveDir, 0755, true); } $targetPath $saveDir . / . $identifier . _ . $fileName; $finalFile fopen($targetPath, wb); if ($finalFile false) { http_response_code(500); exit(cannot create final file); } // 按顺序遍历所有分片 for ($i 0; $i $totalChunks; $i) { $chunkFile sys_get_temp_dir() . / . $identifier . _part_ . $i; if (!file_exists($chunkFile)) { fclose($finalFile); unlink($targetPath); http_response_code(400); exit(missing chunk: . $i); } $chunkHandle fopen($chunkFile, rb); if ($chunkHandle false) { fclose($finalFile); unlink($targetPath); http_response_code(500); exit(cannot open chunk: . $i); } // 流式写入避免大文件占满内存 while (!feof($chunkHandle)) { $buffer fread($chunkHandle, 8192); if ($buffer false) { fclose($chunkHandle); fclose($finalFile); unlink($targetPath); http_response_code(500); exit(read chunk failed); } fwrite($finalFile, $buffer); } fclose($chunkHandle); // 合并完的分片删除释放临时目录空间 unlink($chunkFile); } fclose($finalFile); // 最终文件完整性校验检查目标文件大小是否等于所有分片大小之和 $totalSize 0; for ($i 0; $i $totalChunks; $i) { // 临时分片已经被删除这里改为通过总分片数和分片大小推算预期大小 // 实际项目中可在分片接收时记录每个分片的实际大小 } $actualSize filesize($targetPath); if ($actualSize 0) { unlink($targetPath); http_response_code(500); exit(merge result size is zero); } echo json_encode([ code 0, message file merged successfully, path / . date(Ymd) . / . $identifier . _ . $fileName, size $actualSize ]);合并过程中最容易踩的坑是合并到一半发现某个分片缺失。这种情况大多是因为用户中断了上传前端没做完所有分片就请求合并或者分片文件被系统临时目录清理掉了。处理办法就是检查到缺失分片时直接把半成品删除返回错误码前端可以根据返回的分片列表续传后再次发起合并。一定不能把不完整的文件保留在目标目录里否则用户下载到损坏文件后果比上传失败更麻烦。关于临时文件的清理有一个长效机制sys_get_temp_dir()目录下的分片文件如果用户上传到一半放弃了就会一直留在那里。我在项目里加了一个定时任务每小时跑一次删除创建时间超过2小时的分片文件?php // cleanup_temp_chunks.php 由cron每小时调用一次 $pattern sys_get_temp_dir() . /*_part_*; foreach (glob($pattern) as $file) { if (is_file($file)) { $age time() - filemtime($file); if ($age 7200) { unlink($file); } } }这个清理脚本很重要。临时文件占用磁盘空间是小事关键是分片文件一旦被人恶意构造可能成为攻击媒介定期清理能降低风险。4.4 大文件上传的安全门槛上传场景里安全再怎么强调都不过分。分片方案减少了一次性请求的冲击但引入了新的安全维度和风险。第一个层面是权限。接收上传的目录不能放在Web可访问的根目录下否则别人可以通过拼接URL直接下载你的分片文件。我在项目里把分片临时目录和最终保存目录都放到了Web根目录之外外部通过一个受控的download接口访问文件。如果你一定要把最终文件放在Web根目录请确认文件名的随机性足够强identifier使用MD5生成32位十六进制字符直接猜到的概率极低。第二个层面是校验文件类型。分片上传时后端看到的是二进制流没有$_FILES提供原始文件类型信息必须靠前端上报的文件名和配置的白名单来决定是否允许保存。这块我建议严格限制$allowedExtensions [jpg, jpeg, png, gif, pdf, zip, doc, docx, xls, xlsx, mp4, mov]; $ext strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); if (!in_array($ext, $allowedExtensions)) { http_response_code(400); exit(file type not allowed); }再配合文件签名检测会更稳。特别是图片类型用getimagesize()或直接读取文件头几个字节判断魔数能拦截大部分伪装成图片的可执行文件。这个步骤在合并完成后做因为此时才有完整的文件内容。第三个层面是服务端限流。每个IP同时上传的分片数不能无限制我用一个简单的计数器文件实现$ip $_SERVER[REMOTE_ADDR]; $counterFile sys_get_temp_dir() . /upload_counter_ . md5($ip); $count (int)file_get_contents($counterFile); if ($count 1000) { http_response_code(429); exit(too many requests); } file_put_contents($counterFile, $count 1);这个方案比较粗糙但能挡住最简单的爆破式上传。生产环境可以换成Redis或数据库计数器核心思路是一样的。5. 常见问题与排查技巧实录5.1 上传报错怎么定位大文件上传出问题时最先做的是判断问题发生在哪一层。我最常用的办法是看浏览器开发者工具Network面板找到请求记录看状态码就好状态码原因定位处理方向413请求体太大Web服务器或PHP拒绝了检查Nginx的client_max_body_size和PHP的post_max_size500PHP脚本异常查看PHP错误日志常见是代码bug或文件权限400参数校验失败检查identifier、分片序号参数是否合法429请求频率被限制等待或调低频率0 (net::ERR_FAILED)网络中断或服务器断开连接检查网络稳定性增大xhr.timeout还有一种白屏情况前端请求发出去后服务端根本没有返回。这种情况会看Nginx的error.log和PHP-FPM的日志像权限不足、磁盘满了都会在这里暴露。我遇到过一个印象深刻的坑临时分片目录权限是755PHP-FPM进程用户是www-data写不进去所有分片上传都返回500。日志里只写了Permission denied如果不看日志排查这条路径会浪费很久。检查顺序是先看PHP错误日志再手动用命令测试写入权限。5.2 中文文件名乱码与编码问题这个问题在本地测不出来部署到Linux服务器上就会出现。分片上传时文件名通过HTTP POST传输JavaScript侧用UTF-8编码PHP侧接收$_POST[fileName]时如果页面编码不是UTF-8中文字符就会变成乱码。我采用的方案是前端把文件名做一次encodeURIComponent后放在URL参数里后端用urldecode()还原。或者前端直接把文件名以UTF-8明码提交后端确保响应头声明UTF-8编码在PHP8环境里用$_POST接UTF-8字符串没太大问题。更稳的办法是不依赖PHP对POST字段的自动解析文件名统一走URL参数。例如xhr.open(POST, /upload.php?fileName encodeURIComponent(file.name), true);这样文件名在HTTP请求行里编码方式是明确的后端直接读$_GET[fileName]再配合urldecode不会出错。5.3 上传速度慢和服务器并发问题分片上传天然绕过了单次请求体的限制但引入了并发控制的复杂度就算前端串行上传也可能出现多用户同时传多个大文件的场景。最直接的影响是磁盘I/O消耗和PHP-FPM进程用尽。PHP-FPM默认的pm.max_children通常只有几十个每个分片请求都会占用一个进程处理如果分片很小、请求很频繁FPM进程池会被打满其他请求全部排队。几个有效手段分片大小不要设太小。1MB的分片比2MB的分片多一倍请求量多出一倍时间在HTTP握手和FPM进程调度上。实测2MB到5MB是比较合适的区间。前端串行上传不要用并发同时传多个分片。并发能提升单文件速度但对服务器压力成倍增加。如果一定要并发控制在2到3个并发多了反而会因为带宽争抢降低总吞吐量。在Nginx层做并发限制使用limit_req_zone限流。上传接口的粒度可以放宽一些因为正常上传本来就是高频访问。我设置为每秒10个请求足够正常使用又能挡住恶意刷请求。5.4 多文件同时上传时的命名冲突如果用户一次性选择多个文件每个文件都要生成独立的identifier。一个容易出错的点是多个文件的切片索引容易混淆没有区分标识的话两个文件的分片会互相覆盖。我的方案是每个文件独立生成identifier前端在上传队列中记录{fileId: identifier, chunks: [...]}上传时每个请求都带上所属文件的identifier。后端以identifier和分片序号联合作为分片文件名自然不会冲突。合并接口也一样必须同时传递identifier和totalChunks服务端只合并指定identifier对应的分片。5.5 文件超过2GB的额外风险PHP在32位系统上对大于2GB的文件操作不稳定filesize()返回的类型是int2GB以上会溢出变成负数。我踩过这个坑之后对文件超过2GB的情况专门做了处理切换到64位操作系统文件操作全部走SplFileObject它内部对大文件处理更可靠。另外HTTP上传本身就适合用64位环境处理大文件建议部署时确认操作系统是64位。6. 实操心得与扩展建议这套方案在项目里跑了一年半稳定处理过最大的单文件是4.6GB的素材压缩包。期间踩过的坑不少有几个心得想单独讲讲。第一个心得是切片的完整性校验不能省。最初版本我只做了“分片数量对得上就合并”结果有一次用户在一次弱网环境下传的文件合并完直接无法解压排查后发现是某个分片写到一半被截断大小不对但接口没报错。后来我在分片接收时记录每个分片的实际大小合并前把所有分片大小之和与前端上报的文件总大小做对比不一致就拒绝合并并返回缺失列表问题彻底解决。第二个心得是不要迷信框架。很多人一上手就想找现成的分片上传组件其实核心逻辑就是切块、传块、合并这三步。用原生代码写一遍你会对整个链路有透彻的理解之后再封装或引入组件都很顺手。而且原生实现的兼容性和可控性比自己不熟悉的框架库要好得多。第三个心得是上传功能上线前必须做异常路径测试。我的测试清单包括上传过程中刷新页面重新开始后应跳过分片继续上传过程中断网恢复后应重试失败分片而不是从零开始服务器磁盘写满时前端应收到明确的错误提示上传同名文件、传中文名文件、传空文件、传0字节文件并发上传多个文件观察进程和内存占用用慢速网络模拟工具限制带宽到1Mbps看超时处理是否正确这些测试做完上线才有底气。如果项目后续想进一步优化可以考虑加秒传功能。做法是在前端生成文件内容哈希先通过一个接口查询哈希是否已存在存在就直接返回已有文件地址省掉传输时间。文件内容哈希的计算可以用增量哈希算法不用等待整个文件加载到内存几秒内能完成。我计划在下个版本给这套上传模块加上这个能力。最终建议别把大文件上传想得太复杂但别把它想得太简单。核心链路就三件事——参数配置、切片传输、有序合并把这三件事用流式处理做到位一台普通服务器就完全能撑起GB级别的上传需求。遇到问题按网络层、服务层、应用层逐层排查大多数坑都能很快定位。