HTML+PHP大文件分片上传:解决设计评审视频超大附件传输 去年陪某整车厂信息部做内部系统改造聊到设计评审视频上传这件事对方的形容很实在评审会开完了视频却传不上去几个G的文件折腾一整天。这场景在汽车行业太典型了——造型评审、工程评审、试制评审动辄4K机位全程录制一场评审的原始视频三五个G是家常便饭遇上多机位素材上10G也不奇怪。而企业内部系统多数还是传统的HTMLPHP架构表单直接整包上传大文件成功率极低。这篇文章就完整拆解一下如何用HTMLPHP处理设计评审视频的超大附件分片传输从原理、参数设计到前端切片、后端合并再到内网部署会踩的坑一次说清楚。适合正在给汽车制造企业或其他重资产行业做内部系统的开发团队参考也适合想自己实现断点续传上传的PHP开发者。1. 设计评审视频的体量正在超出传统上传方案的承受极限1.1 汽车设计评审的真实场景一场评审会下来素材有多大先还原一个我接触过的真实场景。某整车厂造型中心每两周一次造型评审会四个机位同时录制全景机位拍油泥模型工作室两个特写机位分别对着前脸和内饰还有一个移动机位跟着评审专家走。一场评审会两个半小时四路4K素材加起来接近20GB剪辑合并后交付的评审视频也常常在4-8GB。这还只是造型评审。工程评审那边更夸张——碰撞试验室的高速摄像机、试制车间的路试采集、密封测试的防水录像原始素材动辄几十个GB。设计评审视频不是给领导看一眼就完事的汇报材料它是后续设计变更、模具调整、责任界定的依据必须完整留档。体量是一回事保密性又是另一回事。新车型的设计图、评审过程、试验数据在上市前都属于企业核心机密外包给公有云对象存储基本不用想数据合规部门那一关就过不了。所以这些视频只能进企业自己的内部系统而且要求传输链路可控、留痕可查。这就是汽车行业区别于互联网公司的地方不是不想用现成的云服务是政策不允许。1.2 传统HTML表单上传为什么在这个场景下必挂内部系统多数还是老一套的HTMLPHP架构文件上传走的是最原始的方式form表单enctype设成multipart/form-data一个文件一个请求整包提交。这套方式上传几十MB的图纸、文档没问题遇到几个GB的视频问题就全冒出来了。第一个是PHP的硬限制。upload_max_filesize默认2Mpost_max_size默认8M虽然能调大但调到几个GB之后隐患不少PHP-FPM处理这个请求时要占用大量worker时间和内存max_execution_time、max_input_time一触发请求直接被杀。就算你把这些参数全调成无限大服务器的内存和磁盘也扛不住一个超大请求同时在内存里缓冲。第二个是PHP-FPM进程被长时间占用。一个1GB的上传请求可能持续几分钟期间的worker进程全被占住。企业内网几十人同时用系统几个大文件上传就能把PHP-FPM的进程池打满排队的请求超时其他所有功能跟着卡死。这个问题我在不止一个现场见过上午有人传了个大视频下午全公司都在抱怨系统慢。第三个是断点即放弃。跨地域专线哪怕是千兆实际传输中丢包、抖动很常见一个大文件传到最后几分钟断了整个请求作废用户又得从头传。传一次两小时断一次用户就骂一次IT部门。第四个是完全没有进度反馈。传统表单提交期间浏览器就是转圈用户根本不知道是传了50%还是卡死了体验极差。评审视频这种动辄几十分钟的传输没有进度条等于没有心理预期用户只能干等。四个问题叠加结论很明确直接整包上传在超大视频面前就是死路必须分片。1.3 为什么是HTMLPHP而不是其他方案你可能会问都2025年了为什么不用Java写个上传服务或者直接用WebUploader、Plupload这些现成组件原因很实际。第一绝大多数车企的内部业务系统图档管理、评审管理、问题跟踪都是PHP写的运维团队也只熟悉这套体系为了一个上传功能引入新语言新服务从评审到上线要走的流程不比做正经项目短。第二现成的上传组件确实好用但它们默认对接的对象存储、鉴权方式、回调逻辑拿到纯内网PHP环境里反而要改一大堆。第三HTML5的File API加PHP的分片接收整个链路自己可控出了问题不用等组件作者修看一眼代码就能定位。要说明的是这里的HTML不是远古静态页是HTML5的File、Blob、Fetch这些能力。内网客户机装个新版Chrome或Edge内核浏览器并不是什么难事。这套组合最大的优势就是两个字可控。2. 分片方案的核心设计切片大小、并发数与断点续传的取舍2.1 分片上传的整体逻辑分片上传的原理用一个搬家类比就很好理解整包上传是把整个家当塞进一辆卡车路上爆胎就全完了分片是把家当拆成几十个小纸箱每箱自己走一个快递到地方再拼起来。单个箱子丢了就补发那一箱不用全屋重搬。映射到技术实现上分三步前端用原生JavaScript的File.slice()方法把大文件切成N个固定大小的分片每个分片作为一个独立的HTTP请求上传到PHP服务端服务端按分片序号的规则命名存放在临时目录所有分片传完后前端通知后端执行合并后端按分片序号依次拼接出完整文件。配合断点续传的话在上传前前端先向后端查询这个文件已经传过哪些分片已传过的直接跳过。这样网络中断、页面刷新、甚至换一台电脑登录都能接着传。2.2 切片大小怎么定分片大小是第一个要拍板的参数。我的经验是普通内网环境5MB一个分片最稳专线质量高、带宽100Mbps以上可以放宽到10-20MB公网弱网环境建议压到1-2MB。为什么不是越大越好分片大了单次传输耗时变长中途断掉重传的成本就高断点续传的意义就被削弱。为什么不是越小越好分片小了请求数量会爆炸。一个4GB的文件如果用1MB分片就是4096个请求每个请求在PHP-FPM里都有完整的请求解析、会话校验、目录写入开销Nginx访问日志、PHP慢日志都会被刷得没法看而5MB分片是820个请求各方面压力小得多。具体算一笔账820个分片3并发内网每个分片传输加服务端落盘的耗时大约0.3-0.8秒理论总耗时大约820/3乘以0.5秒约137秒加上网络波动和重试实际10-20分钟之内能完成一个4GB文件的传输。相比传统方式经常一传两小时最后失败重来这个效率已经具备实用价值。2.3 并发数为什么取3浏览器同时发多个上传请求属于网络IO密集型的并发理论上并发越高整体越快但有三个制约PHP-FPM进程池的worker数量有限一个worker同一时间只能处理一个请求并发太高相当于你一个人把进程池占满并发上传会同时打开多个文件句柄写入磁盘机械硬盘或NAS存储上多个写入流相互争抢磁头实际吞吐不升反降SSD好一些但企业NAS很多还是机械盘加RAID5企业级Nginx连接数配置未必扛得住大规模用户同时操作单客户端开太多连接容易把worker_connections吃光。综合下来单客户端3并发是我用下来最稳的折中点。上传速度快慢在这个场景里不是第一诉求稳定不出错才是。2.4 文件的唯一标识怎么生成分片管理、断点续传、最终合并全都依赖一个稳定的文件标识。最常见的做法有两种。一种是前端用MD5或其它哈希算法给整个文件算一个指纹。问题是大文件算全量MD5极慢4GB文件在浏览器里用WebCrypto算SHA-256要卡主线程十几秒甚至更久而且不是所有内网老机器都支持。所以不推荐。另一种是我在项目里用的方案文件名加文件大小加lastModified时间戳拼成一个字符串作为file_id。这个组合虽然不绝对唯一两个文件的这三个字段完全一致是极小概率事件但在评审视频这种每场评审有自己的文件名前缀的业务场景里基本够用。要绝对唯一的话可以在后端合并时校验发现同名文件且大小一致就提示用户是否覆盖或改名。3. 前端HTMLJavaScript切片上传的完整实现3.1 页面骨架与切片逻辑前端页面不需要复杂框架一个隐藏文件选择框、一个上传按钮、一个进度条足矣。核心逻辑都在upload.js里。!DOCTYPE html html langzh-cn head meta charsetutf-8 title设计评审视频上传/title style .progress-wrap { width: 600px; height: 24px; background: #eee; border-radius: 4px; margin-top: 16px; } .progress-bar { height: 24px; width: 0; background: #2f9e44; border-radius: 4px; } .progress-text { margin-left: 12px; font-size: 14px; color: #555; } /style /head body input typefile idvideoFile acceptvideo/* button idstartUpload开始上传/button div classprogress-wrap div classprogress-bar idprogressBar/div /div span classprogress-text idprogressText0%/span script srcupload.js/script /body /htmlJavaScript里最关键的两个动作一个是切片一个是并发控制。// upload.js const CHUNK_SIZE 5 * 1024 * 1024; // 每个分片5MB const CONCURRENCY 3; // 同时上传3个分片 const MAX_RETRY 3; // 单个分片失败最多重试3次 const fileInput document.getElementById(videoFile); const startBtn document.getElementById(startUpload); // 生成文件唯一标识名称大小最后修改时间 function makeFileId(file) { return ${file.name}_${file.size}_${file.lastModified}; } // 用 File.slice 把大文件切成若干分片 function sliceFile(file) { const chunks []; let offset 0; while (offset file.size) { const end Math.min(offset CHUNK_SIZE, file.size); chunks.push({ index: chunks.length, blob: file.slice(offset, end) }); offset end; } return chunks; }File.slice()不需要把整个文件读进内存它只是创建一个指向原文件某段字节范围的Blob引用。这是分片方案在浏览器端能够成立的基石——不管文件多大切片操作都是瞬间完成的真正的内存开销只取决于单次上传的分片大小。3.2 并发上传与失败重试并发控制我用了一个最简单的worker池模式准备一系列待上传的分片启动3个worker每个worker循环从待处理队列里取一个分片上传直到队列清空。这个模式写起来清晰也方便控制重试。async function uploadChunk(fileId, chunk, index, total) { const formData new FormData(); formData.append(file_id, fileId); formData.append(chunk_index, index); formData.append(total_chunks, total); formData.append(chunk, chunk.blob, part_${index}); const response await fetch(/upload_chunk.php, { method: POST, body: formData }); const result await response.json(); if (result.code ! 0) { throw new Error(result.msg || 分片上传失败); } } async function uploadWithRetry(fileId, chunk, index, total) { for (let attempt 1; attempt MAX_RETRY; attempt) { try { await uploadChunk(fileId, chunk, index, total); return; } catch (e) { console.warn(分片 ${index} 第 ${attempt} 次失败, e); if (attempt MAX_RETRY) throw e; // 简单退避等1秒再重试 await new Promise(resolve setTimeout(resolve, 1000)); } } } startBtn.addEventListener(click, async () { const file fileInput.files[0]; if (!file) return; const fileId makeFileId(file); const chunks sliceFile(file); // 先查询服务端已收到的分片实现断点续传 const checkResp await fetch(/check_chunks.php?file_id${encodeURIComponent(fileId)}); const checkData await checkResp.json(); const uploadedSet new Set(checkData.received || []); // 只上传缺少的分片 const pendingChunks chunks.filter(c !uploadedSet.has(c.index)); let uploadedCount uploadedSet.size; // worker池并发上传 let cursor 0; async function worker() { while (cursor pendingChunks.length) { const current pendingChunks[cursor]; cursor; await uploadWithRetry(fileId, current, current.index, chunks.length); uploadedCount; const percent Math.round(uploadedCount / chunks.length * 100); document.getElementById(progressBar).style.width percent %; document.getElementById(progressText).textContent percent %; } } const workers []; for (let i 0; i CONCURRENCY; i) { workers.push(worker()); } await Promise.all(workers); // 全部传完通知后端合并 const mergeResp await fetch(/merge.php, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: file_id${encodeURIComponent(fileId)}file_name${encodeURIComponent(file.name)}file_size${file.size}total_chunks${chunks.length} }); const mergeResult await mergeResp.json(); if (mergeResult.code ! 0) { alert(合并失败 mergeResult.msg); return; } alert(上传完成); });这里有个细节进度百分比是按分片个数算的不是按字节算的。因为并发上传时各分片完成顺序不固定按分片个数计数要简单准确得多。分片大小固定个数和字节基本成正比用户看到的进度误差可以忽略。另一个细节是断点续传的「查询」放在点击开始上传之后。如果查询放在选择文件时就做用户还没点开始目录里的临时分片可能已经被清理脚本删了反而拿到一份失效的已传列表。放在点击上传时重新查询数据是最新的。3.3 前端需要注意的两个兼容性细节第一个是fetch的兼容性。现在内网基本都是Chrome/Edge内核fetch没问题。但如果车间机房里还存着老的IE11建议要么统一更新浏览器要么把fetch换成XMLHttpRequest的写法。我的建议是更新浏览器IE的File API、Blob、Promise生态都已经跟不上分片上传的需求了为一个上传功能维护两套前端代码很不划算。第二个是并发数别在后端写死。我习惯在前端用一个全局常量控制同时在后端对file_id维度的并发做上限校验双保险。前端开多了会占满Nginx连接后端不校验则可能被绕过前端直接用脚本狂刷接口。4. PHP后端分片接收、临时存储与顺序合并4.1 三个接口的分工后端PHP只做三件事对应三个接口upload_chunk.php负责接收单个分片并落盘check_chunks.php负责返回已收到的分片序号merge.php负责把全部分片按顺序合并成完整文件。职责单一出问题好排查。先看接收分片的实现。?php // upload_chunk.php session_start(); // 假设登录后session里存了user_id这里做鉴权 if (!isset($_SESSION[user_id])) { header(Content-Type: application/json); echo json_encode([code 401, msg 未登录]); exit; } // 重点校验完身份立刻释放session锁否则并发上传会被锁死 session_write_close(); $fileId isset($_POST[file_id]) ? trim($_POST[file_id]) : ; $chunkIndex isset($_POST[chunk_index]) ? intval($_POST[chunk_index]) : -1; $totalChunks isset($_POST[total_chunks]) ? intval($_POST[total_chunks]) : -1; // file_id 只允许字母数字下划线点防止路径穿越 if (!preg_match(/^[A-Za-z0-9_.-]{1,200}$/, $fileId)) { header(Content-Type: application/json); echo json_encode([code 400, msg file_id 非法]); exit; } if ($chunkIndex 0 || $totalChunks 0 || $chunkIndex $totalChunks) { header(Content-Type: application/json); echo json_encode([code 400, msg 分片参数非法]); exit; } if (!isset($_FILES[chunk]) || $_FILES[chunk][error] ! UPLOAD_ERR_OK) { header(Content-Type: application/json); echo json_encode([code 400, msg 未收到分片文件]); exit; } $uploadRoot /data/video_upload_tmp; $chunkDir $uploadRoot . / . $fileId; if (!is_dir($chunkDir)) { if (!mkdir($chunkDir, 0770, true) !is_dir($chunkDir)) { header(Content-Type: application/json); echo json_encode([code 500, msg 分片目录创建失败]); exit; } } $chunkPath $chunkDir . /part_ . $chunkIndex; if (!move_uploaded_file($_FILES[chunk][tmp_name], $chunkPath)) { header(Content-Type: application/json); echo json_encode([code 500, msg 分片保存失败]); exit; } header(Content-Type: application/json); echo json_encode([code 0, msg ok]);4.2 session锁是并发上传的隐形杀手上面代码里有一行很多人会忽略的代码session_write_close()。PHP的session文件默认是加锁的一个请求调用了session_start()之后如果没关掉其它请求即使来自同一个用户也要等这个请求结束后才能读取session。那么问题来了前端3个并发分片请求同时到达第一个请求握着session锁在处理上传第二个、第三个请求在session_start()这里排队等待等第一个处理完才能继续。3个并发实际退化成串行速度直接除以3。如果并发是10那就变成完全的排队。这个坑不实际跑一次并发上传根本发现不了我看过不少网上代码都没处理。正确的姿势就是只要在初始化阶段确认了登录状态立刻session_write_close()释放锁。后面的处理完全不依赖session内容。如果你们系统的鉴权不是session而是token那就没有这个烦恼。但很多老PHP系统的统一登录都是session这篇文章的方法可以无缝接进去。4.3 查询已传分片与断点续传的配合check_chunks.php负责扫描临时目录列出已存在的分片序号。?php // check_chunks.php session_start(); if (!isset($_SESSION[user_id])) { header(Content-Type: application/json); echo json_encode([code 401, msg 未登录]); exit; } session_write_close(); $fileId isset($_GET[file_id]) ? trim($_GET[file_id]) : ; if (!preg_match(/^[A-Za-z0-9_.-]{1,200}$/, $fileId)) { header(Content-Type: application/json); echo json_encode([code 400, msg file_id 非法]); exit; } $chunkDir /data/video_upload_tmp/ . $fileId; $received []; if (is_dir($chunkDir)) { foreach (glob($chunkDir . /part_*) as $f) { $received[] intval(substr($f, strrpos($f, _) 1)); } } header(Content-Type: application/json); echo json_encode([code 0, received $received]);前端拿到这份序号列表和本地切片列表比对把已存在的分片剔除只传缺失部分。这就是断点续传的全部实现。要注意的是这里只校验了「分片文件存在」没校验分片大小是否正确。严谨的做法是每个分片文件落盘时记录一份分片大小清单比如存一个json文件查询时顺带校验大小防止网络传输数据不完整。项目初期可以先不做但日志里要留好分片大小信息出问题了能追溯。4.4 合并文件stream_copy_to_stream避免内存爆炸合并是实现中最容易写错的一环。常见错误是用file_get_contents读整个分片再拼字符串一个分片5MB还好但如果有人把你的分片参数改大或者分片数量很多内存就扛不住了。正确的写法是用流式拷贝PHP的stream_copy_to_stream内部有固定缓冲内存占用始终是常数不会随着文件变大而增长。?php // merge.php session_start(); if (!isset($_SESSION[user_id])) { header(Content-Type: application/json); echo json_encode([code 401, msg 未登录]); exit; } session_write_close(); $fileId isset($_POST[file_id]) ? trim($_POST[file_id]) : ; $fileName isset($_POST[file_name]) ? trim($_POST[file_name]) : ; $fileSize isset($_POST[file_size]) ? intval($_POST[file_size]) : 0; $totalChunks isset($_POST[total_chunks]) ? intval($_POST[total_chunks]) : 0; if (!preg_match(/^[A-Za-z0-9_.-]{1,200}$/, $fileId)) { header(Content-Type: application/json); echo json_encode([code 400, msg file_id 非法]); exit; } // 扩展名白名单只允许视频类型 $ext strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); $allowedExt [mp4, mov, avi, mkv, wmv, flv, m4v]; if (!in_array($ext, $allowedExt)) { header(Content-Type: application/json); echo json_encode([code 400, msg 不允许的文件类型]); exit; } $chunkDir /data/video_upload_tmp/ . $fileId; if (!is_dir($chunkDir)) { header(Content-Type: application/json); echo json_encode([code 400, msg 分片目录不存在]); exit; } // 加锁防止同一个文件被多个合并请求并发处理 $lockFile $chunkDir . /merge.lock; $lockHandle fopen($lockFile, c); if (!$lockHandle || !flock($lockHandle, LOCK_EX | LOCK_NB)) { header(Content-Type: application/json); echo json_encode([code 409, msg 合并正在进行中]); exit; } // 校验分片数量是否齐全 $partFiles glob($chunkDir . /part_*); if (count($partFiles) $totalChunks) { flock($lockHandle, LOCK_UN); header(Content-Type: application/json); echo json_encode([code 400, msg 分片不完整]); exit; } $targetDir /data/video_archive; if (!is_dir($targetDir)) { mkdir($targetDir, 0770, true); } // 目标文件名fileId_原始名避免重名与路径穿越 $safeName preg_replace(/[^A-Za-z0-9_.-]/, _, $fileName); $targetPath $targetDir . / . $fileId . _ . $safeName; $out fopen($targetPath, wb); if ($out false) { flock($lockHandle, LOCK_UN); header(Content-Type: application/json); echo json_encode([code 500, msg 目标文件创建失败]); exit; } // glob返回顺序不保证必须按数字序号排序 usort($partFiles, function ($a, $b) { return intval(substr($a, strrpos($a, _) 1)) intval(substr($b, strrpos($b, _) 1)); }); foreach ($partFiles as $partFile) { $in fopen($partFile, rb); if ($in false) { continue; } stream_copy_to_stream($in, $out); fclose($in); } fclose($out); // 大小校验如果前端传了期望大小比对最终文件 $finalSize filesize($targetPath); if ($fileSize 0 $finalSize ! $fileSize) { unlink($targetPath); flock($lockHandle, LOCK_UN); header(Content-Type: application/json); echo json_encode([code 500, msg 文件大小校验失败]); exit; } // 清理临时分片 array_map(unlink, glob($chunkDir . /part_*)); rmdir($chunkDir); flock($lockHandle, LOCK_UN); fclose($lockHandle); header(Content-Type: application/json); echo json_encode([code 0, msg 合并成功, path $targetPath]);这里三个细节值得展开说。第一是锁文件。前端如果因为网络原因重复点击合并或者上传完成页自动刷新后再次请求合并没有锁就可能出现两个进程同时读写同一个临时目录、一个读着分片另一个正删分片的情况。用flock加非阻塞锁第二次合并请求直接返回「合并正在进行中」安全得多。第二是扩展名白名单。评审视频格式就那么几种允许列表能极大降低被人传PHP、jsp可执行文件的风险。后端校验扩展名没有任何性能损耗必须做。第三是合并完成后立即清理临时分片目录。这部分容易出疏漏用得多了临时目录里会堆出几十G的分片垃圾。后面第五章讲清理策略。5. 车企业内部部署时最容易翻车的配置与安全细节5.1 Nginx与PHP-FPM的配置分片上传方案部署到NginxPHP-FPM环境时有几个配置不调好代码再对也会翻车。首先是Nginx的client_max_body_size。默认值是1M超过就直接返回413请求根本到不了PHP。虽然我们单次只上传5MB的分片但万一有人改了前端代码或者手工发请求大分片还是可能被拦。建议统一设为20M给分片大小留出4倍冗余同时限制住真正的超大请求。server { # 单个请求体上限必须大于分片大小 client_max_body_size 20m; }然后是PHP的post_max_size和upload_max_filesize。这两个参数同时影响请求体和上传文件的大小。分片是5MB加上表单字段和multipart格式的额外开销建议两个都设成20M。upload_max_filesize只影响单文件大小post_max_size影响整个请求体把它们都放开到20M同时配合Nginx的20M限制三个值一致不容易出现边界歧义。; php.ini 或 pool config 中设置 upload_max_filesize 20M post_max_size 21M max_execution_time 300 max_input_time 300 memory_limit 256Mmemory_limit保持256M就够了。分片保存用的是move_uploaded_file合并用的是stream_copy_to_stream都不需要把大文件读进内存。如果发现哪个环节内存上涨得很厉害问题不在内存设置而在代码里用了file_get_contents。5.2 临时目录与磁盘空间管理分片临时目录建议独立挂载一块磁盘和系统盘、数据库分开。评审视频上传的临时占用量是峰值性的一个4GB文件在传输过程中临时目录里最多堆到4GB左右如果系统盘只有40G几个并发上传就把盘写满了数据库跟着遭殃。磁盘监控方面在磁盘使用率到90%时告警是底线。我在实际项目中还加了一段代码merge.php合并之前先检查/data/video_upload_tmp所在分区的剩余空间如果小于目标文件大小的两倍直接拒绝合并返回明确的提示。这一步能避免把磁盘彻底写死再让运维半夜爬起来删文件。临时分片的清理也要有保底机制。如果用户传一半关掉了页面临时目录里的分片就成了孤儿文件永远不会被合并。建议写一个清理脚本用cron每天跑一次?php // cleanup_chunks.php // cron: 0 3 * * * php /var/www/upload/cleanup_chunks.php $uploadRoot /data/video_upload_tmp; $dirs glob($uploadRoot . /*); if (empty($dirs)) { exit; } foreach ($dirs as $dir) { if (!is_dir($dir)) { continue; } // 目录最后修改时间超过24小时视为孤儿分片直接清理 if (time() - filemtime($dir) 86400) { foreach (glob($dir . /part_*) as $f) { unlink($f); } rmdir($dir); } }清理脚本的时间窗口要大于可能的断点续传间隔。比如用户第一天传到一半下班了第二天早上来接着传相隔12小时如果清理窗口设为1小时分片就被误删了。设24小时比较合适既不会立刻清掉正在进行的传输也能保证不会堆积太久。5.3 权限与路径穿越细节安全方面分享三个我实际遇到过的细节。第一个是file_id的校验必须用正则白名单。有人会直接把前端传来的file_id拼进文件系统路径../../etc/passwd这样的字符串就能造成路径穿越。上面代码里preg_match(/^[A-Za-z0-9_.-]{1,200}$/, $fileId)直接杜绝了这种可能。第二个是临时目录的权限。PHP-FPM的worker通常以www-data用户运行临时目录需要让这个用户可写但要注意不要用777这种宽松权限。我一般用0770目录属主设为php-fpm的运行用户和组。否则同机的其他服务用户也能读写这些还没合并的评审视频这在车企这种数据敏感的环境里是要被审计的。第三个是合并后的文件名处理。原始文件名可能包含中文、空格、特殊字符直接拼路径存在风险。我在代码里做了两步先用正则把文件名里非白名单的字符替换成下划线再用fileId_做前缀。这样既保留了可读性又杜绝了路径穿越和文件名注入。5.4 日志与可观测性分片上传的日志比普通接口重要得多。因为一个问题可能涉及几百个请求没有一个统一的file_id维度的日志排查时只能靠猜。建议在三个接口里都记录以下字段file_id、chunk_index、分片大小、IP、耗时、结果。日志写到单独的文件里比如/var/log/upload_php.log用file_id做检索键。实际排障时最典型的场景是用户说传到一半卡住了一看日志发现某个分片反复重试失败重试3次后放弃了前端没有提示失败的单个分片用户以为还在传实际上进程已经结束了。所以前端在上传结束后要检查一下是否有分片最终失败如果有明确提示用户「部分分片上传失败请重试」不要只说一句「上传完成」。这个体验细节我在改版时专门优化过效果很明显用户投诉率直接降了一截。6. 完整目录结构与后续扩展方向6.1 一套可直接落地的目录结构把上面的代码整合起来一个最小可用的工程目录是这样的/var/www/upload/ ├── upload.html # 上传页面 ├── upload.js # 前端切片与并发控制 ├── upload_chunk.php # 接收单个分片 ├── check_chunks.php # 查询已传分片 ├── merge.php # 合并分片 └── cleanup_chunks.php # 清理孤儿分片cron /data/ ├── video_upload_tmp/ # 分片临时目录独立挂载 └── video_archive/ # 合并后的成品目录部署顺序我建议分四步先把三个PHP接口配好用curl手工模拟分片上传验证接口正常再放前端页面用一个小文件比如100MB跑通全流程然后用真实评审视频验证并发和断点续传最后接上登录鉴权和日志。每一步都验证通过再往下走出问题能快速定位是前端还是后端。我自己的一个习惯是在merge.php合并完成后往业务数据库里插入一条上传记录包括文件ID、原始文件名、大小、上传人、上传时间、最终存储路径。评审视频后续要跟评审单、问题项关联这条记录就是整个系统的源头数据。这一步一定要做不做的话视频传上去了业务系统里却查不到等于白传。6.2 断点续传、秒传与转码联动的扩展空间这套基础方案跑通之后有几个很实用的扩展方向。断点续传现在已经有了但还可以做得更完善前端把file_id、已传分片列表、文件信息缓存到localStorage用户刷新页面后自动恢复上传任务不用手动重新选择文件。这个改动量不大但对评审这种可能需要传很久的大文件来说体验提升非常明显。「秒传」是另一个高频需求。如果同一个评审视频被不同部门反复上传每次都传几GB完全是浪费。实现思路是前端生成file_id后后端先查一张「文件指纹表」看这个file_id是否已经传过且合并成功过如果存在直接返回原文件路径跳过上传。这个查重表的粒度可以细化到文件大小和名称也能升级成真正的内容哈希。注意全量MD5在浏览器端算不动可以在上传过程中用SparkMD5增量计算全部分片传完后得到一个稳定的内容哈希再去和库里的哈希比对这样秒传才真正可靠。第三个是转码联动。评审视频合并之后原始格式可能不适合在线预览比如AVI、老款MOV需要转成H.264的MP4方便评审组在线观看。PHP不适合直接做转码但可以通过消息队列把「待转码文件路径」发给独立的FFmpeg worker处理PHP这边只负责入队和查询状态。转码完成后更新数据库状态前端就能轮询到「已可预览」。这套联动已经是另一个项目的范畴了但你把分片上传的存储目录设计好转码服务接进来就是自然延伸。回到最开始的问题汽车制造企业用HTMLPHP处理设计评审视频的超大附件分片传输核心就是「前端切片、后端合并、接口加锁、目录隔离」这十六个字。我在做这套方案的过程中最大的体会是技术本身不复杂复杂的是那些看不见的约束——PHP-FPM的进程池、session锁、磁盘空间、内网老浏览器的兼容性任何一环没考虑到方案都会在实际运行中露出破绽。建议你落地的时候先拿一个真实大小的评审视频完整走一遍把日志打全把所有参数在测试环境里压一遍再往生产上放。分片上传这种方案一旦在生产环境跑起来稳定性比花哨的技术选型重要得多。