去年在给一家煤化工集团做视频监控集中归档系统时,我第一件头疼的事不是摄像头接入,而是视频文件怎么从各分厂传到中心服务器。厂区两百多路监控,单路一小时 1080P 视频就有 1.5GB 到 2GB,NVR 本地存几天后需要集中回传,网络又是车间内网、办公网多段隔离,传一半掉线是家常便饭。试过普通 HTTP 上传,断一次就得从头传,运维人员直接在群里骂街。最后是 WebUploader + PHP 的分片断点续传方案救场:文件切成分片并发上传,任一分片传完即落盘,断线后只补剩余分片。我干脆把这套方案的选型配置、接口设计、合并逻辑和踩坑记录一次写透,适合正在给企业内网做视频归档、大文件回传的 PHP 工程师参考。
1. 监控视频上传场景里的真实痛点:为什么普通上传方案会"翻车"
1.1 视频文件又大又多,默认配置先崩为敬
监控视频不是普通办公文档。按 H.264 编码、4Mbps 码率计算,一小时文件体积 = 4Mbps × 3600 秒 / 8 = 1.8GB。厂里摄像头多,一天集中回传个几十路就是几十 GB。这时候用 PHP 默认配置做上传,几乎是必死局:upload_max_filesize 默认 2M,post_max_size 默认 8M,max_execution_time 默认 30 秒。就算把这三个值改成 2048M / 2048M / 600,也只是把"能传"的窗口放大,没法解决"断了就前功尽弃"的问题。
更大的隐患在进程模型。PHP-FPM 处理大文件上传时,如果走传统的 $_FILES 一次性接收,整个请求会长时间占用一个 worker,请求体落在临时目录再 move 到目标位置。期间网络抖动、超时、nginx 的 client_max_body_size 限制,任何一个环节出问题,前端拿到的就是一个 502 或空响应,用户只能重新选文件再来一遍。
1.8GB 的文件在 10Mbps 带宽下理想耗时约 24 分钟,一旦传了 20 分钟断开,重传又要 24 分钟,等于做了 44 分钟的无用功。这就是"大而多"带来的第一重痛苦:不是不能传,是传不起、重传更痛苦。
1.2 厂区网络的"间歇性失联"是最大的变数
能源化工企业厂区面积动辄几平方公里,生产区、罐区、装卸区、办公区各有各的网络条件。车间里可能是光纤到交换机,但跨区域回传要走主干链路;部分点位靠工业级无线网桥或 4G/5G 工业网关,信号受天气、大车遮挡、电磁干扰影响很明显。运维人员拿着平板在装置区巡检时,网络质量更是飘忽不定。
这种环境下,任何"单次长连接持续十几分钟"的方案都不可靠。断线重连、IP 变化、代理超时,都会把普通上传打断。分片断点续传的天然优势就在这里:把 1.8GB 的文件切成 10MB 一个小块,单次请求只有几秒到十几秒,单个分片失败只影响那一片,重试成本极低。就算断了 10 次,前面传过的分片还在服务器临时目录里,恢复后只补缺口。
1.3 安全生产流程对视频有"不可缺帧"的要求
监控视频在能源化工企业里不只是"看了玩"的东西。它经常作为安全检查、异常事件追溯、操作规范抽查的依据,视频文件必须是完整、可连续播放的,不能出现花屏、中断、缺流。普通上传中断后,服务器上往往残留一个损坏的半截文件,如果不做校验,后面追溯时才发现不能播,那时候原始文件可能已经被覆盖了。
分片模式有助于规避这个问题:服务端可以逐片校验,合并时按顺序写入,合并完成后再做一次文件大小和 MD5 校验,确保落地的视频和源文件完全一致。这也是为什么我在这类项目里坚持用"分片 + 合并 + 校验"而不是简单调大上传限制。
2. WebUploader 前端选型与核心配置:分片、并发与 MD5 指纹
2.1 为什么从一堆上传组件里挑中 WebUploader
做企业内网系统,选前端上传组件我有几个硬指标:必须支持分片、必须有并发控制、必须能拿到文件唯一标识来做断点续传、最好不依赖公有云。市面上的方案里,vue-uploader 和 element 的 upload 组件对分片支持偏弱;xhr 手写分片逻辑需要自己处理队列、重试、合并状态机,维护成本高;阿里云 OSS SDK 虽然能力强,但企业内网私有化部署不一定允许引入外部云依赖。
| 对比维度 | WebUploader | 手写 XHR 分片 | 公有云 OSS SDK |
|---|---|---|---|
| 分片上传 | 成熟,开箱即用 | 需自己实现 | 成熟 |
| 并发控制 | threads 参数直接控 | 需自己写队列 | 有并发限制策略 |
| 断点续传 | 支持 MD5 状态恢复 | 需自己维护状态表 | 依赖云平台 |
| 私有化部署 | 纯内网可用 | 纯内网可用 | 不一定允许 |
| 维护成本 | 低,但依赖 jQuery | 高 | 中 |
WebUploader 虽然是百度 FEX 团队的老项目,官方已经停止维护很多年,但它的核心能力在"内网私有化大文件上传"这个赛道上依然能打:基于 HTML5 File API 实现分片,保留 Flash 降级(虽然现在基本用不上),提供完整的文件队列生命周期、事件回调、上传进度和停止/继续机制。社区里大量生产系统都用它,稳定性经过了充分验证。
选老项目也有代价:WebUploader 依赖 jQuery,和现代前端打包工具集成时有点别扭。但企业后台本来就是 jQuery 时代留下的项目,反而配合得很顺。如果你要在一个全新 React/Vue 项目里用它,建议用官方 dist 文件挂全局变量,别强行 import。
2.2 分片大小、并发数与重试参数怎么设
监控视频文件大,分片大小不能拍脑袋。我推荐 chunkSize 设置在 5MB 到 10MB 之间:太小则请求数量爆炸,一个 1.8GB 文件如果用 1MB 分片就是 1800 个请求,服务端日志和 MySQL 压力都大;太大则失去分片意义,弱网下单个分片传输时间边长,失败重试成本升高。
并发数 threads 建议 2 到 3。很多人觉得并发越高越好,但 PHP-FPM 的 worker 是有限的,5 个并发上传乘以 3 路分片,瞬间占满 15 个 worker,正常业务接口全被堵死。内网千兆环境下,3 路并发足以打满带宽;弱网环境下,并发太高反而容易触发网络拥塞。
下面是核心配置片段,我把它整理成可直接抄的版本:
var uploader = WebUploader.create({ swf: '/static/Uploader.swf', server: '/api/video/upload.php', pick: '#picker', accept: { title: 'Video', extensions: 'mp4,avi,flv,mov,ts', mimeTypes: 'video/*' }, chunked: true, chunkSize: 10 * 1024 * 1024, threads: 3, fileVal: 'file', formData: { type: 'monitor_video' }, fileNumLimit: 10, fileSizeLimit: 10 * 1024 * 1024 * 1024, duplicate: true });fileSizeLimit 设成 10GB 是因为有些长时段录像文件确实大;fileVal 保持默认 'file' 即可,后端按 $_FILES['file'] 接收。
2.3 MD5 指纹:断点续传的真正钥匙
分片能解决"传一半断了",但要实现"继续传而不是重头传",必须让服务器知道"你手里已经有哪几个分片了"。WebUploader 的做法是:在文件加入队列后,先计算整个文件的 MD5 值,用这个 MD5 作为文件的唯一标识,上传时带给后端。
计算 MD5 要依赖 spark-md5,它也是 WebUploader 官方示例里的标配。对大文件,整文件 MD5 计算会吃满 CPU 并且卡住浏览器,我建议在 start 回调里通过 setTimeout 把计算过程放到异步队列,避免 UI 假死;如果文件超过 2GB,可以考虑分块渐进计算 md5(spark-md5 支持 incremental append),算到哪显示到哪。
uploader.on('fileQueued', function (file) { // 先检查是否已计算过 md5,避免重复算 var cached = localStorage.getItem('md5_' + file.name + '_' + file.size); if (cached) { file.md5 = cached; return; } uploader.md5File(file) .progress(function (p) { // 更新进度提示 }) .then(function (val) { file.md5 = val; localStorage.setItem('md5_' + file.name + '_' + file.size, val); }); });注意 localStorage 缓存只适合文件大小 + 文件名能唯一确定同一物理文件的场景。如果不放心,可以在整个队列上传完后删除该缓存,或者用文件 mtime 参与 key 拼接。
后端拿到 MD5 最直接的收益是:服务器用 MD5 作为临时目录名,同一文件重复上传时可以直接复用已传分片,后端判断分片已存在就跳过,用户下次再拖同一个文件进来,甚至能实现"秒传"。
3. PHP 后端如何接收分片并合并成完整视频
3.1 接口协议约定:参数、路径与返回格式
前端 WebUploader 在 chunked 模式下,每个分片请求都会带上分片上下文参数。服务端接口按这个约定解析:
// 来自前端分片请求的公共参数 $chunk = isset($_POST['chunk']) ? (int)$_POST['chunk'] : 0; $chunks = isset($_POST['chunks']) ? (int)$_POST['chunks'] : 1; $md5 = isset($_POST['md5']) ? trim($_POST['md5']) : ''; $name = isset($_POST['name']) ? trim($_POST['name']) : ''; $size = isset($_POST['size']) ? (int)$_POST['size'] : 0;返回格式统一为 JSON:
{ "code": 0, "msg": "ok", "data": { "chunk": 0, "uploaded": true } }前端的 uploadSuccess 回调会根据返回的信息决定是否继续下一分片。这里有个容易踩的坑:如果服务器返回的不是合法 JSON 或者 Content-Type 不对,WebUploader 可能把成功响应当失败,导致死循环重试。响应时一定要显式输出 JSON 并结束。
3.2 分片落盘:目录设计与校验逻辑
临时目录我建议设计成"业务类型 / 用户标识 / 文件MD5"三层结构。比如:
/data/web_upload/monitor/user_1001/3f9e2c1a.../chunk_0.tmp /data/web_upload/monitor/user_1001/3f9e2c1a.../chunk_1.tmp按用户分目录能避免不同用户的同名文件互相干扰,按 MD5 分目录是断点续传和秒传的基础。接收单个分片的代码要点如下:
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { exit(json_encode(['code' => 1, 'msg' => '分片上传失败'])); } $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $allow = ['mp4', 'avi', 'flv', 'mov', 'ts']; if (!in_array($ext, $allow, true)) { exit(json_encode(['code' => 1, 'msg' => '文件类型不允许'])); } $tmpDir = '/data/web_upload/monitor/user_' . (int)$uid . '/' . $md5; if (!is_dir($tmpDir)) { mkdir($tmpDir, 0755, true); } // 校验分片索引范围 if ($chunk < 0 || $chunks < 1 || $chunk >= $chunks) { exit(json_encode(['code' => 1, 'msg' => '分片参数非法'])); } $target = $tmpDir . '/chunk_' . $chunk . '.tmp'; // 分片幂等:已存在则直接跳过 if (is_file($target) && filesize($target) > 0) { exit(json_encode(['code' => 0, 'msg' => 'ok', 'data' => ['chunk' => $chunk, 'duplicated' => true]])); } if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) { exit(json_encode(['code' => 1, 'msg' => '分片保存失败'])); } exit(json_encode(['code' => 0, 'msg' => 'ok', 'data' => ['chunk' => $chunk]]));两点必须注意:第一,$name 只是用来取扩展名,不要用它拼接服务器路径,否则 path traversal 直接被打穿;第二,md5 参数要校验格式,正则匹配 32 位 hex,防止恶意构造目录名。
3.3 合并实现:别把 2GB 文件一次性读进内存
最后一个分片传完后,前端会发一个合并请求(或由服务端在检测到分片数量足够时自动触发)。合并的常规做法是按 chunk 序号从小到大,把每个分片以流式追加写入目标文件。
function mergeVideoChunks($tmpDir, $targetFile, $chunks, $expectedSize) { $out = fopen($targetFile, 'wb'); if (!$out) { throw new RuntimeException('无法创建目标文件'); } // 加锁,防止多个请求同时触发合并 $lockHandle = fopen($targetFile . '.lock', 'w'); flock($lockHandle, LOCK_EX); for ($i = 0; $i < $chunks; $i++) { $chunkFile = $tmpDir . '/chunk_' . $i . '.tmp'; if (!is_file($chunkFile) || filesize($chunkFile) === 0) { fclose($out); flock($lockHandle, LOCK_UN); throw new RuntimeException("第 {$i} 个分片缺失"); } $in = fopen($chunkFile, 'rb'); while (!feof($in)) { // 8MB 缓冲流式写入,避免内存暴涨 fwrite($out, fread($in, 8 * 1024 * 1024)); } fclose($in); unlink($chunkFile); // 合并完删除分片,节省磁盘 } fflush($out); fclose($out); flock($lockHandle, LOCK_UN); fclose($lockHandle); unlink($targetFile . '.lock'); // 合并后校验总大小 if (filesize($targetFile) !== $expectedSize) { unlink($targetFile); throw new RuntimeException('合并后文件大小不一致'); } }合并完成后记得做一次最终校验:文件大小与前端传入的 size 一致,再算一次整体 MD5 与前端 md5 比对。这一步能挡住绝大多数传输和合并过程中的静默损坏。在视频归档场景里,宁可慢几秒校验,也不要收下一个不能播的文件。
4. 断点续传的状态探测与异常恢复处理
4.1 已上传分片探测接口:让前端"知道手里还有什么"
断点续传的核心不是"重传所有分片",而是"只传缺失的分片"。所以服务端要提供一个查询接口,输入 MD5,返回该文件已上传的分片序号数组。
// GET /api/video/status.php?md5=xxx&uid=1001 $md5 = isset($_GET['md5']) ? trim($_GET['md5']) : ''; $tmpDir = '/data/web_upload/monitor/user_' . (int)$uid . '/' . $md5; $loadedChunks = []; if (is_dir($tmpDir)) { foreach (glob($tmpDir . '/chunk_*.tmp') as $f) { if (preg_match('/chunk_(\d+)\.tmp$/', basename($f), $m)) { $loadedChunks[] = (int)$m[1]; } } } sort($loadedChunks); echo json_encode(['code' => 0, 'data' => ['loaded' => $loadedChunks, 'total' => count($loadedChunks)]]);前端在文件真正开始上传前,先查询这个接口,把已存在的分片从任务队列中摘除或标记为完成。WebUploader 的 beforeFileQueued 和 uploadStart 阶段都可以做,推荐在 uploadStart 时查一次,因为此时文件 MD5 已经算出来了,查询参数齐全。
4.2 断线恢复的完整流程与幂等保障
实际恢复时的场景是这样的:用户上传 180 个分片,传到第 67 片时网络断开。WebUploader 会触发 error 回调,我们把错误信息转成"上传已暂停"的提示,并保留文件在队列里。用户重新点击继续后,uploader 重新进入队列流程,先用缓存 MD5 查询 status 接口,拿到 loaded 数组 [0..66],然后从第 67 片开始上传。
这里有两个坑要提醒。第一个坑是 WebUploader 默认在文件重新上传时会把队列里已完成的状态重置,你可能需要手动设置 file.setStatus('complete') 跳过已传分片,或者干脆用 loaded 数组做局部判断:
uploader.on('uploadBeforeSend', function (file, data, header) { // 如果该分片已存在服务器,取消本次发送 if (window.loadedChunks && window.loadedChunks.indexOf(data.chunk) > -1) { return false; // 阻止发送 } });第二个坑是服务端必须保证分片写入的幂等性。同一个分片可能因为前端超时重试而被提交两次,后端在上面的落盘代码里已经做了"存在则跳过"的判断,重复提交不会造成文件叠加,也不会报错。如果后端不加这个判断,两个请求同时写一个文件,就可能出现分片内容被截断或文件损坏。
4.3 过期临时分片的清理
断点续传依赖临时分片一直留在服务器上,但也不能无限留。网络太差的用户可能传了一半就放弃了,这些孤儿分片会一直占用磁盘。我在项目里用 crontab 每半小时扫一次临时目录,删除创建时间超过 48 小时、且没有对应完整文件的目录。
# 清理超过48小时未完成的分片目录 find /data/web_upload -type f -name 'chunk_*.tmp' -mtime +2 -delete find /data/web_upload -type d -name '[0-9a-f]*' -empty -mtime +2 -delete注意清理脚本要避开"正在上传"的活跃分片,mtime +2 已经留了足够宽裕的时间窗口,正常上传流程不会受影响。
5. 能源化工部署环境下的性能、安全与运维细节
5.1 并发上传控制与文件锁
内网系统平时并发不高,但集中归档时段(半夜批量回传)可能同时有几十个文件在上传。PHP-FPM 的 worker 数量有限,建议把上传接口的并发限制前置到 Nginx 或应用层计数器,防止上传任务挤爆正常业务接口。
合并操作尤其要加锁。两个用户上传同一个文件(MD5 相同)时,服务端可能同时触发两次合并,如果不加锁,两个进程同时向同一个目标文件写入,轻则文件内容混乱,重则直接报错。我上面的合并代码里已经用了 flock 对.lock文件加锁,这个锁要覆盖整个合并循环,不能只在开头加一下。
5.2 身份认证、类型白名单与路径安全
企业内网系统也不能裸奔。上传接口必须校验登录态或 API Token;推荐对上传接口单独加一个签名:前端带上用户 ID 和取自 session 的一次性 token,后端校验通过才接受分片。
文件类型白名单要做得比"看扩展名"严格一层。监控视频就那几种封装格式:mp4、avi、flv、mov、ts,其余一律拒绝。扩展名一律用服务端从原始文件名提取后强制小写,禁止把原始文件名拼进路径。最终归档文件名由服务端生成,格式可以用{日期}/{用户ID}/{MD5}.mp4,这样既避免重名覆盖,也避免路径穿越。
5.3 磁盘空间预估与监控
大文件分片上传会把临时磁盘占用放大。一个 2GB 视频,分片全部落在临时目录后,合并完成前最多占 2GB 加一点余量。如果同时有 20 个上传任务,临时区就有 40GB 以上。上线前我建议单独挂一块大盘给上传临时区,和生产数据分区分离,避免磁盘打满影响业务库。
顺手做了个简单的磁盘检测接口:上传前检查分区剩余空间,低于 10GB 时拒绝新任务并返回提示。监控告警直接复用企业内部运维平台的磁盘监控即可,关键是临时目录路径要纳入监控范围,很多人只盯主数据盘,忽略了上传临时目录。
5.4 PHP 配置与 Nginx 配合要点
最后补一段配置层面的提醒。PHP 的 upload_max_filesize 不需要设置成整个文件大小,但必须大于单个分片大小。比如 chunkSize 是 10MB,建议设置为 20MB,留出表单字段的余量;post_max_size 同理。max_execution_time 要放宽到 120 秒以上,因为分片合并大文件时可能超过默认 30 秒。
Nginx 的 client_max_body_size 也要对应调整,如果它小于单个分片请求体,所有分片上传都会被 413 挡住。我建议设置成 25m(略大于 20MB 的 PHP 限制)。
6. 几组实测数据与踩过的坑
6.1 弱网模拟下的续传效果
用一个 1.8GB 的 1080P 监控视频段做实测:服务器千兆内网,客户端模拟 2Mbps 限速 + 20% 丢包环境。10MB 分片,188 片总量,3 路并发。第一次传到第 67 片时强制断网,此时已传输约 640MB。普通上传方案必须从头再传,总共要传 2 份完整数据,约 1.8GB × 2,耗时约 48 分钟;分片续传方案只补剩余 121 片,传输量约 1.16GB,耗时约 29 分钟,省了近 40% 的时间和一半以上的无效流量。
在实际生产环境里,弱网波动是常态,断两三次之后,分片续传的优势会拉到非常夸张的程度。这也是我后来跟运维强调"上传中断不要慌,点继续就行"的底气。
6.2 我在这个项目里踩过的四个坑
第一个坑是 WebUploader 计算大文件 MD5 时浏览器直接假死。2GB 文件的整文件 MD5 在普通笔记本上要跑几十秒,期间页面点不动。解决方式是分块渐进计算,并且在计算期间显示进度,别用整文件一次性算完再响应。
第二个坑是合并时用 file_get_contents 一次性读取整个分片再 file_put_contents 追加。单个分片 10MB 没问题,但如果有人把 chunkSize 调成 50MB,并发 3 路合并时内存峰值可以到几百 MB,PHP-FPM 直接 OOM。改成流式 fread/fwrite 之后内存占用稳定在 10MB 左右。
第三个坑是 Nginx 的 client_max_body_size 没改,前端明明配置了 10MB 分片,却总是 413。排查时先看 Nginx error log,这类问题一分钟就能定位。
第四个坑是合并时发现分片序号对不上。后来排查是前端在断线重连时把 queue 里已完成的分片状态重置,重新发送了已存在的分片;后端幂等判断存在则跳过才把这个现象压下去。所以务必要做幂等,不能依赖前端保证"每个分片只传一次"。
最后再分享一个小细节:归档视频进入对象存储或 NAS 后,最好在业务库里记录文件 MD5、大小、原始文件名、上传用户、上传时间、合并完成时间和校验结果。这个表就是整个追溯链路的底账,后面做审计、做安全事件复盘,全靠它。别偷懒,字段多几个没有坏处。