☰
大文件上传解决方案:WebUploader分片上传与PHP断点续传实战
2026/10/20 19:31:29 网站建设 项目流程

做文件上传功能时,迟早会遇到大文件这一关。不管是后台管理系统里传压缩包、教学平台传视频、还是工具类网站传安装包,只要文件一上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 }
  • exists=true表示最终文件已存在,前端可以走秒传
  • exists=false但uploadedChunks有内容,表示部分分片已传,前端只需要补齐缺失分片
  • exists=false且uploadedChunks为空,表示全新上传

后端实现时先查最终文件目录里有没有对应MD5的文件,有就直接返回exists=true;没有再读临时目录的进度JSON,有就返回已传分片列表。

5.2 秒传是怎么做到的

秒传不是真的一秒传完整文件,而是“因为服务端已经有一模一样的文件了,所以不再需要传”。判断的核心就是MD5碰撞概率足够低。现实中,128位MD5用于常规文件去重,碰撞概率对这些业务场景完全够用,不必上升到SHA-256,处理速度和代码量都更省。

在秒传场景,前端拿到exists=true后直接调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分片时强制使用multipart:

uploader.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%,就是不同业务场景下的参数调优和边缘情况修补,到时候再按实际情况处理就行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询