资讯动态

WebUploader + PHP 视频分片上传实战:从分片到合并

发布时间:2026/10/21 12:42:11 来源:尧图企业网站定制
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: 320MB/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($i0; $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都可以逻辑很简单但非常实用。分片上传是一个看起来基础但细节极多的功能。还是那句话前端负责把大块切碎后端负责把碎块拼好中间穿插安全校验和数据完整性验证。希望这篇文章能让你少走一些弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑