恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
大文件上传实战:秒传与断点续传的完整架构方案
首页
资讯中心
/
大文件上传实战:秒传与断点续传的完整架构方案
大文件上传实战:秒传与断点续传的完整架构方案
发布时间:2026/10/18 20:12:39
1. 需求拆解与整体方案设计1.1 先说清楚“超大附件”到底难在哪做上传功能最怕的就是文件一超过1GB各种问题全冒出来了。我见过不少新手直接拿普通的表单上传去扛大文件结果用户传了半小时进度条卡在99%最后浏览器直接报错。问题本质上是三个第一HTTP连接极不稳定。手机切个Wi-Fi、电脑锁个屏幕、或者网线被踢了连接就断了之前上传的数据全部作废。第二服务器有上传大小限制。PHP默认的upload_max_filesize是2MNginx默认client_max_body_size是1M就算你把这些调大了一次读入几百MB数据到内存里PHP-FPM进程直接被拖垮。第三用户没有那么多耐心重传。传了一半失败再让他从头点一次体验极其糟糕。秒传的本质是“识别文件”不是真的把文件快速传上去。如果服务器上已经有一份内容完全一样的文件哪怕路径不同、名字不同那就不需要再传了。断点续传的本质是“分片去重”把大文件切成小块已经传上去的分片不再重传只补传缺失的。两者配合下来用户端的感知就是要么瞬间完成要么断点自动续上不需要任何手动干预。1.2 为什么选择WebUploader而不是别的方案现在市面上做上传的库不少有老朋友Uploadify有后起之秀uppy还有各类云厂商的SDK。我自己的选择标准其实很朴素能干活的方案少能生产级可用的方案更少。WebUploader是百度早期开源的项目虽然现在官方维护频率不高了但它的基础设计放在今天依然扎实尤其是分片机制底层走的是Flash和HTML5统一封装老浏览器也能兼容这在企业内网环境里是非常实际的需求。我手里维护的一个归档系统用户的浏览器还是老版本的协议模式换成新库反而跑不起来。我的选型结论WebUploader负责前端分片、MD5计算、队列管理PHP后端负责接收分片、记录进度、合并文件。这套组合的成本极低不需要引入Redis之类的额外组件单机用文件系统就能完成断点续传的标记。1.3 三个核心流程之间的衔接关系很多人把秒传和断点续传当成两件事来做这是不对的。它们共用同一套“文件指纹”体系。完整流程是这样的用户选定文件后前端先计算整个文件的MD5值然后带着MD5去后端查询——如果后端发现这个MD5对应的完整文件已经存在直接返回“秒传成功”这就是秒传如果后端发现MD5对应的记录存在但文件不完整就返回“已上传分片列表”前端只传缺失的分片这就是断点续传如果后端什么都没查到就按正常分片全部上传。流程的关键就是那个MD5它既是秒传的查询键又是断点续传的分片索引依据。我这里用的是内容MD5不是简单的文件名。因为同名文件内容可能完全不同反之内容相同的文件可能名字完全不同以内容为准才能保证秒传不传错。2. 秒传识别用MD5做文件去重2.1 前端怎么计算大文件MD5而不卡死页面计算一个2GB文件的MD5如果直接在主线程里干页面会直接卡死用户以为浏览器崩了。正确做法是用Web Worker在后台线程算同时利用sliced方法把文件分块读取逐块累积Hash这样既不阻塞UI又能算完整文件指纹。前端我用的是spark-md5这个库它支持增量计算。核心实现思路是在WebUploader的beforeSend回调发请求之前或者在上传任务加入队列后就开始预计算。我没有直接改WebUploader的源码而是利用它自带的md5配置项给它塞一个计算函数进去。需要注意一点WebUploader在默认配置下会自己处理文件分片这个分片和秒传的MD5计算是两个独立的概念不要混淆。实际计算的时候我会控制切片大小不要让每个切片太大否则单次读取耗内存。我通常用的是2MB一片// 利用Worker线程计算文件MD5 const worker new Worker(spark-md5-worker.js); let chunks []; const chunkSize 2 * 1024 * 1024; // 2MB一片 function calculateFileMD5(file) { return new Promise((resolve, reject) { let offset 0; const readNext () { if (offset file.size) { worker.postMessage({ cmd: end }); return; } const slice file.slice(offset, offset chunkSize); chunks.push(slice); const reader new FileReader(); reader.onload (e) { worker.postMessage({ cmd: md5, data: e.target.result }); offset chunkSize; readNext(); }; reader.readAsArrayBuffer(slice); }; worker.onmessage (e) { if (e.data.cmd result) { resolve(e.data.md5); } }; readNext(); }); }Worker内部的核心逻辑就是不断调用spark-md5的append方法累积数据最后取end()的返回值。整个过程对用户来说是无感的唯一要注意的是大文件算MD5确实需要时间比如2GB的文件在普通电脑上算完可能要几十秒到一分钟。这期间用户会看到“准备中”的状态我在产品上把这个阶段显示成“解析中”避免用户误以为卡死。2.2 后端MD5查询接口的实现细节后端这边我建了一张file_meta表作用是记录所有已经上传完成的文件的MD5指纹、大小、存储路径。public function checkAction() { $md5 $this-getRequest()-getPost(md5); $fileName $this-getRequest()-getPost(name); if (empty($md5)) { return $this-responseJson([status error, msg 缺少md5参数]); } $fileModel new FileMetaModel(); $fileInfo $fileModel-getByMd5($md5); if ($fileInfo file_exists($fileInfo[storage_path])) { // 秒传直接返回文件访问路径 return $this-responseJson([ status success, action secpass, file_path $fileInfo[storage_path], size $fileInfo[file_size] ]); } // 查询分片上传记录 $uploadSession $this-getUploadSession($md5); if ($uploadSession) { $uploadedChunks $this-getUploadedChunkList($uploadSession[session_id]); return $this-responseJson([ status success, action resume, session_id $uploadSession[session_id], uploaded_chunks $uploadedChunks ]); } // 全新上传 return $this-responseJson([ status success, action new, session_id $this-createUploadSession($md5, $fileName) ]); }秒传的关键在这里服务器存的是同一份物理文件不管用户传多少次磁盘上只有一份。所以检查的时候不能只看数据库记录还要确认文件确实存在。出现过这种情况数据表里还有记录但磁盘文件被人为清理掉了结果前端显示秒传成功点开附件却是404。所以在查询时我加了一个file_exists判断这是踩坑之后补上的非常重要。2.3 分片去重与秒传的统一处理很多人会问秒传判断是完整的文件那如果文件只有一部分相同怎么办这就要说到分片去重了。我的做法是在创建上传会话时不仅记录完整的文件MD5还会记录每个分片的MD5。前端每个分片上传之前会带上这个分片自己的MD5后端检查这个分片是否已经存在如果存在就跳过上传直接标记为已完成。这样即便整个文件没有秒传成功单个分片也有可能秒传成功极大减少带宽消耗。我遇到过一个实际场景某个视频文件3GB后端文件完整存在但是前端因为MD5计算函数在低配机器上执行时间太长用户在上传开始前就关掉了页面。第二次打开还是这个文件这次MD5算出来了后端识别到相同内容直接秒过。这就是秒传真正的价值。3. 断点续传分片上传的状态标记与恢复3.1 分片策略与参数配置WebUploader默认的chunkSize是undefined意味着不分片。对于超大附件必须显式配置分片大小。这个值怎么定我建议在配置里这样写var uploader WebUploader.create({ // 关闭UI插件自定义UI swf: /static/js/webuploader/Uploader.swf, server: /api/upload/chunk, pick: #picker, accept: { title: All Files, extensions: zip,rar,tar,gz,mp4,avi,... }, chunked: true, chunkSize: 5 * 1024 * 1024, // 5MB分片 threads: 3, // 并发上传数 fileNumLimit: 5, fileSizeLimit: 10 * 1024 * 1024 * 1024, // 10GB上限 autoStart: false, // 计算文件MD5 md5: true });分片大小的选择直接影响体验。我用过2MB、5MB、10MB三挡对比过。在千兆内网下10MB分片性能最好网络往返次数少但在公网环境10MB分片一旦网络抖动重传成本太高。我最终选5MB这是一个平衡点公网和专线环境下都表现稳定。threads并发数建议控制在3到5之间太高了容易打满带宽反而拖慢整体速度而且后端接收并发分片时对磁盘写入压力也大。3.2 后端接收分片的核心逻辑后端接口接收的参数包含chunk当前分片序号、chunks总分片数、name文件名、session_id会话标识以及分片文件本身。我的处理逻辑是这样的public function chunkAction() { $sessionId $this-getRequest()-getPost(session_id); $chunk $this-getRequest()-getPost(chunk); $chunks $this-getRequest()-getPost(chunks); $file $this-getRequest()-getFile(file); if (!$file || $file-getError() ! UPLOAD_ERR_OK) { return $this-responseJson([status error, msg 分片上传失败]); } // 分片存放目录upload_tmp/session_id/chunk_0001.part $chunkDir UPLOAD_TMP_DIR . / . $sessionId; if (!is_dir($chunkDir)) { mkdir($chunkDir, 0777, true); } $chunkFilePath $chunkDir . /chunk_ . str_pad($chunk, 4, 0, STR_PAD_LEFT) . .part; // 如果分片已存在直接返回成功实现断点续传的关键 if (file_exists($chunkFilePath) filesize($chunkFilePath) $file-getSize()) { file_put_contents($chunkDir . /upload_progress.json, json_encode([ session_id $sessionId, chunk $chunk, chunks $chunks, name $this-getRequest()-getPost(name), md5 $this-getRequest()-getPost(md5), status uploading, updated_at time() ])); return $this-responseJson([status success, chunk $chunk]); } // 使用move_uploaded_file移动临时文件到目标目录 if (!$file-moveTo($chunkFilePath)) { return $this-responseJson([status error, msg 分片保存失败]); } // 记录分片上传进度便于前端恢复 file_put_contents($chunkDir . /upload_progress.json, json_encode([ session_id $sessionId, chunk $chunk, chunks $chunks, name $this-getRequest()-getPost(name), md5 $this-getRequest()-getPost(md5), status uploading, updated_at time() ])); return $this-responseJson([status success, chunk $chunk]); }这里有几个细节值得多说一句。第一分片文件名必须固定序号用str_pad补零方便后续排序合并。第二同一个分片重复上传时如果大小一致直接跳过这是断点续传能生效的基础。第三进度文件每收到一个分片就更新一次这样即使服务器重启也能从upload_progress.json恢复现场。3.3 前端如何实现“续传”而不需要用户手动操作WebUploader本身没有内置断点续传功能需要自己配合后端接口实现。我的做法是在每个分片上传之前先调用checkAction拿到这个文件的已上传分片列表然后通过WebUploader的beforeSend回调来跳过已经上传的分片。uploader.on(beforeSend, function (block, data) { // 请求接口获取该文件已上传的分片列表 $.ajax({ url: /api/upload/check, data: { md5: block.file.md5 }, type: POST, async: false, // 同步等待返回确保跳过逻辑执行 success: function (res) { if (res.action resume) { block.preserved true; // 标记为保留 data.uploadedChunks res.uploaded_chunks || []; } } }); }); uploader.on(uploadBeforeSend, function (block, data) { if (block.preserved data.uploadedChunks.indexOf(block.chunk) ! -1) { // 已上传过跳过 block.skip true; } });block.skip设为true后WebUploader会跳过这个分片的实际上传但仍然会触发uploadSuccess回调这样进度条就能走完整个流程。这个方案的体验是用户刷新页面重新选择同一个文件前端还是继续算MD5或者从浏览器里缓存拿我这边做了localStorage缓存算过就不重算然后后端告诉它哪些分片已经有了前端一秒内就补传了缺失的分片全程无需任何操作。4. 分片合并文件完整性校验与落盘策略4.1 合并时机怎么确定当所有分片都上传完成后前端会调用合并接口。但这里有一个坑前端认为传完了不代表后端分片真的完整了。可能最后一个分片传输时其实损坏了只是PHP端收到了文件就返回成功。所以后端合并前必须校验分片数量和大小。public function mergeAction() { $sessionId $this-getRequest()-getPost(session_id); $md5 $this-getRequest()-getPost(md5); $fileName $this-getRequest()-getPost(name); $totalChunks $this-getRequest()-getPost(chunks); $chunkDir UPLOAD_TMP_DIR . / . $sessionId; $progressFile $chunkDir . /upload_progress.json; if (!file_exists($progressFile)) { return $this-responseJson([status error, msg 上传会话不存在或已过期]); } $progress json_decode(file_get_contents($progressFile), true); // 校验已收到的分片数量 $chunkFiles glob($chunkDir . /chunk_*.part); if (count($chunkFiles) ! intval($totalChunks)) { return $this-responseJson([ status error, msg 分片数量不完整已上传 . count($chunkFiles) . / . $totalChunks ]); } // 根据progress里的分片大小校验 // ...此处省略详细大小校验逻辑 // 开始合并 $finalPath UPLOAD_DIR . / . $md5 . _ . uniqid() . _ . $fileName; $fp fopen($finalPath, wb); if (!$fp) { return $this-responseJson([status error, msg 无法创建目标文件]); } // 按序号排序分片文件 sort($chunkFiles, SORT_STRING); try { foreach ($chunkFiles as $chunkFile) { $chunkFp fopen($chunkFile, rb); if (!$chunkFp) { throw new Exception(无法读取分片文件: . $chunkFile); } // 流式拷贝避免大文件占用内存 stream_copy_to_stream($chunkFp, $fp); fclose($chunkFp); } } catch (Exception $e) { fclose($fp); // 合并失败保留分片不清理 return $this-responseJson([status error, msg 合并失败: . $e-getMessage()]); } fclose($fp); // 合并完成后记录到file_meta表下次直接秒传 $fileModel-save([ md5 $md5, storage_path $finalPath, file_size filesize($finalPath), created_at time() ]); // 清理临时分片目录 $this-cleanupChunkDir($chunkDir); return $this-responseJson([ status success, file_path $finalPath, size filesize($finalPath), action merged ]); }合并这里我用的是stream_copy_to_stream不是file_put_contents($fp, file_get_contents($chunkFile))。两者的区别在于内存使用。file_get_contents是一次性把整个分片读入内存如果分片是5MB并发几个进程同时合并内存立刻爆掉。stream_copy_to_stream是按块拷贝PHP内部自有个缓冲机制实际内存占用非常低。我在一次2GB文件合并测试中峰值内存占用不到30MB。4.2 合并失败时的降级策略合并过程中可能出现极端情况比如服务器磁盘满了、某个分片文件在读的时候被其他进程删了。这种时候最忌讳的是把整个临时目录清理掉那样前面的分片全部白传了。我的策略是合并失败不清理分片返回特定错误码前端收到后自动重新触发合并请求。只有当合并成功或者用户主动取消才清理分片。还有一种情况合并过程中PHP脚本执行超时。默认max_execution_time是30秒合并2GB文件肯定不够。处理方式有两种一个是脚本开头用set_time_limit(0)取消限制另一个是把合并任务扔到CLI脚本里异步执行。我线上用的是异步方案合并接口只负责创建合并任务返回一个task_id真正合并由一个常驻后台进程去做前端轮询合并状态。这样就算文件再大HTTP请求也不会超时。如果你没有队列系统用set_time_limit(0)是可以接受的折中方案但要注意PHP-FPM进程会被占用并发量高档位时容易堆积。4.3 合并后的最终校验合并完成不代表万事大吉还要校验合并出来的文件是否真的和用户上传的源文件一致。这一步我在后端重新计算完整文件的MD5和前端提交的MD5比对。如果不一致说明分片在传输或合并过程中出了问题我会自动执行一次“补传”策略删除最后一个分片让前端重新传最后一个分片再重新合并。这个逻辑在实际生产中出现过尤其是用户网络非常差导致某个分片实际上传的不是完整数据时最后校验能兜住那个极低概率的错误。$finalMd5 md5_file($finalPath); if ($finalMd5 ! $md5) { // 标记文件异常删除异常记录返回特殊错误前端提示重试 unlink($finalPath); // 保留分片 return $this-responseJson([ status error, code FILE_MD5_INVALID, msg 文件MD5校验失败请重新上传 ]); }对于超大文件md5_file计算会比较耗时但这是确保数据完整性最直接的兜底手段。我建议保留这个步骤不要为了性能省掉它。数据完整性永远高于性能。5. 常见问题与排查技巧实录5.1 前端明明传完了后端却提示缺少分片这个问题我遇到过很多次最典型的原因是WebUploader的并发机制如果设置了threads: 3同时有3个分片在传其中最后一个分片可能在uploadSuccess回调触发的时候实际上后端还没落盘。等到前端调merge接口时后端一看分片列表少了最后一个。解决办法是合并前加一个“辅助校验”接口前端在调merge之前先调一次checkChunks后端返回缺失的分片号如果有缺失前端自动把缺失分片补传一遍然后再merge。我实现的checkChunks接口逻辑就类似上面mergeAction里的分片数量校验但它不合并只返回缺失列表。这个接口在恢复场景中也很有价值刷新页面后前端先调它拿到需要的分片序号。5.2 秒传后文件打开404这个排查思路很清晰如果file_meta表记录的storage_path指向了一个已经被清理的目录前端展示附件时就会404。原因往往是磁盘清理脚本直接删了上传目录里的文件但没有同步清理数据库记录。修复方案有两个层面一是程序层面查询时加file_exists判断我前面已经强调过二是运维层面清理脚本删除文件前先查表或者直接标记删除状态。我在这条上吃过亏现在是双保险。5.3 大文件MD5计算太慢用户等得不耐烦这个问题的根因是计算完整文件MD5确实需要时间对于10GB文件即使加了Worker单线程算MD5也要一两分钟。我的优化措施有三个第一个是浏览器端做localStorage缓存同一文件同一大小同一修改时间MD5按用户维度缓存到本地第二次直接读取缓存不重新计算。缓存时要加上文件大小和最后修改时间作为辅助判断防止文件内容被篡改但MD5还复用旧的。第二个是提前开始计算不要等用户点“上传”才开始在用户选择文件后立即启动MD5计算等用户填写备注、选择分组等信息时MD5基本就算完了。第三个是允许用户跳过秒传检测直接走分片上传。有些用户网络很好不想等MD5计算的时间可以在UI上提供一个开关“跳过秒传直接上传”这样前端不计算完整文件MD5直接进入分片流程损失的是重复文件秒传的能力换来的是即时上传的体验。5.4 分片上传过程中PHP截断请求或进程崩溃当分片较大且并发较高时PHP-FPM进程可能因为内存或执行时间问题被kill。排查时先看php-fpm日志如果发现Maximum execution time exceeded报错需要把max_execution_time调高。如果看到Allowed memory size exhausted就需要检查代码里是否不该有的file_get_contents大文件读取。我经常对新人说处理大文件时脑子里要时刻绷着一根“流式处理”的弦所有读写一律走fopenfreadfwrite或stream_copy_to_stream绝不用一次性读入的方式。另外PHP的post_max_size和upload_max_filesize也要分别调大否则分片会直接被丢弃。5.5 如何清理历史遗留的临时分片如果用户上传到一半不传了临时分片目录会一直躺在磁盘上。这些垃圾文件不清理磁盘迟早爆掉。我在后端做了一个定时清理任务每天凌晨检查所有upload_tmp目录如果一个目录的mtime超过24小时没有变化就说明这个上传会话已经废弃直接删除整个目录。对于file_meta里已经合并完成且超过保留期限的文件可以按业务需求做归档或删除但要谨慎不要默认删除业务数据这里的清理只针对上传临时文件。5.6 细说WebUploader配置的容易踩坑项WebUploader的配置项其实不算多但有好几个位置容易踩坑。第一个是accept的extensions配置它的作用是过滤用户选择的文件类型但是它不限制拖拽上传的文件如果产品要求无论如何不能传某类文件后端必须做二次校验。我用的是前端负责引导后端负责强制校验后端接分片时不校验扩展名合并完成后校验MIME类型不合法直接删除。第二个是chunkSize的单位是字节不是KB有同事填过5以为是5MB结果分了5字节一片瞬间产生几十万个分片文件。第三个是duplicate默认是false同一个文件不能重复上传但如果用户要上传两个同名但不同内容的文件需要设置duplicate: true。这个在项目交付时经常被测试人员当作bug提出来务必提前处理。5.7 关于并发合并的锁问题有一个进阶场景两个用户同时上传同一个文件后端file_meta里没有记录两个人都走分片上传最后几乎同时调merge接口。如果不加锁两个人可能会同时合并出两个同名物理文件浪费磁盘空间。解决办法是合并前先查file_meta表比MD5如果已经存在则直接复用已有文件不再合并。如果两个进程同时查到记录不存在那就用数据库的INSERT ... ON DUPLICATE KEY UPDATE或者Redis的SETNX做合并锁。我这边用的最简单的方案在file_meta表上给md5建唯一索引合并前尝试插入一条statusmerging的记录插入成功的进程执行合并失败的进程等待轮询等合并完成直接复用结果。6. 生产环境中的配置参考清单我把线上环境用到的关键配置整理成一张清单方便你直接复制。位置配置项建议值说明php.iniupload_max_filesize20M单分片大小上限一定要大于分片大小php.inipost_max_size25M必须大于upload_max_filesize预留一些余量php.inimax_execution_time300合并接口如果同步处理需要足够时间php.inimax_input_time300防止大分片读取超时Nginxclient_max_body_size30M分片最大5MB时请求体小于5MB但留余量更稳Nginxfastcgi_read_timeout600PHP响应超时配置WebUploaderchunkSize5MB平衡网络往返和重传成本WebUploaderthreads3并发分片数公网环境建议3后端upload_tmp目录与正式目录分盘避免临时文件占满正式磁盘配置表里注意一个细节post_max_size一定要比upload_max_filesize大因为请求体里除了文件还有一堆POST参数和请求头如果正好相等分片姿势稍微大一点就会被PHP拒绝。我见过有人两个值配成一样结果测试时小分片没问题大分片全部报“服务器响应异常”查了半天才发现是post大小的锅。另一个容易被忽略的是Nginx的client_body_buffer_size它表示请求体的缓冲区大小超过部分会写入临时文件。对于大分片建议把它调大一点例如16K或者干脆不显示设置让它走系统默认值Nginx自身会处理临时文件不太需要过度干预。但client_max_body_size必须显式设置否则Nginx默认才1MB分片直接被拒。7. 从实战中提炼的几条体会做这个功能前后花了我大概两周时间第一周做核心逻辑第二周全在打磨边界情况和排查问题。有几条体会想特别分享给正在做类似功能的人。第一不要迷信“一次性到位”的完美方案。秒传、断点续传、分片上传这三个能力是可以分阶段上线的。如果你的系统已经有瀑布式上传先把“断点续传”做了这个收益最明显因为大文件失败重传的问题立刻解决。然后再加秒传减少重复文件的带宽浪费。分阶段上线还有个好处每个阶段的回归测试范围更小不容易出大面积bug。第二日志一定要打全。我在开发过程中给每个分片的上传、跳过、合并失败、校验失败路径都加了日志。后来排查问题时日志给了很大的帮助。特别是生产环境用户报“传了但文件不完整”如果日志里有“分片数量不完整”就能立刻定位而不是让用户一遍遍重试。第三测试不能只测内网。我一开始在局域网里测试一切顺畅信心满满地上到公网结果分片上传速度慢得像蜗牛。后来发现问题出在并发数太高TCP拥塞控制机制在公网环境下表现得非常敏感threads调成3之后好了很多。另外公网环境的丢包率会让个别分片失败WebUploader自带重传机制retry配置要打开重传次数我设的是3次。第四安全不能漏。上传功能是攻击者很喜欢光顾的点。我在接口层面做了三件事登录鉴权必须带token后端校验文件扩展名和MIME类型上传目录禁止PHP解析。这三件事缺一不可。尤其目录禁止执行前阵子我加固某套老系统时发现有人传了带PHP代码的图片马就是因为上传目录还能解析PHP吓得我当场把所有上传目录的exec权限都关了。最后再分享一个我自己习惯用的验收小技巧用户上传一个1GB文件上传到50%时手动断开网络或杀掉浏览器进程然后重新进页面选择同一个文件观察它是不是秒补剩余分片。如果整个流程走通你的断点续传就基本合格了。再准备两个不同文件名但内容相同的文件如果第二个文件上传时都显示“秒传成功”你的秒传逻辑也可以结项了。其余的就是靠时间积累和线上反馈慢慢磨。