车间里老师傅拍了一段10分钟的机床换模操作视频,1080p手机直出,一看文件大小1.2GB。想传到OA里给全车间做SOP培训,结果拖进网页传了一个小时,最后在99%的地方弹了个上传失败。重来一遍,又得面对同样的问题。这不是网速的锅,也不是OA平台故意为难人,而是传统表单上传这种模式,在设计上就没有为“超大文件”做过准备。机械制造企业里设备操作视频的场景越来越多,从标准作业指导书(SOP)录制、新员工培训,到设备验收、售后故障排查,动辄就是几百MB甚至上GB的文件,偏偏OA系统最常见的技术栈又是HTML+PHP,默认的上传限制往往只有2MB到8MB。这篇文章我把自己在制造企业OA里做设备操作视频超大文件分片续传的完整思路、前后端代码、踩坑记录和服务器参数配置整理出来,给同样被大文件上传折磨过的运维和实施同学做个参考。适合在推进企业数字化转型、OA二次开发或者设备管理信息化建设中需要处理大附件的读者。
1. 先捋清业务场景:设备操作视频为什么“大到必须分片”
1.1 三类必须录视频并传到OA的真实需求
我在制造企业里调研过一圈,设备操作视频的需求主要集中在三个方向,每个方向都对文件大小有非常直接的影响。
第一类是设备SOP标准化,这是最典型的需求。每台设备的换型、换刀、对刀、装夹、调参,都有严格的步骤顺序。过去这些内容写在纸面上,老师傅离职了,新人根本看不懂纸上的抽象描述。现在普遍的做法是录成视频,多角度拍摄,配上休息点提示,一个完整的换模SOP视频很容易超过500MB。
第二类是质检与验收留痕。设备出厂前的运行测试、客户现场安装后的带料试切、供应商设备到场后的开箱验收,都要录视频作为证据保存。这类视频涉及责任划分,画质不能压缩太狠,原片归档存档,动不动就是1GB起步。
第三类是故障排查与维修记录。设备停机后维修人员到现场,先把故障现象拍下来,再录拆解排障过程。这类视频往往是一次性素材,不抓紧录就没了,所以现场直接用手机拍,原始画质全部保留,文件体积完全不受控。
这三类视频有一个共同特点:拍摄是不可逆的,丢了就再也补不回来。所以上传方案的可靠性,比上传速度更重要。
1.2 算一笔账:10分钟视频到底有多大
手机录视频的体积问题,我直接用实际数据说明。以一台主流手机录制1080p、30帧的视频为例,实时码率大约在12Mbps到20Mbps之间。按15Mbps的中间值估算:
15Mbps等于每秒约1.875MB,录制10分钟就是约1125MB。也就是说,一部10分钟的手机视频,轻松超过1GB。如果设备是4K、60帧录制,10分钟文件冲到3GB到5GB都很正常。
而PHP默认的upload_max_filesize是2MB,很多OA系统实施时即使调整过,通常也只放到几十MB。一个1GB的视频,和默认上传限制之间差着500倍。这意味着,如果方案没有处理好,这个视频在厂区普通千兆内网里,想通过网页表单直接传上去,就是永远的失败现场。
1.3 传统单请求上传在面对大文件时的四个硬伤
在动手写代码之前,我先把自己在传统上传方式上踩过的坑总结一下,方便理解为什么非要分片。
第一是超时。浏览器和服务器之间的请求有超时时间,一个几百MB的文件传输耗时可能超过10分钟,一旦超过网关、中间件或者PHP执行时间限制,连接就被切断。在文件末尾弹出“上传失败”,就是最常见的结果。
第二是断线全废。传统表单上传是一次性POST请求,文件作为一个整体发送。网络只要闪断一下,已经传出去的所有字节全部作废,只能从头再来。
第三是无进度。浏览器对原生表单上传的进度感知几乎没有,用户看到的是一个不确定的等待状态。在工厂车间这种多设备同时运转、内网偶发波动的环境下,用户等得心慌,就会反复刷新重传,把服务器拖得更慢。
第四是服务器内存压力。PHP接收大文件时,如果配置不当,会把整个请求体读入内存。一个1GB的视频,几路并发上传,服务器直接内存吃满,连OA主站都跟着卡死。
分片上传的思路,本质上是把一个不可中断的大请求,拆成很多个可以独立失败、独立重试的小请求。单次请求只要满足服务器限制即可,断点续传则把“传了一半”的成本从“归零重来”降为“补齐缺口”。
2. 技术选型:为什么在OA里坚持用HTML+PHP自己做分片
2.1 为什么不直接把upload_max_filesize改到1GB
很多人的第一反应是:既然文件大,把服务器上传限制调大不就完了。这个思路在几十MB的文件上有效,在GB级视频上会撞上三堵墙。
第一堵墙是Nginx的client_max_body_size,默认只有1MB,不改的话,任何超过1MB的请求直接返回413。它不像PHP参数改完就能生效,还需要同时处理反向代理的超时参数proxy_read_timeout、proxy_send_timeout,否则长请求会在代理层被掐断。
第二堵墙是PHP的执行时间和内存。上传1GB文件时,如果PHP把整个请求体缓存到临时文件,内存压力还相对可控,但max_execution_time如果不够,超时就被杀了。更重要的是,如果代码里有任何file_get_contents("php://input")之类的操作,1GB文件直接读进内存,再多的memory_limit都白搭。
第三堵墙是用户体验。就算全部参数调通,单个1GB请求仍然意味着任何一次网络抖动都会导致全盘重来。我在现场见过太多老师傅看着进度条走到90%然后断线,表情从期待变成绝望,从此不再信任信息系统。
2.2 为什么不依赖OA平台自带的附件组件
国内制造业常用的泛微、致远、蓝凌这类OA平台,都自带附件上传能力。但这些能力在设计时主要面向文档和图片,对超大视频的支持普遍不理想。有的平台附件大小限制是100MB,有的大附件上传需要单独购买模块,还有的虽然宣称支持大文件,实际上走的是商业闭源控件,遇到问题只能提工单等售后。
更关键的一点是,设备管理模块里往往还需要把视频和具体的设备台账、维修工单关联。直接往OA附件上传,文件是传到了,但业务关联、字段回填、权限控制这些还是得写代码。与其绕一圈走OA的封闭能力,不如自己在前端做HTML5分片,在后端用PHP收分片、合并文件,最后把生成好的文件信息回填给OA表单。这个方案对OA版本完全无侵入,迁移成本低,逻辑透明可控。
2.3 HTML5 File API + PHP这套组合的适用边界
在2020年以后的浏览器环境里,HTML5的File API、Blob.slice()、FormData、XMLHttpRequest这组能力是所有前端方案的基础。不需要安装任何插件,不需要ActiveX控件,苹果手机Safari和Windows上的Chrome、Edge都能直接跑。
后端用PHP,是因为OA系统本身的运行环境就是PHP,用同一个语言栈,部署、维护、排错都不需要引入额外的运行时。分片上传对后端的要求是“能收文件、能写磁盘、能合并”,PHP在这些操作上并不吃力。只要做事的粒度合理,单分片控制在几MB,PHP完全可以轻松承受。
这套方案的核心优势,是它把超大文件问题的解决逻辑从前端到后端都掌握在自己手里。业务需要断点续传,就加一个查询接口;需要限制每个人上传总量,就在合并时加个配额判断;需要把视频转成网页可播放的MP4,就在合并完成后调用ffmpeg处理。所有环节都是可插拔的。
3. 核心实现:前端把视频“切块”传上去的完整过程
3.1 基础切片与上传逻辑
前端切片的底层能力是Blob.slice(),它可以直接从File对象上按字节范围切出一个小Blob。每一片都独立封装成一个FormData请求发送到后端。后端把每个分片当作独立的小文件保存下来,最后再把所有分片按序号合并。
这里给出一个最简可运行的前端切片逻辑,上传部分用XMLHttpRequest是为了能拿到上传进度。
const input = document.getElementById('videoFile'); const file = input.files[0]; // 每片大小:5MB,这个值后面再讲为什么 const CHUNK_SIZE = 5 * 1024 * 1024; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); let currentChunk = 0; function uploadChunk(chunkIndex) { const start = chunkIndex * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const blob = file.slice(start, end); const formData = new FormData(); formData.append('chunk', blob); formData.append('fileName', file.name); formData.append('chunkIndex', chunkIndex); formData.append('chunkTotal', totalChunks); formData.append('fileSize', file.size); const xhr = new XMLHttpRequest(); xhr.open('POST', '/oa_upload/receive_chunk.php', true); // 监听当前分片的字节级进度 xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const loaded = chunkIndex * CHUNK_SIZE + e.loaded; const percent = Math.round((loaded / file.size) * 100); document.getElementById('progressBar').textContent = percent + '%'; } }); xhr.onload = function() { if (xhr.status === 200) { currentChunk++; if (currentChunk < totalChunks) { uploadChunk(currentChunk); } else { mergeChunks(file.name, totalChunks); } } else { // 失败则原地重试,这里只做一次重试,后面会讲改良版 uploadChunk(chunkIndex); } }; xhr.send(formData); } // 全部切片传完之后调合并接口 async function mergeChunks(fileName, chunkTotal) { const formData = new FormData(); formData.append('fileName', fileName); formData.append('chunkTotal', chunkTotal); formData.append('fileSize', file.size); await fetch('/oa_upload/merge_chunks.php', { method: 'POST', body: formData }); }这段代码里最重要的一点是进度计算方式。单次上传的progress事件只针对当前分片,如果直接用e.loaded,进度条会在每个分片之间反复回跳。整个文件的进度必须用“已上传分片序号 × 分片大小 + 当前分片内已上传字节”来算,这样进度条才是稳定递增的。
3.2 断点续传:前端如何知道“上次传到哪里了”
真正实用的版本,必须有断点续传能力。核心问题是:刷新页面或者断网之后,怎么知道哪些分片已经传过了。
我的做法是,后端提供一个查询接口。前端在开始上传之前,先拿文件名和文件大小去问一下服务器:这个文件当前传了哪些分片。然后从缺失的第一个分片开始继续传。
后端查询接口的返回数据如下:
{ "code": 0, "data": { "uploaded": [0, 1, 2, 3], "fileExists": false, "totalChunks": 12 } }front-end收到这个结果后,把currentChunk直接从4开始。前面4个分片不再重复传输,后端也省去了重复接收和校验的IO开销。
前端的续传判断逻辑:
async function initUpload(file) { const formData = new FormData(); formData.append('fileName', file.name); formData.append('fileSize', file.size); formData.append('action', 'status'); const resp = await fetch('/oa_upload/receive_chunk.php', { method: 'POST', body: formData }); const result = await resp.json(); let uploadedSet = new Set(result.data.uploaded); if (result.data.fileExists) { // 服务端已经存在完整文件,直接跳过全部上传 showMessage('秒传成功'); return; } // 从第一个缺失的分片开始传 const startIndex = findFirstMissing(uploadedSet, totalChunks); uploadChunk(startIndex); }这里有个细节值得说一说:“秒传”。如果后端发现某个文件已经传完且大小一致,就直接返回fileExists:true,前端弹一句“秒传成功”,用户压根不用重复上传。我在实际项目里发现,同一台设备同一个SOP视频经常被多个车间重复上传,秒传功能能把服务器压力降一大截。
3.3 并发控制与失败重试策略
上面的基础版是逐片顺序上传,逻辑简单但速度偏慢,尤其在内网延迟低的场景下,等响应的时间白白浪费了。更实际的方案是控制并发数,比如同时传3个分片。
let concurrency = 3; let activeCount = 0; let uploadQueue = []; function startUploads() { for (let i = 0; i < concurrency; i++) { if (uploadQueue.length > 0) { const chunkIndex = uploadQueue.shift(); activeCount++; uploadChunk(chunkIndex).finally(() => { activeCount--; startUploads(); }); } } } function enqueueRemaining() { for (let i = 0; i < totalChunks; i++) { if (!uploadedSet.has(i)) { uploadQueue.push(i); } } startUploads(); }并发数不建议开得太大,3到5是合理区间。并发越大,服务器同时打开的临时文件越多,磁盘IO和内存压力都会上升。制造企业的OA服务器往往还要跑流程审批、报表统计,没必要为一个上传任务把整个服务拖垮。
失败重试的策略也很重要。我采用“每片最多重试3次,失败后记录错误分片,全部结束后统一补传”的方式。这样即使某个分片因为网络抖动连续失败,也不会影响其他分片的正常传输。
4. 后端PHP:接收分片、可靠落盘、最终合成完整视频
4.1 分片接收接口的完整实现
后端接口的第一要务是识别是“查询状态”还是“接收分片”,我这里用action参数区分行为。接收分片时,先把文件名做安全过滤,再按用户维度建立独立的临时目录,避免不同员工上传的相同文件名互相干扰。
<?php // receive_chunk.php // 必须登录才能上传,OA的Session在这里可以直接用 session_start(); if (!$_SESSION['user_id']) { http_response_code(401); exit(json_encode(['code' => 401, 'msg' => '请先登录'])); } $action = $_POST['action'] ?? 'upload'; // 存放根目录,按用户隔离 $userDir = '/data/oa_upload_tmp/' . md5($_SESSION['user_id'] . '_' . $_SESSION['user_name']); // 先处理查询请求 if ($action === 'status') { $fileName = basename((string)($_POST['fileName'] ?? '')); $fileSize = intval($_POST['fileSize'] ?? 0); $totalChunks = intval($_POST['chunkTotal'] ?? 0); $uploaded = []; $fileKey = md5($fileName . '_' . $fileSize); $fileTmpDir = $userDir . '/' . $fileKey; if (is_dir($fileTmpDir)) { foreach (glob($fileTmpDir . '/*.part') as $partFile) { $uploaded[] = intval(basename($partFile, '.part')); } } // 检查完整文件是否已存在:秒传判断 $finalDir = '/data/oa_videos/' . date('Ymd') . '/'; $finalFile = $finalDir . $fileName; $fileExists = file_exists($finalFile) && filesize($finalFile) === $fileSize; echo json_encode([ 'code' => 0, 'data' => [ 'uploaded' => $uploaded, 'fileExists' => $fileExists, 'totalChunks' => $totalChunks ] ]); exit; } // 处理分片接收 if ($action === 'upload') { $fileName = basename((string)($_POST['fileName'] ?? '')); $chunkIndex = intval($_POST['chunkIndex'] ?? -1); $chunkTotal = intval($_POST['chunkTotal'] ?? 0); $fileSize = intval($_POST['fileSize'] ?? 0); if ($fileName === '' || $chunkIndex < 0 || $chunkTotal <= 0) { http_response_code(400); exit(json_encode(['code' => 400, 'msg' => '参数不合法'])); } // 分片大小必须符合预期,拒绝超限数据 if (!isset($_FILES['chunk']) || $_FILES['chunk']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); exit(json_encode(['code' => 400, 'msg' => '分片未正常接收'])); } // 校验扩展名白名单 $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); $allowExt = ['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv', 'webm']; if (!in_array($ext, $allowExt)) { http_response_code(403); exit(json_encode(['code' => 403, 'msg' => '文件类型不允许'])); } // 落盘到用户隔离目录下的文件专属目录 $fileKey = md5($fileName . '_' . $fileSize); $fileTmpDir = $userDir . '/' . $fileKey; if (!is_dir($fileTmpDir)) { mkdir($fileTmpDir, 0750, true); } $partFile = $fileTmpDir . '/' . $chunkIndex . '.part'; // 如果这个分片已经传过,直接返回成功,保证幂等性 if (file_exists($partFile)) { echo json_encode(['code' => 0, 'msg' => '分片已存在']); exit; } if (!move_uploaded_file($_FILES['chunk']['tmp_name'], $partFile)) { http_response_code(500); exit(json_encode(['code' => 500, 'msg' => '分片写入失败'])); } echo json_encode(['code' => 0, 'msg' => 'ok']); exit; }这段代码里有几个细节需要解释一下。
幂等处理是分片上传的重中之重。网络超时可能导致前端重发同一分片,如果后端对重复分片不做处理,合并时同一分片出现两次,文件就毁了。所以在接收分片前先判断文件是否存在,存在就直接返回成功。
临时目录按用户和文件双重隔离,是为了解决两个实际问题:一是多人上传同名文件时互相覆盖,二是如果某个用户的临时文件写权限失控,其他用户的目录不被波及。目录层级是“用户名MD5/文件MD5/序号.part”,虽然目录层级多了一点,但对于GB级视频的可靠性保障是有价值的。
4.2 分片合并接口的实现与优化
当所有分片都传完之后,前端调用合并接口。合并的过程是按顺序读取所有.part文件,追加写入最终视频文件。
<?php // merge_chunks.php session_start(); if (!$_SESSION['user_id']) { http_response_code(401); exit(json_encode(['code' => 401, 'msg' => '请先登录'])); } $fileName = basename((string)($_POST['fileName'] ?? '')); $fileSize = intval($_POST['fileSize'] ?? 0); $chunkTotal = intval($_POST['chunkTotal'] ?? 0); if ($fileName === '' || $chunkTotal <= 0) { http_response_code(400); exit(json_encode(['code' => 400, 'msg' => '参数不合法'])); } $userDir = '/data/oa_upload_tmp/' . md5($_SESSION['user_id'] . '_' . $_SESSION['user_name']); $fileKey = md5($fileName . '_' . $fileSize); $fileTmpDir = $userDir . '/' . $fileKey; if (!is_dir($fileTmpDir)) { http_response_code(400); exit(json_encode(['code' => 400, 'msg' => '没有找到对应的临时分片目录'])); } $finalDir = '/data/oa_videos/' . date('Ymd') . '/'; if (!is_dir($finalDir)) { mkdir($finalDir, 0750, true); } $finalFile = $finalDir . $fileName; // 用追加二进制模式写入,避免一次性读入大文件 $fp = fopen($finalFile, 'ab'); if (!$fp) { http_response_code(500); exit(json_encode(['code' => 500, 'msg' => '无法创建最终文件'])); } for ($i = 0; $i < $chunkTotal; $i++) { $partFile = $fileTmpDir . '/' . $i . '.part'; if (!file_exists($partFile)) { fclose($fp); unlink($finalFile); http_response_code(400); exit(json_encode(['code' => 400, 'msg' => '缺少分片:' . $i])); } // stream_copy_to_stream比file_get_contents更省内存 $src = fopen($partFile, 'rb'); if (!$src) { fclose($fp); unlink($finalFile); http_response_code(500); exit(json_encode(['code' => 500, 'msg' => '分片读取失败:' . $i])); } stream_copy_to_stream($src, $fp); fclose($src); } fclose($fp); // 校验最终文件大小 if (filesize($finalFile) !== $fileSize) { unlink($finalFile); http_response_code(500); exit(json_encode(['code' => 500, 'msg' => '文件大小校验失败'])); } // 清理临时目录 $parts = glob($fileTmpDir . '/*.part'); if ($parts) { foreach ($parts as $p) { unlink($p); } } rmdir($fileTmpDir); echo json_encode(['code' => 0, 'msg' => '合并成功', 'filePath' => $finalFile]);合并这段有两个容易翻车的地方。
第一是内存。有些教程会让用file_get_contents读完整分片再fwrite,5MB分片时问题不大,但如果有人把分片大小调到50MB甚至更高,并发的内存占用就会失控。稳妥的做法就是用fopen加stream_copy_to_stream,它走的是流式拷贝,不会把整个分片一次性拉进内存。
第二是中途失败时的清理。合并过程中如果发现缺少分片,必须把已经写了一半的最终文件删掉,否则留一个半个视频在正式目录里,前端播放器一打开就是坏的,用户还以为是系统做的视频有问题。
4.3 需要调整的PHP与服务器参数清单
下面这份参数配置是这个方案里最容易踩坑的部分,实测下来这样设置最稳妥。
| 配置项 | 建议值 | 说明 |
|---|---|---|
| upload_max_filesize | 10M | 单个分片不超过5MB,10M留出余量 |
| post_max_size | 15M | 必须大于upload_max_filesize,且要考虑FormData里其他参数 |
| max_execution_time | 300 | 合并大文件时给足时间,但也不要无限大 |
| max_input_time | 300 | 接收分片请求时的输入时间限制 |
| memory_limit | 256M | 正常情况下够用,主要是防止恶意大请求 |
| client_max_body_size(Nginx) | 15m | 必须与post_max_size匹配,否则直接413 |
| proxy_read_timeout / proxy_send_timeout(Nginx) | 300 | 如果PHP在代理后面,必须调大,否则代理层掐断连接 |
很多人在这一步犯的错是把upload_max_filesize直接调到2G。分片方案的核心思想就是单请求只需要承载一个分片,完全不需要给PHP开这么大的单请求上传上限。开太大反而增加内存和超时风险,得不偿失。
5. 实操中容易踩的坑:分片大小、文件名、并发与OA集成
5.1 分片大小到底选多大比较合适
分片大小是个需要平衡的决策。我之前试过1MB和20MB两种极端值,各有各的问题。
1MB分片:优点是单请求轻量,失败重传成本低,服务器内存压力小。缺点是要传输的分片数量太多,一个1GB文件要切1024片,每片都要建立TCP连接、发起POST请求、等待响应,总耗时明显偏长,而且分片元数据的管理开销会上升。
20MB分片:优点是请求次数少,传输效率高。缺点是单请求已经触碰Nginx和PHP的配置边界,而且一旦某个分片传了一半断线,重传20MB的成本相当高。
在千兆内网的车间环境里,我最后选了5MB。这个值单请求不会撞上Nginx默认的1MB限制(所以要调),也不需要把PHP参数改得很夸张,同时切出来的分片数量适中,一个300MB的视频只需要60片,10分钟级别的大视频也就200多片,请求量完全可接受。
如果你所在的场景是公网传输,带宽有限,建议把分片降到2MB。公网丢包率更高,小分片的重传成本更低,整体成功率会明显改善。
5.2 中文文件名、空格与特殊字符的处理
设备操作视频的文件名通常长这样:“龙门加工中心换刀操作流程(2025-03-01).mp4”。中文、括号、空格、横线全都齐了。这种文件名直接传给后端,会出现两个问题。
第一是路径问题。PHP的basename函数能过滤掉目录穿越字符,但不同系统的文件系统对特殊字符的支持不同,Windows后端和Linux后端的处理结果可能有差异。我在Linux服务器上就遇到过文件名末尾带空格,导致后续合并时找不到文件的情况。
我的处理流程是:前端在上传时,额外传一个加密后的文件标识字段,后端在临时目录和最终目录都使用这个标识作为存储文件名,只有最终写入OA附件字段时才还原原始文件名。
// 前端生成文件标识 const fileId = md5(file.name + file.size + Date.now()); formData.append('fileId', fileId);后端落盘全部用fileId,彻底避开原始文件名的干扰。展示给用户看的时候,从数据库里读原始文件名显示,两套逻辑各管各的,互不干扰。
5.3 并发上传时的服务器IO压力控制
有一段真实的现场经历。车间里同时有十几位老师傅上传视频,每个人并发3个分片,瞬间就有几十个写入请求打到服务器磁盘。机械硬盘在这种压力下随机写入性能会直线下降,上传速度反而比顺序传还慢。
后来我做了两个改进。第一是前端把并发数从3降为2,显著缓解了IO压力。第二是后端做了分片目录的批量写入优化,先用array_multisort按文件名排序再处理,减少目录跳转。虽然单个分片5MB不算大,但高并发时磁盘寻道时间累计起来的影响还是很明显的。
如果企业预算允许,把上传临时目录放到SSD上是最直接有效的方案。OA服务器哪怕只分一个100GB的SSD分区给临时上传目录,体验也会好很多。
5.4 上传完成之后怎么和OA业务流程打通
分片上传完成只是前半段,后半段是把视频挂到OA的业务单据上。制造企业常见的需求是把视频关联到设备台账、维修工单或培训计划里。
我的做法是在OA的审批表单里放一个自定义按钮,调用自己开发的附件上传页面。视频传完、合并完成后,PHP把文件路径写入OA的数据库表,或者通过OA提供的接口把附件记录关联到当前单据的单据号、流程ID。这样文件本体存在独立存储目录,业务数据在OA里照常流转。
这个方案的优点是文件存储独立,不会因为OA的升级、迁移而丢失视频文件。缺点是OA管理员需要有一定的数据库操作能力,毕竟往业务表里插入数据要根据具体OA的数据结构来调整。但相比把视频塞进OA的附件插件,这种解耦方式在后续扩容时明显更灵活。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 现象 | 直接原因 | 排查与解决 |
|---|---|---|
| 上传请求返回413 | Nginx的client_max_body_size过小 | 检查Nginx配置,确保大于单分片大小,重启Nginx |
| 上传请求报500,日志显示POST Content-Length超过限制 | PHP的post_max_size过小 | 调整post_max_size,必须大于upload_max_filesize |
| 页面白屏或请求超时 | PHP的max_execution_time过短 | 合并操作加长执行时间,建议300秒 |
| 断网刷新后进度条归零 | 前端没有查询已传分片列表 | 实现status查询接口,跳过已传分片 |
| 合并后文件播放一半就断 | 分片顺序错乱或分片缺失 | 检查合并逻辑是否按序号严格顺序写入,检查临时目录是否被清理 |
| 重复上传秒传不生效 | 后端fileExists判断的目录不对 | 确认finalDir和finalFile路径一致,比较filesize是否等于fileSize |
| 中文文件名乱码 | 前端UTF-8和后端编码不一致 | 统一全链路UTF-8,建议显式header设置 |
| 上传进度条走不到100% | 并发下进度计算错误 | 用已传分片数加当前分片内字节计算总进度 |
| 临时目录堆积大量.part文件 | 上传中断后没有清理机制 | 加cron定时任务,清理超过24小时的临时目录 |
6.2 最棘手的一次故障:合并后视频文件损坏
项目上线后遇到一次诡异的故障,用户反馈上传的视频文件大小完全正确,但播放到第4分钟左右画面变黑、声音消失,拖动进度条还会卡死。
刚开始怀疑是分片合并顺序错乱,检查代码后发现循环变量没有问题。最后定位到,问题出在分片接收时的并发写入。两个并发请求可能同时处理同一个分片序号,导致.part文件被覆盖写入了一半新内容一半旧内容,文件大小没变,但内部数据损坏了。
这次故障让我完善了两处设计:一是分片接收接口加文件锁,同一分片同一时间只能有一个写入操作;二是在合并完成后追加了一遍完整性校验——不仅是文件总大小,还要抽样比对几个分片的MD5值。虽然不能100%保证视频可播,但至少能拦截明显的数据错乱。
6.3 上传中断后的“灵异事件”
另一个常见现象是用户传完所有分片之后调用合并接口时报错“缺少分片”。排查发现,临时目录里确实少了一个分片,但前端明明显示所有分片都传完了。
原因出在断网前最后一个分片的HTTP响应没有到达前端。前端认为上传失败,重试时又因为网络瞬断导致重试请求也没发出去,但用户在界面上看到的是进度条已经走满。所以严格来说,前端不能以“所有分片都调用过上传”作为完成标准,必须以“后端status接口返回的已传分片数等于总分片数”为准。
我把这个逻辑加进了前端:所有分片上传完成后,不直接调merge,先查一次status,确认uploaded数组的长度等于totalChunks,再发起合并请求。从此再没有出现过这个灵异问题。
6.4 商业OA平台偶发连接失败错误码的处理思路
用商业OA平台的企业还会遇到一类问题:附件模块在服务端压力大或磁盘读写慢时,返回类似“访问失败”的错误码,文件传一半被强制断开。这类问题的本质往往不是OA代码有bug,而是超长的大文件请求占用了平台内置组件的缓冲区或执行线程。
我的处理思路是把大视频和OA常规附件分离。视频走独立的HTML+PHP分片上传通道,上传完成后只在OA表单里保留文件链接和元数据。OA常规附件继续使用平台自带能力。这样绕开了商业平台对单请求大小的限制,也避免了长连接占用平台进程资源。
7. 安全与合规提示:文件上传功能最容易忽视的边界
7.1 不能只信前端传的MIME和扩展名
视频上传功能的本质是一个文件写入接口,如果不做安全校验,恶意用户可以用它上传PHP脚本、WebShell或者超大垃圾文件。我在接收分片代码里做了三层校验:登录态校验、扩展名白名单校验、分片大小校验。
但扩展名白名单只是第一道防线。攻击者完全可以把一个PHP脚本改名为.mp4上传。所以最终视频存储目录必须禁止执行脚本,最简单的做法是在Nginx配置里对该目录禁用PHP解析:
location /data/oa_videos/ { location ~ \.php$ { return 403; } }配合PHP侧的finfo_file函数读取真实文件类型,如果返回的不是video/*开头的类型,拒绝合并。这样即使上传了伪装文件,也无法在服务器上造成破坏。
7.2 临时目录的定期清理与权限收敛
分片上传中断是常态,每个中断的上传任务都会在临时目录里留下几十个.part文件。如果不管,磁盘迟早被填满。我做了两个机制。
第一是文件权限收敛。上传临时目录和最终视频目录都设置为独立的系统用户,只给750权限,不授予PHP进程所在组的写权限之外的其他权限。这样即使某个目录被攻击者利用,影响范围也被限制在单一目录内。
第二是cron定时清理。每天凌晨3点扫描上传临时目录,删除修改时间超过24小时的目录:
0 3 * * * find /data/oa_upload_tmp -type f -name "*.part" -mtime +1 -delete && find /data/oa_upload_tmp -type d -empty -delete这个脚本保证临时垃圾不过夜,同时不会误删还在进行中的上传任务。上传时间超过24小时的任务本身也该放弃重来了。
7.3 关于元数据记录方式的一个安全建议
在设计分片清单和元数据记录时,我坚持一个原则:不引入XML格式。业界不只一次出现过OA周边组件在处理用户上传的XML文件时,因实体展开问题导致的安全风险。分片上传这类功能本身就涉及大量用户可控的文件名、参数、请求体,攻击面已经不小,没必要再平添一个解析入口。
我的元数据记录方式非常简单:分片序号用目录文件名体现,文件大小、完整状态用数据库行记录,前端续传状态用localStorage加后端查询接口配合。不解析任何用户可控的标记语言,把攻击面降到最低。做文件上传功能,越朴素越安全。
最后再分享一个我个人的建议。分片续传方案上线后,一定要做一轮真实弱网和断网测试。找一台机器故意用网络限速软件制造丢包,或者直接拔网线模拟断连,反复测试十几个大文件上传。分片上传大部分代码路径在正常网络下是永远走不到的,只有异常情况下才会触发重试、恢复、合并失败这些分支。把这些分支跑通,系统才算真正能交付到车间老师傅手里。