
做文件上传功能时迟早会遇到大文件这一关。不管是后台管理系统里传压缩包、教学平台传视频、还是工具类网站传安装包只要文件一上G传统的单次POST提交十有八九会卡在中途——要不超时要不断流好不容易传完还可能被服务器判定临时文件损坏。后来我换成WebUploader配合PHP做分片上传加断点续传才算把这个老大难问题理顺了。这篇文章就把我实际落地这套方案的过程完整拆开讲前端为什么选WebUploader、分片和断点续传背后到底做了什么、PHP端怎么安全地收分片和合并文件、以及我这里踩过的几个坑。内容会尽量通俗代码也是可以直接抄走改用的适合正在研究大文件上传、或者被上传超时折磨过的开发者参考。1. 大文件上传为什么会失败不解决分片就谈不上续传先说个直观的场景。某局域网办公系统里要上传一个约1.2GB的监控录像压缩包用最早那版普通表单上传浏览器等了三分多钟直接报“连接已重置”。再传一次又从头来。这不是网络差的问题而是大文件单次上传本身就有几个硬伤。1.1 单次HTTP上传的三个致命点第一是超时。绝大多数Web服务器和PHP-FPM都有默认的执行时间限制比如max_execution_time默认30秒Nginx的proxy_read_timeout默认60秒。文件一大请求耗时随便超过这些阈值服务端直接掐断连接客户端毫不知情。第二是内存。PHP里接收上传文件时虽然上传的临时文件是落到磁盘的但如果你在脚本里顺手用file_get_contents去整包读取1GB的文件直接能把PHP的内存上限撑爆。即便用move_uploaded_file移动临时文件如果表单里还带了其他字段处理逻辑在等待响应时也会占用大量的请求时长。第三是没有进度感。普通表单上传只有“等待响应”这一种状态传了一半断掉用户不知道是哪一秒断的也不知道能不能续只能重来。这种体验在内部工具里还能忍在面向用户的系统里基本是劝退级别的。1.2 分片上传为什么能绕开这些坑分片上传的核心思路不难把一个完整的文件在浏览器端切成若干个固定大小的块比如每块2MB、5MB或10MB然后逐个上传。这样原本一次1GB的大请求变成了一两百个小请求。每个小块请求都很短超时几乎不会发生每个小块独立校验失败只需要重传这一块更重要的是因为分片是稀疏的、可记录的服务端只要能判断哪些分片已经收到了就可以实现“断点续传”——用户不用从头再来。这也是WebUploader这类组件存在的意义。它把分片、并发数控制、进度回调都封装好了开发者主要精力可以放在后端如何接收和合并。我这里直接用WebUploader 0.1.5版本虽然维护早停了但稳定性和社区案例积累到今天依然很能打。2. 断点续传的核心原理MD5、分片清单和“假装秒传”断点续传听起来高大上真正落地时其实就是三件事文件指纹、已传分片登记、未传分片补齐。2.1 文件指纹用MD5标识“同一个文件”断点续传的前提是“同一个文件”。怎么判断是同一个最可靠的做法是计算整个文件的MD5值。WebUploader自带md5插件可以边选文件边计算文件指纹对于1GB以上的文件计算时间可以接受一般几秒到十几秒。我在项目里的做法是File对象选中的时候先用WebUploader.Uploader的md5配置项开启文件MD5计算计算完成后把MD5串存下来。这一步既服务于断点续传也顺带做了“秒传”——如果服务端发现某MD5对应的文件已经存在完整文件直接返回上传成功返回一个已存在文件的URL整个过程1秒都不到。这里有个细节MD5计算的是整个文件不是单个分片。单个分片也有自己的校验一般用分片内容的MD5或简单CRC。前端脚本里不能对分片再做一次全文件级MD5否则性能开销翻倍。2.2 分片清单服务端要知道“你缺哪几块”分片上传时前端按切片顺序给每个分片编号例如第0片、第1片、第2片……每个分片请求都要带上文件唯一标识MD5值或前端生成的随机ID当前分片索引总分片数分片大小服务端收到第一个分片时就为这个文件建一条上传记录记录里包含总分片数、已收到的分片索引集合。之后客户端每次上传前先向后端请求一次“文件当前状态”返回已收到的分片列表。前端拿到后把未传的分片排进上传队列已经传过的直接跳过这就是续传的完整闭环。2.3 一个反直觉的结论真正的断点续传往往在“前端重试”而非“后端拉取”我在最初设计时总以为断点续传就是“用户点了继续前端遍历所有分片看哪些没传”。实际用起来品牌产品里更顺手的方案是分片上传失败后前端自动重试这一个分片并把失败分片的索引记到本地。如果整个页面刷新了重新选中同一个文件通过MD5从服务端拉一份“已传分片列表”来决定从哪接着传。所以断点续传可以拆成两个层面会话内续传上传过程中某个分片失败剩余分片继续传跨会话续传页面刷新后再次选同文件先秒传已存在的所有分片再补齐缺口WebUploader对这两者都支持但跨会话续传的服务端逻辑需要自己写这也是本文后面PHP端实现的重点。3. 前端落地WebUploader的配置与关键事件处理前端我用纯WebUploader实现不引额外的框架依赖。下面这段配置是我在实际项目里改过很多遍的版本重点注释都标在代码里。3.1 初始化配置var uploader WebUploader.create({ // 上传按钮选择器 pick: { id: #filePicker, multiple: true }, // 文件上传路由 server: /api/upload/chunk, // 单个分片大小 5MB chunked: true, chunkSize: 5 * 1024 * 1024, // 并发上传分片数 threads: 3, // 开启MD5计算用于断点续传和秒传判断 // 注意这里计算的是整个文件的MD5不是分片 md5: true, // 自动上传关掉选完文件后手动控制 auto: false, // 表单额外参数 formData: { type: video }, // 分片发送前回调用来动态附加参数 formData: function() { var file this.file; // 注意不是uploader.file看上下文 return { md5: file.md5, chunkIndex: this.chunkIndex, chunkSize: this.chunkSize, chunks: this.chunks }; }, // 上传失败自动重试一次 retry: 1, // 单个分片超时时间 timeout: 60 * 1000 });有几个点单独说。chunkSize我建议不超过10MB太大就失去了分片的意义太小又会导致请求数量过多、HTTP握手开销变大。5MB是我在普通带宽下测试比较舒服的值。threads并发数不是越大越好。并发太大服务器磁盘IO和PHP-FPM进程都会被打满尤其是共享带宽环境3到5个并发已经足够。关于formData这里要注意WebUploader版本差异。老版本用方法形式动态返回表单参数是可以的新一点的分支会针对每个分片自动注入chunk、chunks等字段。为了兼容我在后端判断以动态返回的参数为准。3.2 关键事件选文件、计算MD5、开始上传uploader.on(fileQueued, function(file) { // 文件进入队列后先不急着上传 console.log(文件入队, file.name); }); uploader.on(md5Progress, function(progress) { // 计算整个文件MD5时的进度 console.log(MD5计算中, progress); }); uploader.on(md5Complete, function(md5, file) { // MD5计算完成后检查服务端是否存在完整文件 // 如果存在就直接标记为“秒穿”否则继续正常上传 checkIfFileExists(md5).then(function(result) { if (result.exists) { uploader.skipFile(file); // 跳过上传走秒传逻辑 setFileUrl(file, result.url); } else { uploader.upload(file); } }); }); uploader.on(uploadProgress, function(file, percentage) { // 注意这里的percentage是整个文件的进度还是分片进度 // WebUploader这个事件回调里给的是整体文件进度 console.log(文件进度, Math.round(percentage * 100) %); }); uploader.on(uploadSuccess, function(file, response) { if (response response.url) { console.log(上传完成, response.url); } }); uploader.on(uploadError, function(file, reason) { console.error(上传失败, reason); });这里checkIfFileExists是我封装的一个HTTP请求它会带上文件MD5请求服务端的“查询文件状态”接口。这个接口同时会返回已传分片列表所以我在逻辑里也用它来续传function checkIfFileExists(md5) { return $.ajax({ url: /api/upload/status, method: GET, data: { md5: md5 } }).then(function(resp) { // resp: { exists: bool, url: string, uploadedChunks: [] } return resp; }); }如果是断点续传场景我一般不手动调用uploader.upload(file)直接上传整个文件而是先在接接口返回已传分片列表然后遍历未传的分片逐个手动入队。WebUploader虽然提供了chunkList状态但手动控制更稳妥尤其是在并发和去重上。3.3 主动提升重试与并发体验的几个实践待上传的分片数量很多时我把失败重试和“自动跳过已传分片”分开处理了前者靠retry配置和uploadError监听后者靠checkIfFileExists返回的分片列表。这里有个经验在md5Complete回调之后再发起状态查询避免MD5还没算完就带一个空的md5去查导致服务端误判为新文件。另外我加了两个小逻辑如果某一个分片连续重试3次仍失败整个文件标记为失败并给用户明确的错误提示而不是无限重试暂停/继续操作绑定到uploader.stop(true)和uploader.upload()但stop(true)会清空队列所以更适合用uploader.stop()来暂停当前批次不让新分片发起4. PHP服务端接收分片安全校验、存储结构与合并策略前端切完片、发请求后端真正要做的事情比想象中多。最核心的是三块临时文件怎么存、怎么校验分片合法性、合并时怎么保证文件不坏。4.1 目录结构与临时文件命名我在项目里用下面的文件结构var/ upload_tmp/ {md5}/ 0.part 1.part 2.part {md5}.json # 上传进度记录 upload_final/ {md5}.mp4 # 合并完成后的最终文件以MD5值作为文件名目录前缀好处很多同一个文件的多个分片可以快速归档、空间占用容易统计、后期清理也方便。需要注意MD5值里有大小写和路径分隔的潜在问题我用preg_replace把非字母数字的字符直接替换掉只保留32位小写十六进制串避免目录名被注入。$md5 strtolower(trim($_POST[md5] ?? )); $md5 preg_replace(/[^a-f0-9]/i, , $md5); if (strlen($md5) ! 32) { throw new RuntimeException(无效的文件唯一标识); }4.2 接收单个分片的完整流程每个分片请求的处理我分成几个步骤第一步判断分片参数的合法性。chunkIndex必须是整数chunks表示总片数这两个值都要大于0。同时校验分片文件本身是否真的上传上来了$_FILES里对应的error必须是UPLOAD_ERR_OK。第二步把$_FILES[file][tmp_name]移动到临时目录。注意这里不能直接改名而是先判断目标分片是否已存在。如果存在说明是客户端重复上传直接跳过返回成功即可这也能避免并发时的互相覆盖问题。第三步写入进度记录。用一个JSON文件保存状态结构大概是{ md5: 640ab2bae077bed6f7c6a1d7f4e5f9b1, filename: demo.zip, totalChunks: 20, chunkSize: 5242880, uploadedChunks: [0, 1, 2, 4, 5], fileSize: 102400000 }每次上传分片后更新uploadedChunks。这里有一个并发问题多个线程同时写一个JSON文件时文件内容可能互相覆盖。我用flock加锁解决具体代码如下$progressFile $tmpDir . / . $md5 . .json; $fp fopen($progressFile, c); if (flock($fp, LOCK_EX)) { $content file_get_contents($progressFile); $progress json_decode($content, true) ?: []; $progress[uploadedChunks][] $chunkIndex; $progress[uploadedChunks] array_values(array_unique($progress[uploadedChunks])); ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($progress, JSON_UNESCAPED_UNICODE)); fflush($fp); flock($fp, LOCK_UN); } fclose($fp);这样每次最多只有一个进程在改进度文件不会出现两个分片同时写坏JSON的情况。4.3 文件合并的策略考量合并时机有两种常见选择每个分片到达后都判断是否已齐齐了就合并单独提供一个“合并”接口由前端在所有分片上传完成后主动触发我建议用第二种。原因很简单分片上传到齐的瞬间可能会有并发请求同时触发合并逻辑如果处理不好同一个文件会被合并两遍生成的最终文件可能因为并发写而损坏。加锁可以解决但更稳妥的是用“合并接口状态标记”前端所有分片都uploadSuccess后调用一次/api/upload/merge接口带上文件MD5和文件名。后端查看进度JSON比对uploadedChunks是否已包含0到totalChunks-1的所有下标确认无误后再合并。合并不复杂就是一个按序写文件的过程$finalFile $finalDir . / . $md5 . . . $ext; $dest fopen($finalFile, wb); $fp fopen($progressFile, r); $progress json_decode(stream_get_contents($fp), true); fclose($fp); for ($i 0; $i $progress[totalChunks]; $i) { $partFile $tmpDir . / . $i . .part; if (!file_exists($partFile)) { fclose($dest); throw new RuntimeException(分片缺失 . $i); } $src fopen($partFile, rb); stream_copy_to_stream($src, $dest, 1024 * 1024); fclose($src); } fclose($dest);这里的stream_copy_to_stream按1MB块复制不会一次性把整个文件读进内存对大文件适用。合并完后用clearstatcache再filesize校验一次大小最后删除临时分片和进度文件。4.4 对上传后“中间文件”的清理不能只写“清理”两个字具体怎么清理我踩过不少坑。如果有人上传到一半就关掉了浏览器临时分片和进度JSON会一直残留。我在服务器上写了一个定时脚本扫描upload_tmp目录删除最后修改时间超过24小时的文件。这个时间窗口要结合业务调整文件越大续传的间隔可能越长一般给到48小时比较安全。另外合并成功后进度JSON文件也要及时删除否则前端查状态时会误以为还在上传中。5. 秒传与续传的完整状态接口设计前端要顺利实现秒传和续传后端必须提供一个“查询文件状态”的接口。这个接口返回的数据结构决定了前端所有判断逻辑的走向。5.1 状态接口返回什么我设计的状态接口返回值长这样{ code: 0, exists: false, url: , uploadedChunks: [0, 1, 2, 4], totalChunks: 20, chunkSize: 5242880, fileSize: 102400000 }existstrue表示最终文件已存在前端可以走秒传existsfalse但uploadedChunks有内容表示部分分片已传前端只需要补齐缺失分片existsfalse且uploadedChunks为空表示全新上传后端实现时先查最终文件目录里有没有对应MD5的文件有就直接返回existstrue没有再读临时目录的进度JSON有就返回已传分片列表。5.2 秒传是怎么做到的秒传不是真的一秒传完整文件而是“因为服务端已经有一模一样的文件了所以不再需要传”。判断的核心就是MD5碰撞概率足够低。现实中128位MD5用于常规文件去重碰撞概率对这些业务场景完全够用不必上升到SHA-256处理速度和代码量都更省。在秒传场景前端拿到existstrue后直接调uploader.skipFile然后把接口返回的URL填入页面用户看到的体验就是“刚选中文件瞬间完成”。5.3 续传场景的前后端配合续传时前端拿到uploadedChunks数组后可以用uploader.upload()指定要传的分片但WebUploader并没有特别优雅的“只传某些分片”的API。我的做法是把已传分片从分片队列里标记为“已完成”再把剩余分片手动触发上传。更具体一点WebUploader每个file对象里有一个chunks数组每个元素对应分片的元信息。我遍历这个数组把index已在uploadedChunks里的分片跳过只把缺失的分片标记为待传。如果跳过太多用户看到的进度可能从80%开始这也符合直觉。6. 大文件场景下PHP端必须处理的三个边界问题即便主流程走通了大文件场景还会遇到一些边界问题我在联调时一个个解决过来的这里挑最重要的三个说。6.1 PHP执行时间和内存限制分片上传的单次请求很小所以max_execution_time不需要设得很高但还是要给30秒以上余量传大分片时音频转码、病毒扫描这类后置操作会拖时间。在合并接口里如果文件特别大合并时间可能超过默认的30秒限制我采用两步做法提高稳定性合并接口本身支持断点续合并即如果上一次合并超时中断下次请求检查进度文件里记录到第几个分片了从那里继续合并不用一次性执行完也可以拆成多次小批量合并实际项目里我会把max_execution_time设为120秒前提是Nginx的proxy_read_timeout也要对应调大否则PHP还没超时Nginx先断开了。6.2 上传目录空间不足的提前探测不是每次都要做但非常有必要。接收每个分片时我顺手判断一下磁盘剩余空间$freeSpace disk_free_space($tmpDir); if ($freeSpace 1024 * 1024 * 1024) { // 剩余低于1GB就先拒绝 return [code 1, msg 服务器空间不足请清理后重试]; }不要等到合并时才发现空间不够那时候所有分片都传完了才是最尴尬的。6.3 文件名与扩展名的安全处理合并后的文件文件名不能直接用前端传的原始文件名拼接进路径里。我把最终文件名统一为{md5}.{ext}扩展名从原始文件名里提取并用白名单过滤一遍。比如只允许zip, mp4, pdf, png, jpg等常见类型。如果是公开上传场景更严格一点可以检测文件开头的魔数而不是只信任扩展名。6.4 上传接口的并发写时序另一个隐藏问题是分片并发时多个请求同时判断“这个进度文件还不存在”然后各自创建初始JSON导致状态被重置。我用上面的flock锁并在第一次创建进度文件时用O_CREAT|O_EXCL模式如果发现文件已经存在就直接读取而不是覆盖。代码里这样处理$progressFile $tmpDir . / . $md5 . .json; if (!file_exists($progressFile)) { file_put_contents($progressFile, json_encode([uploadedChunks []]), LOCK_EX); }这一步做好之后后续所有读改写都在flock保护下进行并发安全性就稳了。7. 实操中遇到的几个典型问题和解决记录理论与主流程都讲完了再记几个我在实际调试中遇到、而且很可能也会出现在你项目里的问题。7.1 WebUploader拿到的是Blob后端$_FILES里却没有第一个典型现象是前端已经发起分片请求后端$_FILES里却是空的。排查后发现WebUploader的每个分片请求头里带的是Content-Type: application/octet-stream它把分片放在请求体里发送而不是以标准的multipart/form-data方式提交。解决方法是让WebUploader分片时强制使用multipartuploader.option(formData, { // 注意如果你用动态formData这里也要保持一致 });或者在后端手动解析php://input根据请求头判断是否是分片请求。我最后采用的是后者因为WebUploader默认行为这么稳定没必要强制改前端。7.2 Nginx的client_max_body_size只挡分片不挡合并Nginx默认的client_max_body_size是1MB但分片后每个分片只有5MB如果忘了调这个值分片请求直接413。设成18M左右就够用但合并接口是走普通POST还是GET取决于你的实现如果合并接口不带文件体就不会受这个限制影响。我在配置里统一设了client_max_body_size 20m;7.3 计算MD5时的内存峰值WebUploader的MD5插件是纯JS实现的计算1GB文件时内存峰值我观察到约600MB这在低端电脑上可能会卡顿。如果想要更好的体验可以在服务端增加一个“计算MD5并对比”的接口但那样部署成本更高。折中做法是如果目标用户多为老设备就分片上传时不建议大家全文件算MD5改为录制全文件的尺寸信息大小修改时间文件名作为弱标识。断点续传仍然可以工作只是“秒传”的判断不绝对严格了。7.4 合并时临时目录里出现0字节文件我在某次调试中发现合并后的文件比原始文件少了几十个字节但分片数量完全对得上。定位后发现是某个分片被写成0字节原因是后端判断分片已存在时直接跳过了移动但这个分片恰好上次是一次失败的空写脏数据留在了临时目录里。解决方法是跳过分片移动时要校验文件大小是否为0如果是0且原文件存在重新覆盖移动。后来我在接口里统一加上“分片大小必须大于0”的校验完成。if ($chunkSize 0) { return [code 1, msg 无效的分片大小]; }7.5 并发上传时合并接口被重复调用前端分片全部成功后如果某一分片其实失败了但前端误判成功合并接口就会提前触发。我在前端的uploadSuccess里会检查当前文件的所有分片确实都已到达再调用合并。后端同样要加一道保险合并前检查分片索引去重后是否完整覆盖缺任何一个就拒绝合并并把缺的分片号返回给前端。前端拿这个信息去补传这才是比较可靠的“断点续传”闭环。8. 我在设计这套方案时的一些取舍与经验最后总结一下个人经验层面的东西。第一不要把大文件上传做成一个“一次性交付”的功能。断点续传、秒传、并发数、分片大小这些参数上线后会根据实际网络环境不断调整。我的经验是先跑通小文件再把大文件扔进去最后才考虑并发压测。不要一上来就把threads调到10那是给自己找麻烦。第二给前端留足够多的状态查询接口比后端一次性解决所有问题要灵活。我的实践里单独的状态查询接口不仅服务于续传也可以用在“上传完成后检查文件详情”“秒传时获取已有文件下载URL”等多个场景一套接口多处复用。第三文件合并完成后最好再做一个大小校验合并后的文件大小与前端提交的fileSize字段对比偏差超过一个分片大小就标记为异常。这个校验成本很低但能在早期抓出不少奇葩问题。第四关于断点续传的“体验边界”我见过很多产品做过头了为了一秒续传在前端本地存了大量分片状态结果换台电脑、清个缓存就失效。真正的续传底层依赖总是要在服务端留一份本地只是加速层。9. 分片上传之外还能顺手解决的两个问题这套方案在实战中还意外带来了两个附加收益说出来供你参考。第一上传速度的稳定性提升了。以前单个大请求要经过代理、网关时任何一个环节超时都会失败。分片上传后每个分片都是短请求网络抖动只会影响当前分片不会导致整个文件作废。我在一个多人共享Wi-Fi环境里实测原来传500MB都要好几次重试分片后基本上一次成功。第二服务器端的并发模型更健康了。普通大文件上传会占用一个PHP-FPM进程很长时间比如30秒甚至几分钟这期间其他请求都会被排队。分片上传后每个分片占用请求时间只有几秒即便有3个并发也远好于3个1GB大文件同时占用进程。从运维视角看性能可控性好了非常多。所以分片上传这件事表面上是解决“传大文件失败”本质上是在改变客户端与服务端之间的协作模式让传输单元变小、可校验、可重试。这也正是断点续传能够稳定的根本原因。如果你正在被大文件上传折磨照这个思路把分片、MD5、状态接口、合并校验这四块逐一落地基本就能解决90%的问题。剩下10%就是不同业务场景下的参数调优和边缘情况修补到时候再按实际情况处理就行。