资讯详情 WebUploader+PHP实现视频分片上传与断点续传实战
📅 2026/10/21 12:41:52
做视频上传尤其是动辄几百MB、几个GB的大文件第一反应就是直接传肯定会炸。PHP环境有upload_max_filesize和post_max_size管着Nginx又有client_max_body_size拦在路上就算全调大用户那边网络一抖传了十分钟的文件说没就没重来一遍体验极差。分片上传就是这个场景下的常规解法把一个大文件切成很多个小块每一块独立上传最后在服务端拼接回来。WebUploader是我用过比较顺手的一套开源上传组件它把分片、并发、进度、队列这些都封装好了配合PHP后端做一个完整的视频分片上传链路并没有想象中那么复杂。1. 为什么视频上传必须走分片这条路1.1 先看清传统上传的两个致命瓶颈传统表单上传最大问题不是代码写得不对而是服务器和网络都扛不住大请求。先说服务器侧PHP默认upload_max_filesize只有2Mpost_max_size也只有8M视频随便一个都超标。就算你把这两个参数调到512MNginx那边默认client_max_body_size是1M一个超过1M的POST请求直接返回413。你会想“那我全调大不就行了”可以但这只是把门槛抬高了并没有解决另外一个更现实的问题一次HTTP请求要承载几百MB的数据任何一环出问题都会前功尽弃。网络侧同样麻烦。上行带宽不足时一个大文件可能要传十几分钟期间手机切了个网络、笔记本休眠了一下、代理超时断连浏览器这边请求失败用户看到的是失败提示服务端收到的是一个写到一半的文件而且大概率已经被PHP当成临时文件清掉了。就算服务端做得好能保留半成品前端重新上传时怎么跳过已传的部分又是一堆额外工作。传统上传方式在视频这个量级面前真的不太够用。1.2 分片上传的本质一次大传输拆成多次小请求分片上传的思路很直白前端先把视频文件按固定大小切成N个分片每个分片单独发起一次HTTP POST请求服务端收到后先把分片暂存起来等所有分片到齐再按顺序合并成完整文件。这样做有几个直接好处单片请求体小轻松绕开upload_max_filesize这类单请求限制某个分片失败只需要重传那一片不用整个文件重来可以通过控制并发分片数来平衡速度和服务端压力每个分片都带文件唯一标识和序号服务端能识别已上传的分片断点续传才有了基础。这个思路放在生活里特别容易理解搬家时一个大衣柜不好搬拆成几块板运过去到了新家重新组装。组装顺序不能乱所以每块板上都得标好编号。分片上传里的编号就是WebUploader在请求里携带的chunk参数。1.3 WebUploader在分片上传里的角色WebUploader做的事情就是帮我把“切分”、“编号”、“并发上传”、“进度统计”、“失败重试”这些前端工作全部封装好。我只需要告诉它分片大小是多少、并发几个、上传地址在哪、文件字段叫什么。剩余的工作比如用HTML5 File API的Blob.slice()切割文件、维护队列、计算上传进度它都内部处理了。这也是为什么很多老项目至今还在用它API稳定文档齐全网上踩坑案例多。对于不打算从零手写XHR分片逻辑的团队来说先用它把业务跑通是最划算的选择。本文后半部分的PHP服务端逻辑即使你换了别的上传组件甚至纯手写前端核心思路也完全通用——服务端根本不关心前端是谁只看你POST过来什么参数。2. WebUploader前端配置先搞定这五个参数2.1 核心配置项逐项拆解用WebUploader做视频分片上传WebUploader.create()里的这几个配置项我是建议优先理解的server分片上传请求发往后端的地址没有它整个上传流程根本跑不起来。chunked是否启用分片做视频上传必须设为true。chunkSize每个分片的大小单位是字节。比如5 * 1024 * 1024就是5MB。这个值不是越大越好也不是越小越好我后面单独说。threads同时上传的分片数。注意它不是同时上传的文件数而是同一个文件内部并发分片的数量。fileVal文件字段名默认是filePHP端就用$_FILES[file]接收。formData附加在每次分片请求里的自定义表单数据比如用于标识文件的guid字段。还有一个容易忽略但实际很关键的参数是duplicate。默认情况下WebUploader会过滤掉重复选择同一文件的操作如果断点续传时需要反复选择同一个文件来触发继续上传就得把duplicate设为true否则第二个请求直接进不了队列。2.2 用代码把前端骨架搭起来下面这段是我常用的配置模板直接抄到一个空白页面里配合后端的upload.php即可跑通基础流程var uploader WebUploader.create({ swf: Uploader.swf, server: upload.php, pick: #picker, auto: true, accept: { title: 视频文件, extensions: mp4,mov,avi,mkv, mimeTypes: video/* }, chunked: true, chunkSize: 5 * 1024 * 1024, threads: 3, fileNumLimit: 1, fileVal: file, duplicate: true });这里swf配置项现在基本只是兼容老代码用的现代浏览器走的是HTML5通道不会加载Flash。accept里的extensions是给用户的选择器做过滤属于用户体验层面的限制不能当作服务端的安全校验这一点我在第4章还会重点强调。chunkSize选多大取决于目标文件大小和服务端承受能力。我自己的习惯是视频普遍在几百MB级别用5MB分片请求量大概几十到一两百个压力可控如果是GB级视频我会把分片调到10MB减少请求数量降低网络往返开销。分片太小比如1MB一个500MB的视频就要500个请求服务器日志刷得飞起网络握手开销占比也大分片太比如50MB单片传输失败时的重传代价又高了。2.3 文件指纹与断点续传的前端准备WebUploader本身只负责把文件切成片传出去不做判断“这个文件之前传过没有”。要做断点续传通常是在前端生成一个文件标识比如文件名的哈希、文件大小和最后修改时间的组合每次选择文件时算出一个guid通过beforeUpload事件写入formDatauploader.on(beforeUpload, function(file) { uploader.options.formData { guid: getFileGuid(file) }; });getFileGuid的计算方式我常用文件名、文件大小和最后修改时间的组合再做一个简单的哈希处理保证同一个文件每次选择都得到同一个guid。这样刷新页面后重新选择同一个视频服务端才能通过guid找到之前没传完的临时分片目录。要做到更严格的文件级秒传需要在选择文件后先异步请求服务端询问该guid是否已经有完整文件如果存在直接跳过上传。这个逻辑WebUploader也支持在beforeUpload里返回一个Deferred对象查询期间暂停当前文件的上传过程。3. PHP服务端的接收与合并这才是大头3.1 分片请求带了哪些参数当WebUploader以分片方式发起上传时后端收到的POST请求除了$_FILES里的一份分片数据之外还会带上几个关键参数。默认情况下$_POST[chunk]当前分片的序号从0开始$_POST[chunks]总分片数$_POST[guid]我自己在formData里加的文件标识$_FILES[file]当前这个分片的临时文件字段名对应前端配置的fileVal。判断一个请求是不是某个大文件的最后一片不需要在前端做特殊通知。服务端只需检查“这个guid对应的临时目录里0到chunks-1号分片是否都存在”如果都存在说明全部到达可以触发合并。所以接收分片的逻辑本质上就是保存分片、逐个检查序号、齐了就合并。3.2 服务端接收分片的完整流程下面这段PHP代码是我在实际项目里沉淀下来的基础版本。它把分片保存到以guid命名的临时目录里分片文件名直接用序号加.part后缀?php error_reporting(E_ALL); ini_set(display_errors, 0); header(Content-Type: application/json; charsetutf-8); set_time_limit(0); define(UPLOAD_ROOT, __DIR__ . /upload/); define(TMP_DIR, UPLOAD_ROOT . tmp/); define(VIDEO_DIR, UPLOAD_ROOT . video/); if (!is_dir(TMP_DIR)) mkdir(TMP_DIR, 0755, true); if (!is_dir(VIDEO_DIR)) mkdir(VIDEO_DIR, 0755, true); if ($_SERVER[REQUEST_METHOD] ! POST) { echo json_encode([status 0, msg 仅支持POST请求]); exit; } $guid trim($_POST[guid] ?? ); $chunkIndex intval($_POST[chunk] ?? 0); $totalChunks max(1, intval($_POST[chunks] ?? 1)); // guid严格白名单校验防止路径穿越 if ($guid || !preg_match(/^[a-zA-Z0-9_-]{6,64}$/, $guid)) { echo json_encode([status 0, msg 非法文件标识]); exit; } if (!isset($_FILES[file]) || $_FILES[file][error] ! UPLOAD_ERR_OK) { echo json_encode([status 0, msg 分片数据接收失败]); exit; } $tmpDir TMP_DIR . $guid . /; if (!is_dir($tmpDir)) mkdir($tmpDir, 0755, true); $partFile $tmpDir . $chunkIndex . .part; if (!move_uploaded_file($_FILES[file][tmp_name], $partFile)) { echo json_encode([status 0, msg 分片写入失败]); exit; } // 逐个检查分片是否齐全 $allPresent true; for ($i 0; $i $totalChunks; $i) { if (!is_file($tmpDir . $i . .part)) { $allPresent false; break; } } if (!$allPresent) { echo json_encode([status 0, msg 分片已接收, chunk $chunkIndex]); exit; } // 分片齐全开始合并 $targetFile VIDEO_DIR . $guid . .mp4; $out fopen($targetFile, wb); if (!$out) { echo json_encode([status 0, msg 目标文件创建失败]); exit; } for ($i 0; $i $totalChunks; $i) { $partPath $tmpDir . $i . .part; $in fopen($partPath, rb); if (!$in) { fclose($out); unlink($targetFile); echo json_encode([status 0, msg 分片{$i}读取失败]); exit; } stream_copy_to_stream($in, $out); fclose($in); unlink($partPath); } fclose($out); rmdir($tmpDir); echo json_encode([ status 1, msg 上传成功, path upload/video/ . $guid . .mp4, size filesize($targetFile) ]);注意几个细节。第一move_uploaded_file是唯一正确处理PHP上传临时文件的函数它会把文件从PHP的临时目录移动到指定位置直接rename或copy某些场景下也能用但move_uploaded_file会额外校验文件确实是通过HTTP POST上传的更安全。第二分片目录用guid做名字而不是原始文件名是为了避免不同用户上传同名文件互相干扰也避免中文文件名在部分服务器上产生编码问题。第三判断“分片是否齐全”必须循环检查每个序号不能只看文件数量否则遇到某个分片被重传覆盖、另一个分片缺失的情况就会被误判为全部到齐。3.3 分片合并的正确姿势与内存控制合并部分我见过不少人在循环里用file_get_contents($partFile)配合file_put_contents($targetFile, $content, FILE_APPEND)小文件没问题但遇到大视频或者超大分片时内存占用会直线上升。file_get_contents会把整个分片读入内存一个10MB的分片还能接受但如果有人把chunkSize设成50MBPHP进程瞬间吃掉几十MB多并发几路内存直接告急。更稳妥的做法是用fopen打开目标文件以二进制追加方式写通过stream_copy_to_stream把一个分片流式拷贝进去。这样内存占用只取决于缓冲区大小和分片大小没有线性关系。上面的合并代码已经用了这个方式合并过程中每拷贝完一个分片就删除对应.part文件省磁盘空间也方便异常清理。合并完成后最好检查一下最终文件大小至少能发现“前端口口声声说传完了但实际分片不完整”的异常。更严格的做法是服务端在分片上传过程中累计每个分片的大小合并后对比总大小不过WebUploader自带的请求参数里没有提供这个信息需要前端额外传一个totalSize字段实现也不复杂但务必要记住这个大小来自客户端只能作为参考值不能当作安全凭证。3.4 合并后的文件清理与目录结构设计临时分片目录要记得清理。一个实际的问题用户传了一半不传了刷新页面关了浏览器临时目录里的.part文件就成了垃圾。我习惯在服务器上配置一个定时任务删除超过24小时未被修改的临时分片目录。这一步看起来不起眼但对长期运行的服务器来说非常关键否则磁盘会被碎片文件塞满。目录结构方面我是按upload/tmp/和upload/video/分开设计的。video目录放合并后的最终文件如果需要通过URL直接播放就让它保持Web可读tmp目录只对服务端开放绝对不允许用户通过浏览器直接访问。你可以在Nginx里给tmp目录单独配置拒绝规则也可以把整个upload目录放到Web根目录之外靠PHP脚本读写后一种安全等级更高但播放视频时需要另外写一个输出文件流的接口麻烦一些。小型项目用前一种就够了。4. 别急着上线校验、安全与性能调优4.1 文件类型与完整性校验先说一个新手容易踩的认知误区前端accept里的extensions只是选择框过滤攻击者可以随便改请求后端不能信这个。那后端靠什么扩展名白名单是最基础的一层还有一个很容易忽略的事实分片阶段不适合用文件头去判断视频类型。为什么因为分片只是完整文件的一部分大多数视频格式的文件头只在文件开头几个KB里。序号较大的分片内容是视频的中段或尾部没有完整的结构头用finfo或读取魔数去判断都会误报。正确的做法是合并完成之后再对完整文件做一次MIME或文件头校验确认它真的是视频文件。基于这个原则上面的Demo代码里没有在接收分片时做严格的MIME判断合并完成后可以这样补充$finfo new finfo(FILEINFO_MIME_TYPE); $mime $finfo-file($targetFile); $allowedMimes [video/mp4, video/quicktime, video/x-msvideo, video/x-matroska]; if (!in_array($mime, $allowedMimes)) { unlink($targetFile); echo json_encode([status 0, msg 文件类型校验失败]); exit; }服务器上如果没装fileinfo扩展可以退而求其次读文件头几个字节判断。比如MP4文件偏移4字节处通常是ftyp标识很多视频文件的头部都有类似魔法数字。但这个方法对某些编码的MOV、MKV不一定准确所以生产环境最好还是开fileinfo。另外合并后的filesize要做一个下限校验比如目标文件小于某个阈值比如100KB基本可以断定不是正常视频直接拒绝。4.2 上传目录安全配置上传功能最怕两件事一是用户传上来一个PHP脚本伪装成视频还能被服务器当成PHP执行二是路径拼接没处理好用户通过文件名穿越目录。第一件事靠目录执行权限控制第二件事靠服务端对文件名严格校验。我在上面的代码里对guid做了正则白名单校验只允许字母、数字、下划线和连字符长度为6到64位。这样就彻底堵死了../../这类路径穿越的可能。不要拿前端file.name直接拼路径不管它经过怎么样的过滤都不要这是铁律。Web目录下的upload/tmp要禁止外部访问。Nginx里可以这样配location ~* ^/upload/tmp/ { deny all; } location ~* ^/upload/.*\.(php|php5)$ { deny all; }第一个规则拒绝任何人直接访问临时目录里的分片文件第二个规则禁止upload目录下任何PHP脚本被Web执行。这里特别提醒一点即使上传的文件扩展名被强制改成.mp4也不能放松警惕。某些老版本Web服务器存在解析漏洞比如/upload/video/xxx.mp4/.php这种路径会被错误解析为PHP所以上面第二条规则能加就加上。4.3 服务器与PHP配置的联动修改走到这里服务端逻辑没问题了但能不能跑起来还取决于环境配置。分片上传虽然绕过了upload_max_filesize对单文件大小的限制但每一个分片请求仍然是一次POST所以post_max_size仍然会限制请求体大小而且Nginx的client_max_body_size管的也不是“整文件”而是“单次请求体”。我做视频上传时环境配置至少会改成这样upload_max_filesize 20M post_max_size 32M max_execution_time 120 memory_limit 256Mupload_max_filesize管的是单文件大小前端chunkSize如果是5MB这个值设20M给足余量避免其他需要一次性上传的场景受限。post_max_size必须大于upload_max_filesize因为POST请求体里除了文件还有各种表单字段和HTTP头我习惯留出十几MB的余量。max_execution_time在合并大文件时主要靠脚本里的set_time_limit(0)兜底但其他PHP脚本还是需要一个合理上限。Nginx这边对应修改的是client_max_body_size和超时时间client_max_body_size 32m; client_body_timeout 120s;如果你用的是Apache注意LimitRequestBody和TimeOut也是类似的思路。这些配置改完要重启Web服务器才生效很多同学改完php.ini发现没变化就是忘了重启PHP-FPM。5. 实战中踩过的坑问题排查速查表5.1 最大概率踩到的几个坑下面这张表是我自己遇到过的、以及帮别人排查过的最高频问题按出现概率排序现象直接原因解决方向上传返回413Nginxclient_max_body_size小于单片大小调大该参数并重启Nginx上传提示POST大小超限PHPpost_max_size小于请求体调大post_max_size同时检查upload_max_filesize前端提示上传失败但后端日志显示收到了文件服务端返回的业务状态不是前端期望的status:1保持前后端约定一致的响应结构固定Content-Type: application/json合并后视频文件打不开合并顺序错误或分片缺失确认合并循环严格按分片序号递增使用wb二进制模式写入上传进度卡在99%后失败最后一片请求处理时间过长触发超时合并不放在最后一片的请求里或set_time_limit(0)关于最后一片触发合并导致超时的问题很多项目早期都会在这里栽跟头一个大视频全部分片到达PHP在最后一个请求里要读取几十个分片并合并耗时可能超过默认的超时时间。前面我用了set_time_limit(0)在高并发下也可以把合并操作丢给异步任务队列处理先向前端返回“分片已全部接收”后台慢慢合并完成后回调通知。具体取舍看你的服务器规模和业务要求。还有一个隐藏很深的坑PHP的session_start()会锁定session文件。如果你在上传接口里同时开启了session并发的那几个分片请求会变成排队执行——因为每个请求都要等session锁释放。前端看起来就是明明设置了3个并发实际请求却一个接一个。解决方法很简单上传接口里如果不需要读session就别开必须开的话尽早session_start()然后立即session_write_close()释放锁。5.2 断点续传的隐藏陷阱断点续传看起来高大上无非就是服务端检查已有分片、前端跳过已传分片。实际做起来有几个细节很容易被忽略。第一guid的生成规则必须稳定。如果每次刷新页面都生成一个完全不同的随机字符串那服务端就永远找不到旧分片每次都是全新上传。用“文件名大小最后修改时间”这些文件本身的信息来生成标识连续两次选择同一个文件才能得到同一个guid。第二检查“已有分片”时不能只检查分片文件是否存在还要检查大小是否大于0。我曾经遇到某客户端上传分片时偶发写入空文件服务端误以为分片已存在直接跳过最后合并出来的视频播放到中间就断。后续的检查逻辑里加了一条“分片文件大小必须大于0KB才认为有效”。第三秒传和断点续传是两个不同级别的功能。断点续传是分片级别的秒传是文件级别的服务端查一下这个guid是否已经合并过完整文件如果有前端直接跳过整个文件。这两个接口可以单独做合并接口和查询接口职责各不相同不要混在一起写。5.3 并发合并时的竞态问题分片上传天然是并发的服务端合并逻辑里有个典型的竞态所有分片最后一片到达时恰好有两个分片请求同时进入“检查分片齐全”这段代码两个进程都认为分片齐了于是同时开始合并写入同一个目标文件轻则数据错乱重则互相覆盖导致文件损坏。解决思路也简单在合并动作之前加一把锁或者用一个“合并中”的标记文件。比如在临时目录里创建一个.merging文件fopen它时用排他锁或者直接判断这个标记文件是否存在存在就说明另一个进程正在合并当前请求只返回成功状态即可。合并完成后删除标记文件再清理临时目录。我实际项目里更倾向于用一个独立的合并队列分片到达后只负责落盘由后台任务统一扫描、合并从根上避免了竞态问题代价是多写一个常驻脚本适合文件量比较大的场景。6. 一个可直接复用的Demo归纳6.1 前端完整代码把前面的核心配置拼起来一个最小的可运行页面大概是这样的。页面里有一个选择按钮一个显示结果的容器静态资源webuploader.js、webuploader.css和jquery要自己放到对应目录。!DOCTYPE html html head meta charsetUTF-8 title视频分片上传Demo/title link relstylesheet hrefwebuploader.css /head body div idpicker选择视频/div div idresult/div script srcjquery.min.js/script script srcwebuploader.js/script script var uploader WebUploader.create({ swf: Uploader.swf, server: upload.php, pick: #picker, auto: true, accept: { title: 视频文件, extensions: mp4,mov,avi,mkv, mimeTypes: video/* }, chunked: true, chunkSize: 5 * 1024 * 1024, threads: 3, fileNumLimit: 1, fileVal: file, duplicate: true }); function getFileGuid(file) { var info file.name _ file.size _ (file.lastModifiedDate ? file.lastModifiedDate.getTime() : ); var hash 0; for (var i 0; i info.length; i) { hash ((hash 5) - hash) info.charCodeAt(i); hash | 0; } return vid_ Math.abs(hash).toString(36) _ Date.now().toString(36); } uploader.on(beforeUpload, function(file) { uploader.options.formData { guid: getFileGuid(file) }; }); uploader.on(uploadProgress, function(file, percentage) { console.log(进度 percentage); }); uploader.on(uploadSuccess, function(file, response) { var data typeof response string ? JSON.parse(response) : response; if (data.status 1) { $(#result).html(上传完成文件地址 data.path); } }); uploader.on(uploadError, function(file, reason) { alert(上传失败 reason); }); /script /body /html这里有一个值得注意的细节uploadSuccess回调里的response可能是字符串也可能是对象。服务端如果设置了Content-Type: application/jsonWebUploader会自动解析成对象如果服务端返回的是普通文本或没有正确设置响应头response就是一串JSON字符串。所以我在回调里做了typeof response string的判断这个小习惯能避免很多莫名其妙的问题。6.2 服务端完整代码服务端完整代码在第3章的Demo基础上稍作调整即可。如果你把上传目录放在Web根目录外记得在uploadSuccess返回给前端的path字段里拼接一个可访问的映射地址而不是真实的服务器绝对路径。还要补一个“查询是否已存在完整文件”的接口用于秒传场景。代码思路很简单guid对应的目标文件如果存在返回exists: true否则检查临时目录返回已有分片数。前端拿到结果后可以决定是否调用uploader.skipFile(file)。6.3 我的几点经验总结做了几个实际项目之后我对WebUploader分片上传有这么几点体会。分片大小不要死脑筋用默认值。默认配置不一定适合你的业务场景需要根据平均文件大小、服务器带宽、后端并发能力去调。5MB是一个比较通用的起点但如果你们平台视频普遍在2GB以上可以适当调大到10MB甚至20MB同时记得同步调大各个服务端的限制参数。并发数控制在3到4最稳妥。有人觉得并发越大传得越快实际上对于普通带宽的服务器并发高了反而导致分片到达顺序错乱、磁盘写入争抢上传进度条还会因为乱序而频繁跳动。真正常见的情况是单条TCP连接的吞吐已经接近用户上行带宽上限开那么多并发并没有额外收益。服务端临时目录一定要有清理机制。我接手过一个跑了半年的项目临时目录里堆了几十GB的半截分片磁盘告警才知道是用户传一半放弃留下的。后来安排了一个每天凌晨清理超过24小时分片的任务问题再没出现过。最后关于响应结构前后端尽早约定好。status、msg、path、size这些字段名的意思要写清楚最好有个简单的接口文档。我在排查别人代码时见过最乱的场景是前端判断status 0表示成功后端却用status 1表示成功两边都觉得自己没错最后浪费一个下午对代码。约定好之后再配合上面这套完整的接收、合并、校验逻辑一套稳定可用的视频分片上传服务就算落地了。