1. 项目背景与核心思路拆解
先说结论:视频文件分片上传这件事,前端再怎么花哨,后端都得老实接住每一个分片、记好顺序、最后拼起来。WebUploader负责把大视频切成小块、一个一个发,PHP负责在服务器上把这些小块收好,等全部到齐后按顺序合成完整文件。这个模式是我在大文件上传场景里用得最多的一套方案,尤其是视频文件动辄几百MB甚至几个GB的时候,不分片基本寸步难行。
为什么必须分片?想想传统表单上传,一个文件一旦超过PHP配置的upload_max_filesize和post_max_size,直接报错或者默默丢弃,让用户重传一个200MB的视频,体验就是灾难。分片之后,每个小片只有几MB,即使某一个传输失败,只需要重传那个失败的小片,而不是整个文件。而且分片能解决服务器超时问题,Nginx和PHP的默认超时时间通常不适合长时间的大文件上传,分成多次请求后,每次请求都能在合理时间内完成,不会被中途掐断。
这套方案适合谁?适合正在做视频上传网站、企业内部平台、教育课件系统、直播回放管理这类场景的开发者。无论你是前端为主还是后端为主,理解分片上传的完整链路,能帮你在排查问题的时候少熬夜。我下面写的所有代码和参数,都是我实际跑通过的一套配置,你可以直接抄作业,也建议在抄之前花五分钟理解每个环节在干什么。
2. 前端实操:WebUploader初始化与分片配置
2.1 初始化参数示例与解释
WebUploader的初始化并不复杂,但有一组关键参数直接决定分片行为。我经常看到有人漏掉chunked和chunkSize,导致上传走成了普通流程。下面是一份我常用的初始化代码:
var uploader = WebUploader.create({ // swf文件路径,使用HTML5上传时也需要这个属性占位 swf: '/path/Uploader.swf', // 接收分片的后端地址 server: '/upload/chunk', // 是否分片上传,必须为true chunked: true, // 每个分片的大小,视频我一般设为5MB chunkSize: 5 * 1024 * 1024, // 队列长度 threads: 3, // 上传并发数 fileNumLimit: 5, fileSizeLimit: 4 * 1024 * 1024 * 1024; // 限制单文件4GB // 选择文件后自动开始上传 auto: true, // accept伪参数,实际用于类型校验 accept: { title: 'Videos', extensions: 'mp4,avi,mov,mkv,flv', mimeTypes: 'video/*' } });这里最核心的是chunked和chunkSize。chunked为true代表启用分片,chunkSize决定每个分片多大。5MB是一个平衡点:太小会频繁发请求,增加服务端IO压力;太大会失去分片的意义,一个请求接近5MB一般不会触发Nginx的client_max_body_size限制,默认通常就是5MB或更大。如果你需要更细的控制,可以设置chunkRetry为3,这样某个分片失败后会自动重试三次,对不稳定的网络环境很实用。
另一个容易忽略的参数是threads,它控制同时上传几个分片。对于视频这种大文件,建议设为2到3,并发太高会压垮服务器,太低会浪费带宽。我在实际项目中用过threads: 3,20MB/s的带宽下,1GB的视频大概两三分钟传完,效果是比较稳的。
2.2 自定义上传事件与进度条
WebUploader提供了一套完整的事件机制,你需要监听的不是singleUpload而是uploadAccept和uploadError。分片上传时,每次传输一个小块都会触发uploadProgress,但那个进度是分片级别的,如果你想做整体进度条,需要自己算。
我一般会这样绑定事件:
uploader.on('uploadProgress', function (file, percentage) { // percentage只是当前分片的进度,不能直接使用 console.log('chunk progress: ' + percentage); }); uploader.on('startUpload', function () { // 记录整体开始时间 }); uploader.on('uploadFinished', function () { // 整体上传完成,可以通知后端合并 doMergeRequest(); }); uploader.on('uploadAccept', function (object, ret) { // 每个分片上传完成后的回调,ret是后端返回的json // 如果后端返回失败状态,可以在这里中断后续上传 });整体进度条的正确做法是靠文件的总大小和已上传分片的大小来计算。每上传完一个分片,就把这个分片的大小累加到一个变量里,用累加值除以文件总大小就是整体进度。最开始我偷懒直接用percentage,结果进度条跳来跳去,后来才意识到分片和整体是两个维度的事。
uploadAccept很关键,它是后端和前端交互的一个窗口。后端在处理完分片后可以返回一个JSON,比如{"status": success}或{"status": fail, "chunk": 0}。如果这里是失败,WebUploader会自动重试当前分片。利用这个机制,你可以在后端做校验,比如检查分片大小是否符合预期,不符合就返回失败,前端会自动重传,不需要整体重来。
2.3 上传前的参数准备
在真正开始上传前,你需要给WebUploader注入一些额外参数。尤其是断点续传的场景,前端必须知道当前分片属于哪个文件,否则后端无法将分片归组。
我通常会这么写:
uploader.on('beforeUpload', function (file) { // 生成一个唯一的文件标识,可以用文件名+大小+hash var fileKey = md5(file.name + file.size + file.lastModifiedDate.getTime()); file.fileKey = fileKey; uploader.options.formData = { fileKey: fileKey, fileName: file.name, fileSize: file.size }; });这里只是为了演示思路,真正做项目时可以用一个简单的字符串拼接,不一定非要用md5。但需要注意,fileKey必须在所有分片请求中保持一致,后端就是靠它来归组分片的。如果你不做这个参数,分片上传的所有请求就只有chunk编号,后端根本无法判断哪些分片是一个文件的,上传就会乱套。
另外,如果涉及用户权限,可以在formData里带token。每次上传分片时都会携带formData,后端可以通过token验证身份,确保不是匿名用户把大文件塞到你的服务器上。不要依赖前端传的用户ID,因为那可以伪造。
3. PHP后端:接收分片与传统上传的区别
3.1 后端接口设计:接收分片并保存为临时文件
当WebUploader把分片POST到PHP接口后,你接收到的不是完整文件,而是HTTP请求中附带的分片数据。在PHP里,你可以通过$_FILES['file']来拿到这个分片。WebUploader默认把分片字段名设为file,如果你改过参数,就要对应调整。
下面是我常用的接收分片代码:
public function receiveChunk(Request $request) { $fileKey = $request->input('fileKey'); $chunkIndex = $request->input('chunk', 0); $chunks = $request->input('chunks', 1); $uploadFile = $request->file('file'); if (!$uploadFile || !$uploadFile->isValid()) { return response()->json(['status' => 'fail', 'msg' => '无效的上传分片']); } $tmpDir = storage_path('app/tmp/' . $fileKey); if (!is_dir($tmpDir)) { mkdir($tmpDir, 0777, true); } $tmpPath = $tmpDir . '/' . $chunkIndex; $uploadFile->move($tmpPath); return response()->json(['status' => 'success', 'chunk' => $chunkIndex, 'chunks' => $chunks]); }这里的核心不是移动文件本身,而是如何组织临时文件的命名。我习惯用fileKey + chunkIndex作为临时文件名,一个文件的所有分片都放在以fileKey命名的目录下。这样在最后合并时,我只需要遍历这个目录,按数字排序分片文件,然后依次写入目标文件即可。
有一个容易踩的坑是,PHP的move方法在跨文件系统时可能有问题。如果服务器设置了多个存储盘,确保临时目录和最终目录在同一个文件系统上,否则move会失败或者变成复制,浪费大量IO。我一开始没注意,结果分片明明上传成功,合并时却总是找不到对应路径。
3.2 合并分片:按顺序拼接、校验完整性
合并分片的动作通常是由前端在最后一个分片上传完成后发起的,但更稳妥的做法是后端检查:当接收到的分片数量等于前端上报的chunks总数时,自动触发合并。我建议用后端自动合并,这样即使前端漏发请求也不会卡住。
合并代码如下:
public function mergeChunks(Request $request) { $fileKey = $request->input('fileKey'); $fileName = $request->input('fileName'); $chunks = (int)$request->input('chunks'); $tmpDir = storage_path('app/tmp/' . $fileKey); $finalPath = storage_path('app/uploads/' . $fileName); if (!is_dir($tmpDir)) { return response()->json(['status' => 'fail', 'msg' => '临时目录不存在']); } $fileHandle = fopen($finalPath, 'wb'); if (!$fileHandle) { return response()->json(['status' => 'fail', 'msg' => '无法创建目标文件']); } for ($i = 0; $i < $chunks; $i++) { $chunkPath = $tmpDir . '/' . $i; if (!file_exists($chunkPath)) { fclose($fileHandle); response()->json(['status' => 'fail', 'msg' => '缺少分片:' . $i]); } $chunkContent = file_get_contents($chunkPath); fwrite($fileHandle, $chunkContent); unset($chunkContent); // 释放内存 } fclose($fileHandle); // 合并完成后清理临时目录 array_map('unlink', glob("$tmpDir/*")); rmdir($tmpDir); return response()->json(['status' => 'success', 'path' => $finalPath]); }这个合并逻辑里有几个关键设计。第一,分片顺序严格按照数字序号,所以我在接收分片时保存的文件名就是纯数字,不要在前面拼chunk_这样的前缀,排序时数字顺序才能保证正确。第二,file_get_contents读取每个分片然后fwrite到目标文件,分片是5MB的话,一次读一个分片,内存占比很小,不容易拖垮PHP进程。如果你把整个合并逻辑写成一个循环读取一个大文件,内存会直接爆掉。
校验完整性是容易被忽略的环节。合并完成的视频文件,能不能让播放器正常解码,取决于所有二进制数据是否完整。最基本的校验是比较最终文件大小是否等于所有分片大小的总和。更可靠的做法是在前端使用WebUploader时,计算出整个文件的md5值,后端在所有分片上传完成后,再去计算合并后文件的md5并比对。不过计算大文件的md5需要遍历全文件,性能代价比较大,我通常在文件小于2GB时才这么做,再大就退化为校验大小和头尾数据块。
3.3 安全与性能考量
后端接收分片看似独立操作,实际上有很多安全隐患需要处理。
先说文件类型验证。不能光看$_FILES里的MIME类型,因为那个字段是可以任意伪造的。我一般会分两步:第一步在接收分片时,限制前端上报的文件名后缀,只允许mp4、mov、avi等视频扩展名;第二步在合并完成后,用finfo_file读取真实文件头信息,判断是否真的是视频。如果真实验证发现不是视频,直接删除合并文件并返回错误。只做第一步而不做第二步,等于留给别人一个上传任意文件的后门。
再说大小限制。虽然分片只有5MB,但分片总数可以无限多,如果有人写脚本不断上传分片,你的临时目录会被塞满。建议在接收分片时,根据fileSize参数做一个全局限定。又或者用fileKey做目录,每次接收时检查这个目录下已有分片的数量,超出阈值就拒绝继续接收。
性能方面,视频文件分片多,每个分片都落盘一次临时文件,合并时又要全读一遍再写一遍,IO次数约等于分片数的两倍。如果服务器是机械硬盘,并发写多个分片目录会造成严重的磁盘争用。我建议在前端把threads设小一点,后端则可以对同一fileKey的分片做串行处理,也就是不要用多进程处理同一文件的多个分片。在PHP-FPM模式下,这天然是串行的,但如果你引入了队列或异步处理,就要手动加互斥锁。
4. 核心代码落地:完整的前后端示例
4.1 前端JS完整代码(含注释)
为了让你能直接跑通,我把完整的前端代码放在这里。这段代码依赖jQuery和WebUploader,记得先引入这两个库:
<script src="/js/jquery.min.js"></script> <script src="/js/webuploader.min.js"></script>$(function () { var uploader = WebUploader.create({ swf: '/uploader/Uploader.swf', server: '/upload/chunk', pick: '#picker', accept: { title: 'Videos', extensions: 'mp4,avi,mov,mkv', mimeTypes: 'video/*' }, fileNumLimit: 3, fileSizeLimit: 4 * 1024 * 1024 * 1024, chunked: true, chunkSize: 5 * 1024 * 1024, threads: 3, auto: false, formData: { token: 'your-token' } }); // 上传前构建文件标识 uploader.on('beforeUpload', function (file) { var key = makeFileKey(file); uploader.options.formData.fileKey = key; uploader.options.formData.fileName = file.name; uploader.options.formData.fileSize = file.size; }); // 每个分片上传后的回调 uploader.on('uploadAccept', function (file, ret) { if (ret && ret.status === 'success') { return true; } return false; // 返回false会让该分片重试 }); // 整体上传完成 uploader.on('uploadFinished', function () { var fileKey = uploader.options.formData.fileKey; var fileName = uploader.options.formData.fileName; var chunks = uploader.options.formData.chunks; $.post('/upload/merge', { fileKey: fileKey, fileName: fileName, chunks: chunks }, function (resp) { if (resp.status === 'success') { alert('上传并合并完成'); } else { alert('合并失败:' + resp.msg); } }); }); // 手动触发上传 $('#uploadBtn').on('click', function () { uploader.upload(); }); function makeFileKey(file) { var raw = file.name + file.size + file.lastModifiedDate.getTime(); var hash = 0; for (var i = 0; i < raw.length; i++) { var chr = raw.charCodeAt(i); hash = ((hash << 5) - hash) + chr; hash = hash & hash; } return file.name + '_' + Math.abs(hash); } });注意我在uploadFinished里用了一个uploader.options.formData.chunks,这个chunks其实是在上传过程中由WebUploader内部计算的。如果你没有在别的地方设置,这里可能拿到的是undefined。稳妥的做法是在beforeUpload里自己算:var totalChunks = Math.ceil(file.size / uploader.options.chunkSize);然后把totalChunks存到一个外部变量里。上面的代码主要是演示逻辑,你实际使用时务必补上这个计算,否则会在合并请求时报错。
4.2 PHP端核心函数代码
后端我用原生PHP风格写,方便你迁移到Laravel、ThinkPHP等框架。主要拆成两个接口:接收分片和合并文件。
// 接收分片 public function uploadChunk() { $fileKey = $_POST['fileKey'] ?? ''; $chunk = $_POST['chunk'] ?? 0; $chunks = $_POST['chunks'] ?? 1; $fileName = $_POST['fileName'] ?? ''; if ($fileKey === '' || !isset($_FILES['file'])) { echo json_encode(['status' => 'fail', 'msg' => '参数错误']); return; } $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); $allowed = ['mp4', 'avi', 'mov', 'mkv', 'flv']; if (!in_array($ext, $allowed)) { echo json_encode(['status' => 'fail', 'msg' => '不支持的视频格式']); return; } $tmpDir = __DIR__ . '/tmp/' . $fileKey; if (!is_dir($tmpDir)) { mkdir($tmpDir, 0777, true); } // 防止超过单个文件最大分片数,避免恶意请求 $maxChunks = 1000; if ($chunks > $maxChunks) { echo json_encode(['status' => 'fail', 'msg' => '分片数量异常']); return; } $tmpPath = $tmpDir . '/' . (int)$chunk; if (move_uploaded_file($_FILES['file']['tmp_name'], $tmpPath)) { echo json_encode([ 'status' => 'success', 'chunk' => $chunk, 'chunks' => $chunks ]); } else { echo json_encode(['status' => 'fail', 'msg' => '保存分片失败']); } } // 合并分片 public function mergeChunks() { $fileKey = $_POST['fileKey'] ?? ''; $fileName = $_POST['fileName'] ?? ''; $chunks = $_POST['chunks'] ?? 0; $tmpDir = __DIR__ . '/tmp/' . $fileKey; $finalDir = __DIR__ . '/upload/'; if (!is_dir($finalDir)) { mkdir($finalDir, 0777, true); } $finalPath = $finalDir . $fileName; $size = 0; $fp = fopen($finalPath, 'wb'); for ($i = 0; $i < $chunks; $i++) { $chunkFile = $tmpDir . '/' . $i; if (!file_exists($chunkFile)) { fclose($fp); echo json_encode(['status' => 'fail', 'msg' => "缺少分片 {$i}"]); return; } $chunkData = file_get_contents($chunkFile); fwrite($fp, $chunkData); $size += strlen($chunkData); unset($chunkData); } fclose($fp); // 清理临时目录 foreach (glob($tmpDir . '/*') as $f) { unlink($f); } rmdir($tmpDir); // 用文件真实类型校验 $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $finalPath); finfo_close($finfo); if (strpos($mime, 'video/') !== 0) { unlink($finalPath); echo json_encode(['status' => 'fail', 'msg' => '文件类型校验失败']); return; } echo json_encode([ 'status' => 'success', 'size' => $size, 'path' => $finalPath ]); }这段代码我特意保持简单,核心逻辑一目了然。真实项目里,你还需要加上权限验证、日志记录、数据库记录上传记录。尤其要注意避免shellexec之类的调用,不要相信文件名里的任何路径信息,否则存在路径穿越风险。合并文件时,$fileName直接来自前端,如果用户传一个../../shell.php之类的字符串,后患无穷。安全处理方式是重新生成一个服务端文件名,保存时把用户文件名只当作展示元数据,而不是作为实际路径。
4.3 示例运行流程说明
假设用户选择了一个200MB的视频。WebUploader会把这个视频分成40个分片,每个5MB,然后依次向/upload/chunk发POST请求,每个请求的formData里都带fileKey、chunk、chunks。服务端每收一个分片就存成一个以数字命名的临时文件。40个分片全部到达后,前端触发/upload/merge请求,后端把这40个临时文件按顺序拼成一个完整视频文件,然后删掉临时文件。
整个过程中,用户中途断网也没关系,因为每个分片都会重试。只要断网时间不长,已经上传成功的分片不会被覆盖。如果刷新页面,WebUploader就没法恢复进度了,要实现真正的断点续传,需要后端提供查询哪些分片已上传的接口,前端初始化时先发一个比对请求,跳过已存在的分片。这是扩展功能,但核心机制和接收分片一致。
5. 真实遇到的坑与排查技巧
5.1 并发上传导致分片乱序
我印象最深的一次问题是,上传总是失败,但偶尔也能成功。查日志发现,分片到达先后顺序不稳定,比如chunk 3先到,chunk 2后到。我的接收逻辑是按chunk编号存文件,所以顺序不影响存储。但在合并阶段,如果一条请求在分片还没全部到齐时就被触发了,合并就会报缺少分片。
原因在于我用了一个简单的判断:接收到的分片数量 == chunks就合并。但前端传来chunks是通过$_FILES请求里的formData,如果一个分片重传了两次,接收数量会重复计数,导致判断提前满足。解决方法是改成扫描临时目录,统计实际存在的唯一分片索引数量,而不是累加请求次数。或者直接在接收接口里,成功保存某个分片时写一个标记位。
我后来的做法是,在接收分片时,如果该分片已存在,直接返回成功但不覆盖原分片。这样重复上传的分片不会造成计数重复。合并前仍然扫描目录,数一下文件个数,如果等于chunks就继续。这算是一个很小的防御逻辑,但很管用。
5.2 内存溢出与超时问题
PHP的内存限制默认是128MB,视频分片虽然小,但合并时用file_get_contents读取5MB,再fwrite,通常不会超。但如果你直接用file_get_contents去读整个大文件,比如合并后的500MB视频,内存会直接爆。
我见过不少新手在合并时用copy($tmpPath, $finalPath, stream_context_create()),或者直接file_put_contents一个大数组,这些写法都会死。合并时一定要逐块读取,每读一个分片就写一次,然后立刻unset释放变量。如果脚本执行时间限制为30秒,合并一个2GB的视频可能不够,你需要在执行合并的方法是临时提高执行时间,比如PHP里用set_time_limit(0),但不要全局放开,只在这一段逻辑里放开。
Nginx的超时也要注意。分片请求通常很轻,不会超时,但合并请求可能慢。需要确认Nginx的fastcgi_read_timeout是不是合适,我一般设置为300秒。如果合并请求超时,用户看到的报错是504,排查时先看Nginx错误日志,再去看PHP日志,顺序不能反。
5.3 视频文件合并后无法播放怎么办
合并完的视频打不开,是概率比较高的故障。我原来以为是合并顺序错了,后来发现是分片保存时的问题。
WebUploader在发送分片时,文件内容是从指定偏移位置读取的原始字节。每个分片都是独立的二进制数据块。如果你的接收端用了move_uploaded_file,理论上文件内容不会损坏。但如果某个分片因为网络原因只传了一半,WebUploader会重试整个分片,问题不大。真正容易出错的是你在合并时,用了错误的排序,比如用字符串排序,那数字10就会排在2前面,顺序就乱了。
我建议合并前先做一次热身检查:按for($i=0; $i<$chunks; $i++)顺序读取,每个分片文件的大小应该大致相等,只有一个例外是最后一片,它可能小于chunkSize。如果某个中间分片大小比chunkSize还大,说明写入有问题,检查一下是否是接收时把多个分片拼到了一个文件里。
还有一个经验是视频文件的moov盒子可能出现在文件中间,FFmpeg对这种情况不敏感,但你要做的是先确认文件头尾是否正常。最简单的方法是tail -c 100 $finalPath查看文件尾是否有正确标签,再用播放器试播。
5.4 常见问题速查表
为了方便排查,我把常见问题整理成一个表,按症状、可能原因、解决顺序排列。
| 症状 | 可能原因 | 处理建议 |
|---|---|---|
| 分片上传后全部返回success,但合并失败 | 分片保存顺序与请求顺序无关,但合并顺序错误 | 检查合并代码,确认是按数字索引顺序读取 |
| 上传到某个分片时总是卡住 | 该分片对应的临时文件无法写入,权限不足 | 检查临时目录权限,chmod -R 775 tmp/ |
| 合并后视频只有开头一点内容 | 合并时读取分片不完整,或者多个分片被覆盖 | 检查接收分片时是否用了move方法,避免覆盖 |
| 前端进度条跳到50%之后不动了 | 单个分片请求卡住,可能超时 | 降低chunkSize,或调大后端超时时间 |
| 合并时提示缺少分片,但界面上都显示成功 | 前端计算chunks时用的是上传前的旧值,和实际分片数不一致 | 统一通过后端扫描目录判断 |
| 视频文件能传但不能播放 | fileInfo验证被误杀,或合并字节错位 | 用finfo验证是要保留的,但记得允许video/开头的所有类型 |
这张表不能覆盖所有状况,但能帮你快速定位90%的问题。排错顺序永远是从前端到后端,先确认每个分片请求的状态码都是200,再确认临时文件的数量和大小,最后才去查合并逻辑。
6 一些实操补充
最后再分享一个小技巧:如果你希望同一个用户断网后重新上传时跳过已上传的分片,后端需要提供一个checkChunks接口。前端在上传前对这个接口发送fileKey,后端扫描临时目录,返回已经存在哪些分片序号。WebUploader原生支持skip参数,可以指定跳过哪些分片。这个功能配合文件MD5哈希,就可以做到真正的秒传,使用体验会好很多。
我在实际项目里还会加一个定时清理任务,每晚删除一小时前残留的临时分片目录,防止上传失败后垃圾文件堆积占用磁盘空间。这个清理脚本用计划任务调用PHP或者Shell都可以,逻辑很简单,但非常实用。
分片上传是一个看起来基础但细节极多的功能。还是那句话,前端负责把大块切碎,后端负责把碎块拼好,中间穿插安全校验和数据完整性验证。希望这篇文章能让你少走一些弯路。