前段时间接了个内部项目,要支持用户批量上传几百MB甚至上GB的教学视频到服务器做归档。一开始图省事,前端直接用普通文件选择框,后端PHP拿$_FILES接住就完了。结果测试的时候,网络稍微一波动,整个上传就失败,重新传又得从零开始,用户骂声一片。后来老老实实换成了分片上传,配合断点续传,才算把这个事彻底解决。这次就把WebUploader和PHP配合实现大文件分片上传的完整方案整理出来,里面包含了踩坑记录和一些关键细节的取舍,给正在做类似功能的后端或者全栈朋友做个参考。
这个方案适合什么场景呢?你的项目里如果涉及视频、安装包、日志备份这些动不动就上百MB的文件,或者用户的网络环境不稳定(比如内部专线带宽不高、移动网络切换频繁),那这套方案基本能帮你把“上传失败重来”的恶性循环打断。核心价值就三个:把大文件切碎逐个传、记住进度只补没传的数据、传完自动拼回原文件。下面从思路到代码一步一步拆开讲。
1. 整体设计:分片上传怎么解决“大文件传不动”的问题
1.1 为什么选WebUploader而不是别的组件
提到大文件上传,业界方案不少,比如 Plupload、Resumable.js、还有各种基于 XMLHttpRequest 自己封装的上传逻辑。我最终选 WebUploader,原因很实际:它虽然已经停止维护了,但百度开源出来的这个项目在“分片上传”这个垂直场景上做得相当成熟,API设计直白,中文文档完整,网上案例多到搜都搜不完,真要出问题,踩坑资料比冷门库好找得多。
WebUploader 默认就支持分片(chunked: true),自带并发控制、进度回调、文件队列管理,这些能力如果从零手写,至少要花两三天的功夫。手写 XMLHttpRequest 分片上传也不是不行,但你要自己维护每个分片的发送状态、重试逻辑、并发控制,工作量和要踩的坑呈指数上升。选 WebUploader 属于“花最小的代价解决最疼的问题”。
1.2 整体流程与角色分工
分片上传这个方案,模式上像搬家工人搬大件家具。整件沙发搬不动也搬不进电梯,就把它拆成几个部件,分批搬上去,到了房间再拼装还原。网络传输也一样,一个1GB的文件整体走HTTP上传,哪怕带宽再宽,中途任意一秒断掉,前面传的数据全部作废。切成几MB一块的小分片后,每片独立传输,哪一片失败了就重传哪一片,其他已上传的分片不受影响。
具体到落地,整个流程是这样分工的:
- 浏览器端(WebUploader):负责把文件切成固定大小的分片;给每个分片编号;把文件内容的唯一标识(通常是md5)连同分片序号一起发给后端;展示上传进度。
- 后端(PHP):负责接收每个分片,把分片按“文件标识+分片序号”命名保存到临时目录;记录整个文件的接收进度;所有分片到齐后,按序号拼回完整文件。
前端和后端的配合有点像工单系统:前端拿着文件指纹(md5)去后端查“这个活干到哪了”,后端返回一个已接收分片序号列表,前端直接跳过这些分片,从没传的继续传。这就是断点续传能成立的根基。
1.3 分片大小的权衡:不是越大越好也不是越小越好
分片大小是个很有讲究的参数。WebUploader 里用chunkSize控制,我项目里最终定的是2MB。为什么不是10MB或者1MB?这里有个权衡:
- 分片太大:单个分片传输时间久,中途断网的概率和损失都在上升。比如网络不稳定时,10MB的分片传到一半断了,重传这10MB的成本比2MB大得多。
- 分片太小:虽然单次失败损失小,但分片数量激增。1GB文件切2MB就是512片,如果切512KB,就是2048片,每片都是一次HTTP请求,请求往返的开销(网络延迟、服务端IO、PHP进程处理)会显著拉低整体速度。
- 还有并发因素:WebUploader 默认有
threads并发数,我设的是3。如果分片太碎,同时并发几十个,后端磁盘IO和PHP进程都会被拖垮。
实测下来,2MB到5MB是体验比较平衡的区间。如果服务器磁盘性能一般,建议往2MB靠;如果内网传输速度快、带宽充裕,可以放宽到5MB。
2. 核心原理拆解:断点续传背后的“记忆能力”
2.1 分片是怎么解决上传失败的
普通上传是一竿子买卖:文件内容从请求开始写到请求结束,中间不能断,断了就得重新来。分片上传把这一竿子买卖拆成多个独立小买卖,每个分片都是一个完整的HTTP请求,可以并行也可以排队,服务端各自独立处理。
这里面有个容易被忽略的点:分片虽然拆开了,但它们之间存在“顺序语义”。服务端保存分片时,绝对不能依赖文件名自然排序,必须显式保存“这是第几个分片”的索引。WebUploader 默认会在请求参数里带上chunk(当前分片序号)和chunks(总分片数),后端必须保存这两个字段,合并的时候按chunk从小到大拼装。我在解析参数时就遇到过chunk被当成字符串导致顺序错乱的坑,后面会细说。
分片解决失败的另一个关键机制是“独立重试”。WebUploader 发送某个分片失败后,会自动重试这个分片(retry接口),不会影响其他分片的发送。用户全程无感,只是进度条停了一下又继续往前跳。
2.2 断点续传的记忆机制:用md5当文件的“身份证”
断点续传的核心,不是断点时刻发生了什么,而是重传时怎么知道“哪些分片已经传过了”。这个记忆过程依赖两个东西:
第一,文件的唯一标识。我用的是整个文件的 md5 值。文件内容确定,md5就唯一确定;同一文件哪怕改个文件名,md5不变。这个值在文件第一次被用户选中时由前端计算出来,之后每次上传分片、查询进度时都带上它。
第二,服务端的进度记录。后端收到分片后,会按照“md5_序号.part”的格式把分片落盘。当用户重新上传时,前端先用md5请求后端一个查询接口,后端扫描临时目录里属于该md5的所有.part文件,把已存在的分片序号做成数组返回给前端。WebUploader 拿到这个数组后,跳过多余的分片,直接排到缺失分片的位置继续传。
这里要特别说明:md5计算是前端做的,后端不重复计算。前端算好整个文件的md5,在请求参数里传给后端即可。有人会担心前端算md5耗时长,1GB文件在现在的PC上,用 WebUploader 内置的md5File方法大概也就几秒到十几秒,相比上传耗时是可以接受的。但要注意计算过程中不能让浏览器卡死,后面踩坑章节我会讲怎么处理。
2.3 秒传与断点续传的关系
断点续传往下延伸,还能做“秒传”。原理非常简单:既然md5能唯一标识文件,用户选择文件计算完md5后,先调一个检查接口,后端发现该md5对应的完整文件已经存在,直接返回“上传完成”,前端连分片都不用传。这个机制在文件去重、资源仓库场景特别实用。
我当前项目里把秒传和断点续传合到了同一个检查接口里,后端逻辑一句话说清:如果该md5文件已完整存在,返回特殊标记is_exists: true,前端直接跳完成;否则返回已上传的分片序号列表,供断点续传使用。
3. 前后端完整实现:从零搭一套分片上传接口
3.1 环境准备和目录规划
后端环境没有特殊要求,只要 PHP 5.6 以上,支持move_uploaded_file和file_put_contents即可。需要注意两件事:
第一个是上传大小限制。分片上传已经规避了upload_max_filesize对单文件大小的限制,但post_max_size必须大于分片大小,因为每个分片本身也是一个POST请求。保险起见,post_max_size设为 8M,upload_max_filesize设为 8M,max_execution_time设为 0(不限制),避免大分片合并时执行超时。
建议修改PHP配置文件时,顺手把内存限制memory_limit提到至少 256M,合并分片时要读取和写入整个文件,吃内存的隐患一直都在。
目录规划建议这样:
project/ ├── upload/ │ ├── tmp/ # 分片临时存储目录 │ └── complete/ # 最终合并完成的文件目录tmp目录保存中间态的分片文件,定期清理;complete保存最终文件,保持与业务对齐。两个目录都需要可写权限。
3.2 前端:WebUploader 初始化与核心参数
页面里引入 WebUploader 的 CSS 和 JS(WebUploader 依赖 jQuery,需要提前引入)。核心初始化代码:
<!-- 引入依赖,路径按自己项目调整 --> <link rel="stylesheet" href="webuploader.css" /> <script src="jquery.min.js"></script> <script src="webuploader.min.js"></script><input id="picker" type="button" value="选择文件" />var uploader = WebUploader.create({ // 后端接收分片的地址 server: '/upload.php', // 文件选择按钮 pick: '#picker', // 选完文件后自动上传 auto: true, // 启用分片 chunked: true, // 每片大小:2MB chunkSize: 2 * 1024 * 1024, // 同时上传的分片数,建议3个,太多会给PHP进程和磁盘IO造成压力 threads: 3, // 表单字段名,后端用 $_FILES['file'] 接收 fileVal: 'file', // 额外的全局参数,会附加在每个分片请求里 formData: { type: 'upload' } });前端有几个参数直接影响成败,值得单列说明。
chunked: true是分片总开关,必须开;chunkSize必须和规格匹配;threads并发数不是越大越好,并发太多时后端磁盘写入和Nginx连接数都会成为瓶颈,建议3~5。
fileVal这个参数容易被忽略,默认是file,如果前端改了这个名字,后端$_FILES里的键名也得跟着改。
还有一个容易踩的坑:server地址后面不要带 query string,比如/upload.php?type=video。虽然WebUploader会保留这些参数,但前端传md5和分片序号时,如果后端用$_GET拿参数,和原本的 query string 混在一起容易出问题。我统一把参数放formData里。
3.3 文件级md5计算与状态注册
WebUploader 在文件进入队列时,会触发fileQueued事件。在这个事件里计算整个文件的md5,并把md5作为自定义字段挂到文件对象上。
uploader.on('fileQueued', function(file) { var md5 = ''; // 使用 WebUploader 提供的 md5File 方法计算全文件 md5 // 参数分别表示:文件对象、计算起始位置、计算终止位置(设为 -1 表示整个文件) uploader.md5File(file, 0, -1) .then(function(hashes) { // hashes 是数组类型,这里取最后一个值作为全文件 md5 md5 = hashes.pop(); file.md5 = md5; // 把 md5 回传给后端,后端用于分片存储路径与续传查询 uploader.option('formData', { file_md5: md5, type: 'upload' }); }) .fail(function() { alert('文件特征值计算失败,请重新选择文件'); }); });新版 WebUploader 的md5File返回的是一个数组,每个元素对应文件某个分片的md5,数组最后一个元素才是整个文件的md5。这里有人直接拿取hashes[0]当成整个文件md5,会有问题,我测试时发现不同版本行为还不一样。稳妥做法是取最后一个元素,并且以实际打印结果为准。
由于md5File是异步的,而且大文件计算耗时较长,必须等待md5计算完成后再调用uploader.upload(),否则分片先发出去了,md5才计算完成,后端拿不到文件标识,整个断点续传就失效了。我的处理方式是初始化时先auto: false,然后在md5计算完成的.then里手动触发uploader.upload()。
3.4 前端:上传前查询进度实现续传
WebUploader 每次分片上传前,默认不会主动查询哪些分片已存在。要在uploadBeforeSend事件里拦截,查询后端进度后,决定跳过哪些分片。
uploader.on('uploadBeforeSend', function(block, data) { // 最重要的:每个分片请求都带上 md5 和分片序号 data.file_md5 = block.file.md5; data.chunk = block.chunk; data.chunks = block.chunks; });续传的查询动作通常放在“文件即将开始上传”的时机,我用的是beforeUpload事件。这个事件在文件第一次开始上传或重传前触发,在这个时机发起查询,拿到已上传分片列表,调用 WebUploader 的skip方法跳过分片。
实际操作中,查询接口返回已上传分片的编号数组,然后循环标记跳过:
uploader.on('beforeUpload', function(file) { // 异步查询后端已上传的分片 $.ajax({ url: '/check.php', method: 'POST', data: { file_md5: file.md5 }, dataType: 'json', success: function(res) { if (res.is_exists) { // 完整文件已存在,直接不传了 uploader.skipFile(file); return; } // 已上传的分片编号数组 var uploadedChunks = res.uploaded_chunks || []; // 遍历每个分片,跳过已上传的 for (var i = 0; i < file.__chunks__; i++) { if (uploadedChunks.indexOf(i) !== -1) { uploader.skip(file, i); } } // 跳过完之后,正式开始上传缺失分片 uploader.upload(); } }); });这里要说明一下file.__chunks__这个参数,它是 WebUploader 内部给文件对象挂的属性,保存了当前文件实际的分片总数。在浏览器调试时直接打印file对象就能看到。高版本WebUploader里这个属性可能叫chunks,我用的时候以实际打印为准。
3.5 后端:检查接口 check.php
检查接口负责“记忆”查询和“秒传”判断,逻辑比较集中:
<?php // check.php $fileMd5 = isset($_POST['file_md5']) ? $_POST['file_md5'] : ''; $tmpDir = __DIR__ . '/upload/tmp'; $completeDir = __DIR__ . '/upload/complete'; if ($fileMd5 === '') { echo json_encode(['code' => 1, 'msg' => '缺少文件标识']); exit; } // 幂等检查:如果完整文件已经存在,直接返回存在标记 $completeFile = $completeDir . '/' . $fileMd5 . '.mp4'; if (file_exists($completeFile)) { echo json_encode(['code' => 0, 'is_exists' => true]); exit; } // 扫描临时目录中属于这个md5的所有分片 $uploadedChunks = []; $prefix = $fileMd5 . '_'; foreach (glob($tmpDir . '/' . $prefix . '*.part') as $partFile) { $basename = basename($partFile); // 文件名格式:{md5}_{chunk}.part if (preg_match('/^' . preg_quote($prefix) . '(\d+)\.part$/', $basename, $matches)) { $uploadedChunks[] = (int) $matches[1]; } } echo json_encode([ 'code' => 0, 'is_exists' => false, 'uploaded_chunks' => $uploadedChunks ]);这里有个经验值得分享:合并完成后的完整文件,我按业务直接存成了.mp4。如果你无法预先知道文件类型,建议把原始文件扩展名也通过表单参数传过来,合并时拼上,或者干脆不设扩展名,由业务侧下载时再决定。
glob扫描临时目录找分片,这段逻辑在分片数量较大时可能会有点慢。但正常场景下几百个分片,glob的性能是够用的,没必要为这个上数据库。
3.6 后端:分片接收接口 upload.php
这是整个方案最核心的接收口,每次分片上传都会命中的就是这个接口。主要工作:接收文件流,用“md5_序号.part”命名保存到临时目录。
<?php // upload.php $fileMd5 = isset($_POST['file_md5']) ? $_POST['file_md5'] : ''; $chunk = isset($_POST['chunk']) ? intval($_POST['chunk']) : 0; $chunks = isset($_POST['chunks']) ? intval($_POST['chunks']) : 0; if ($fileMd5 === '') { echo json_encode(['code' => 1, 'msg' => '缺少文件标识']); exit; } // 安全校验:临时目录不能存储在暴露到公网的路径下 $tmpDir = __DIR__ . '/upload/tmp'; if (!is_dir($tmpDir)) { mkdir($tmpDir, 0755, true); } // 检查是否上传了文件 if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { echo json_encode(['code' => 1, 'msg' => '上传分片失败']); exit; } if ($_FILES['file']['size'] > 8 * 1024 * 1024) { echo json_encode(['code' => 1, 'msg' => '分片大小超出上限']); exit; } // 分片文件名格式:{md5}_{chunk}.part $partFile = $tmpDir . '/' . $fileMd5 . '_' . $chunk . '.part'; // 将临时文件移动到目标位置,move_uploaded_file 保证只处理本次请求上传的文件 if (!move_uploaded_file($_FILES['file']['tmp_name'], $partFile)) { echo json_encode(['code' => 1, 'msg' => '保存分片失败']); exit; } // 关键:如果这是最后一个分片,触发合并逻辑 if ($chunks > 0 && $chunk == $chunks - 1) { // 这里不能直接合并,因为最后一个分片到达不代表前面分片全部到达 // 需要调用独立的合并接口或在这里校验完整性 // 我的做法:返回 success,由前端最后主动触发合并 } echo json_encode(['code' => 0, 'msg' => '分片上传成功']);后端分片接收有个重要细节:move_uploaded_file不会去校验文件内容完整性。网络传输中途损坏的情况虽然少,但存在。所以我在合并阶段会校验分片字节数是否和chunkSize匹配(最后一片除外)。如果想做更细的校验,也可以让前端传每个分片的md5,后端md5_file比对,但对大部分场景来说这点收益和额外成本不成比例。
最晚分片到达后,不能立刻合并。原因在于并发上传时,最后一个分片可能比前一个分片先到,如果判定“最后一个分片到了就开始合并”,会发现缺少前序分片。正确方案是由前端在全部上传完成事件里,显式调用合并接口。
3.7 前端:全部传完后的合并触发
WebUploader 在uploadFinished事件里表示所有分片都已上传成功。在这个事件里向后端请求合并:
uploader.on('uploadFinished', function() { var fileMd5 = uploader.options.formData.file_md5; $.ajax({ url: '/merge.php', method: 'POST', data: { file_md5: fileMd5, chunks: uploader.uploader.getFiles()[0].__chunks__ }, dataType: 'json', success: function(res) { if (res.code === 0) { alert('文件上传成功'); } else { alert('合并失败:' + res.msg); } } }); });需要注意:如果uploadFinished触发时包含多文件,需要遍历文件队列或根据file对象来定位。我这里简化了单文件逻辑,多文件场景建议把文件标识和分片总数放到队列数据里保存。
3.8 后端:合并接口 merge.php
合并接口做三件事:校验所有分片齐全、按序号顺序写出完整文件、清理临时分片。
<?php // merge.php $fileMd5 = isset($_POST['file_md5']) ? $_POST['file_md5'] : ''; $chunks = isset($_POST['chunks']) ? intval($_POST['chunks']) : 0; if ($fileMd5 === '' || $chunks <= 0) { echo json_encode(['code' => 1, 'msg' => '参数错误']); exit; } $tmpDir = __DIR__ . '/upload/tmp'; $completeDir = __DIR__ . '/upload/complete'; if (!is_dir($completeDir)) { mkdir($completeDir, 0755, true); } // 校验分片是否齐全 for ($i = 0; $i < $chunks; $i++) { $partFile = $tmpDir . '/' . $fileMd5 . '_' . $i . '.part'; if (!file_exists($partFile)) { echo json_encode(['code' => 1, 'msg' => '缺少分片:' . $i]); exit; } } $targetFile = $completeDir . '/' . $fileMd5 . '.mp4'; // 如果目标文件已存在,先删除,避免重复合并导致内容叠加 if (file_exists($targetFile)) { unlink($targetFile); } // 按顺序写入,使用二进制安全模式 $targetHandle = fopen($targetFile, 'wb'); if (!$targetHandle) { echo json_encode(['code' => 1, 'msg' => '无法创建目标文件']); exit; } for ($i = 0; $i < $chunks; $i++) { $partFile = $tmpDir . '/' . $fileMd5 . '_' . $i . '.part'; $partHandle = fopen($partFile, 'rb'); if (!$partHandle) { fclose($targetHandle); echo json_encode(['code' => 1, 'msg' => '打开分片失败:' . $i]); exit; } // 每片最多 2MB,流式写入不会撑爆内存 while (!feof($partHandle)) { $buffer = fread($partHandle, 8192); if ($buffer !== false) { fwrite($targetHandle, $buffer); } } fclose($partHandle); } fclose($targetHandle); // 合并后清理分片文件 for ($i = 0; $i < $chunks; $i++) { $partFile = $tmpDir . '/' . $fileMd5 . '_' . $i . '.part'; if (file_exists($partFile)) { unlink($partFile); } } echo json_encode(['code' => 0, 'msg' => '合并成功', 'url' => '/upload/complete/' . $fileMd5 . '.mp4']);合并这个环节皱眉点有两个。第一,绝对不能拼字符串直接file_get_contents整个分片读到内存再写。一个2MB分片没问题,但如果有用户传了上百个分片,内存在循环里就会被吃满。fopen + fread + fwrite的流式写入才是正解,边读边写,内存占用恒定在很小范围。第二,合并前一定要检查目标文件是否存在,否则重复合并会造成目标文件内容翻倍。
清理分片这个动作,我放在合并成功后立即执行。这样可以避免临时目录里堆积几百个.part文件。但如果合并失败,分片会保留在临时目录,方便下次断点续传。这也是断点续传的容错机制:合并失败不算白传,重传时只需补上缺失的分片就行。
4. 踩坑实录:这些坑我花了一周才填平
4.1 断点续传不生效,全是session锁在作怪
实现完第一版后,我遇到一个诡异现象:上传大文件时,前端明明只发了3个并发分片请求,但PHP日志显示后面的请求排了很长的队,甚至超时。排查了一下午,最后发现问题出在PHPSession上。
PHP默认会为每个请求打开session文件并加锁,直到脚本执行完毕或显式session_write_close()才释放。多个分片请求并发进来时,第一个请求持有session锁,后面的请求全都堵在等待锁释放那一步,就跟单线程串行一样。
解决办法有两个,我用的是第一种:
- 在不需要使用session的接口里,直接不启动session;如果框架或公共逻辑里已经启动了,调用
session_write_close()手动释放锁。 - 或者改存储,比如把session存到Redis,并发瓶颈会缓解很多,但治标不治本,因为锁逻辑还存在。
上传分片这种接口压根不需要session存数据,所以最佳做法是分片接收接口里不要启session,已经在用的话exit前调用session_write_close()释放。
4.2 使用了PATH_INFO模式后WebUploader分片参数丢失
项目里框架是自研的,路由用的是 PATH_INFO 模式,比如/upload.php/uploadAction。WebUploader 的server配成这个地址后,后端$_POST里死活拿不到chunk、chunks这些参数,但$_FILES['file']又是正常的,分片还是按整体文件上传的形态到达。
原因很快定位:Nginx 的 PATH_INFO 模式和普通查询字符串在解析上存在差异,WebUploader 底层把分片参数放在POST formdata里,某些配置下这些参数被Nginx吞了或者没走到PHP的$_POST。
我的解决办法:server直接指向不带 PATH_INFO 的权限脚本地址,比如/server.php,分片参数正常接收。如果你的框架必须走路由,建议在路由配置里把这个接口单独豁免,保证PHP能正确填充$_POST。这个坑花了我半天时间,写出来给还在用 PATH_INFO 模式的朋友提个醒。
4.3 md5计算耗时太长,用户以为页面卡死了
第一批内测用户反映:“选完文件后整个页面像死了一样,好几分钟没反应。”我一看,500MB的视频文件,md5File计算期间页面交互全部冻住。
原因不复杂:md5File在高版本WebUploader里被设计成了分批次计算,它会把FileReader读取的结果在worker里处理,但还是在主线程上跑的。代码层面我的业务逻辑拖慢了它——我当时在fileQueued事件里做了大量其他DOM操作,还有就是用了低效的重复读取。
解决办法有3个:
- 计算期间显示遮罩层loading效果,明确告知用户“正在计算文件标识,请稍候”。
- 把大文件md5计算放进 Web Worker 里,不让它阻塞主线程。WebUploader 是支持自定义md5计算逻辑的,可以用 spark-md5 配合Web Worker实现。
- 如果文件大小超过1GB,考虑放弃整套文件md5,改用文件大小+最后修改时间+文件名生成一个业务标识。虽然不完美,但对上传成功率几乎没有影响,而且不用付出整个文件读取计算的成本。
实测下来,普通的500MB文件,用 WebUploader 内置md5计算大概7~10秒,用户能接受;超过1GB,建议切换到方案三,别让用户干等。
4.4 Threads并发3,分片总数量太少时并发形同虚设
有个很隐蔽的问题,前端设置threads: 3和chunkSize: 2MB,但如果文件只有6MB,总分片数才3个,并发上限3个分片,效果都一样。如果文件本身只有2MB,切成了1个分片,那分片上传就退化成普通上传。
这个场景一般不是问题,但要注意:分片数量过少时,断点续传的意义也不大。比如100KB的文件,传一半断了,重传成本本身就很低。所以这个方案最适合的文件区间是几十MB到几个GB。小于10MB的文件,直接走普通上传反而更省事,前端可以在fileQueued事件里判文件大小,小文件用一个非分片的server地址。
4.5 临时目录里分片越积越多,磁盘爆了
上线两个月后,运维报警服务器磁盘使用率超过90%。一查,是/upload/tmp里积压了大量.part文件,全是用户传了一半就放弃的残留分片。
解决方式我做了两层:
- 合并成功后立即清理分片(代码里已经做了)。
- 增加一个每日清理计划:遍历临时目录,删除最后修改时间超过24小时的
.part文件。断点续传虽然需要分片保留,但用户在24小时内没继续传的分片,基本可以认定不会再来补了。
清理脚本用find配合-mtime +1就能完成,如果你们的服务器有crontab,加到每日任务里即可。
4.6 大文件合并后的校验:怎么确定文件是完整的
合并完成后,前端一般会以为万事大吉,但后端最好再做一道校验。我的做法是:合并完成后,用PHP计算目标文件的md5,和前端提交的file_md5比对。不一致说明合并过程可能出了问题,返回错误码,同时把分片重新保留,等待重试。
比对逻辑加在merge.php中目标文件写完后:
$actualMd5 = md5_file($targetFile); if ($actualMd5 !== $fileMd5) { // 合并出错,保留分片,返回失败让前端重试 echo json_encode(['code' => 1, 'msg' => '文件校验失败,请重试']); exit; }md5_file对于大文件会一次性读入内存吗?不会,PHP内部对流式处理做了优化,内存占用保持在较低水平。这一步校验虽然会多花一点时间,但对“拼接出的文件能否被正常打开播放”起着决定性作用,强烈建议加上。
5. 性能优化与扩展方向
当前方案的核心目标已经达成:大文件稳定上传、断点续传、秒传、并发控制。但如果想把这套东西做得更成熟,有3个方向值得扩展。
第一个是使用更快的文件标识算法。md5计算耗时在高并发下还是略高,可考虑关联后端存储逻辑做分片级增量校验,前端只确认哪些分片没传,不强行全文件md5。断点续传和秒传的判定,可以用文件大小+分片级md5来替代全文件md5,效率高很多。
第二个是合并后异步处理。某些视频文件合并后还要转码、抽帧、提取信息,这些操作耗时较长,如果放在merge.php里同步处理,接口会被拖住。我的做法是接口只负责合并,返回成功后把文件路径写入队列,由另一个常驻进程处理后续加工。
第三个是前端进度展示的精细优化。当前WebUploader自带整体进度已经够用,但如果你想要显示每片的状态(哪些传输中、哪些等待、哪些已重试),可以监听uploadProgress事件,自己构建分片状态列表。对于上传体验来说,这一步往往是锦上添花,用户对进度感知会更准确。
就我个人实际操作里的体会而言,这套方案虽然实现起来不算复杂,但每个细节(分片大小、并发数、session锁、md5计算时机、合并校验)都能决定整个功能的上线质量。如果你现在也卡在大文件上传上,建议不要一上来就追求功能全齐,先把基础分片链路跑通,再加上断点续传,最后补秒传,走一步稳一步,比一次性上全方案更容易定位问题。