首页 / 资讯中心 / 文章详情

PHP+WebUploader实现大文件分片上传:秒传与断点续传实战解析

PHP+WebUploader实现大文件分片上传:秒传与断点续传实战解析 ★ FEATURED ARTICLE
写这篇东西时我正在调一个分片上传的接口前端报了个“文件已上传”的提示但后端合并时发现少了最后一个分片。用户那边一直催网盘里躺着好几个GB的监控视频等着传。这种“明明提示秒传结果合并失败”的场景搞过WebUploader的人估计都碰到过。这个坑背后其实就是PHP配合WebUploader做超大附件上传时秒传和断点续传的核心问题前端切了多少片、后端到底收到了哪几片、合并时按什么规则拼回去、怎么判断是否真的能秒传。标题里问的是“如何实现”但实际开发中真正要解决的是一整套分片状态管理的问题。这篇文章不做那种讲完原理就完事的科普我把前后端完整流程、接口设计、关键参数和踩过的坑都整理出来直接照着搭就能跑。1. 先把问题说清楚秒传、断点续传到底在解决什么1.1 传统附件上传为什么总在“大文件”上卡住很多PHP项目一开始做上传都是前端直接丢一个input typefile后端用move_uploaded_file存一下。小文件还好三五MB的PDF、压缩包完全够用。但一旦文件大到几百MB甚至几个GB问题就全暴露了PHP配置的upload_max_filesize和post_max_size默认只有2MB和8MB不改的话上传直接报错。就算改大了整个文件一次性POST上来PHP进程内存和超时时间都扛不住。网络一抖传了98%断掉用户就得从头再来。这就引出了第一个关键思路别让大文件以“一个文件”的身份走HTTP上传而是切成很多小块分多次请求发到后端。每一块都小单次请求快失败影响范围小。WebUploader这个组件本质上就是帮你把“切块-发请求-记录进度”这套动作封装好了。1.2 秒传和断点续传其实是两回事很多人把这两个词混在一起其实它们是两个独立但配合使用的机制秒传文件在服务器上已经存在比如别人刚传过同一份文件那么当前用户完全不需要传内容后端比对文件特征后直接返回“这个文件已经有了”前端瞬间跳完成状态。断点续传文件服务器上没有或者没有传完那么把文件切块接收端记录“我已经收到第1、2、4片第3、5片没收到”下次继续时前端只补传缺失的块。两者技术上的交集点在“文件唯一标识”。秒传靠它做去重断点续传靠它定位已有分片。在上传场景里最通用的文件唯一标识是MD5。WebUploader官方就提供了基于SparkMD5的文件MD5计算插件把一个文件的MD5值算出来后端拿这个值做索引。1.3 从整体流程看一遍秒传和续传各自出现在哪一步完整的一次上传流程大致是下面这样的用户在页面选中文件前端对文件计算MD5。前端先把MD5发给后端询问这个文件是什么状态后端查表和目录返回如果文件已存在完整版本返回“可秒传”前端直接收工。如果文件传了一半返回已有分片列表前端跳过已传分片接着传。如果完全没有记录前端从第0片开始传。前端把文件按2MB或5MB切成块每个块单独发一个上传请求带上文件MD5、块下标、总分片数。后端每次收到分片就存到临时目录并更新上传进度记录。前端最后一分片传完后触发合并接口后端把所有分片按顺序拼装成完整文件。这套流程里秒传在步骤3被拦截断点续传在步骤3和步骤4之间通过“跳过已有分片”实现。逻辑并不复杂难点在后端怎么准确记录和判定这些状态。2. 方案与设计用WebUploader时后端要先想清楚这几件事2.1 为什么选WebUploader而不是自己写分片上传有人会觉得不就是把文件切片然后发请求嘛我axios加一个循环也能写。确实能写但WebUploader这个老牌组件有它省心的理由自带chunked分片上传模型和chunkSize、threads并发控制参数很多边界情况已经处理过。官方插件支持MD5分片计算不用你自己去研究大文件怎么算哈希不卡死。它不是一个框架绑定的插件原生JS直接用对老项目特别友好。社区里关于WebUploader和PHP配合的实践非常丰富踩坑答案基本都能找到。当然它有缺点最明显的是官方维护不再积极代码风格也比较旧。但站在“快速交付、稳定运行”的角度它依然是很多PHP项目处理大附件上传的优先选择。2.2 后端的存储与状态设计动手写代码前先想清楚两个关键设计临时文件放哪里上传进度记在哪里。临时分片目录我习惯的做法是/storage/uploads/tmp/20240115/xxxxmd5xxxx/目录路径按日期加文件MD5组织好处是根目录不会堆太多目录而且后续清理过期任务非常方便——直接把某个日期文件夹整个删掉就行。合并后的文件存到另一个目录/storage/uploads/final/如果项目用了对象存储合并上传到对象存储即可但本地临时文件还是按上面目录组织。数据库方面需要一张附件上传记录表核心字段大致是这样字段类型说明file_md5varchar(32)文件唯一标识也是分片目录名file_namevarchar(255)原始文件名file_sizebigint文件总字节数total_chunksint总分片数uploaded_chunkstext已上传分片编号逗号分隔或JSON数组statustinyint0上传中 1上传完成file_pathvarchar(255)合并后的完整文件路径created_atdatetime创建时间这张表承担两件事查看进度时查询uploaded_chunks字段返回给前端合并时判断所有分片是否齐了。字段不用多够用就行。2.3 接口统一风格三个后端接口搞定一切按我实际项目里的做法后端只需要提供两个核心接口加上一个合并接口三个就能覆盖全部场景check接口相当于预检接收file_md5、total_chunks返回是否可秒传、已上传分片列表。upload接口分片上传接收分片文件以及file_md5、chunk_index、total_chunks把分片存到临时目录同步更新记录。merge接口合并文件接收file_md5后端完成分片排序、合并、完整性校验。设计上有个要点不要在每个分片上传请求里都做“文件级”的复杂操作比如每次都扫描分片目录计算还缺哪几片。这种扫描很耗时分片一多后端接口会变慢。直接查表里的uploaded_chunks字段十几毫秒就能返回。3. 前端配合细节WebUploader的关键参数到底怎么配3.1 分片大小与并发数没有固定的正确答案WebUploader的分片文件大小由chunkSize控制官方默认值偏大我在项目里通常设置为2MB到5MB。为什么是这个区间分片太小比如512KB单次请求快但对后端请求压力大一个GB文件要发2000多个请求分片太大比如20MB虽然请求数量少但一旦遇到网络波动单个分片失败重传的成本会高。并发数threads也是一个需要斟酌的点。默认是3我这里是3到5。并发数过高对后端是压力尤其是PHP-FPM模式下并发请求会同时占住多个进程。实际生产里5已经能让用户的带宽利用得比较满再高的收益不大。3.2 MD5计算小心大文件卡死浏览器WebUploader文件上传前通常要先算MD5但一个几个GB的文件用普通方式一次性读入内存计算浏览器会卡死用户可能直接关页面。解决方案是分片计算官方提供的uploader.md5插件会遍历文件分片每读一个分片算一次逐片更新哈希值。配合WebUploader的progress事件还能把计算进度展示给用户。实际开发中MD5计算建议放在上传前做并且传给后台的时机很关键等MD5算完后再让用户点上传按钮还是选中文件后自动后台计算体验更好的方案是后者选中文件后立刻进入计算状态计算完成自动触发check接口进而自动进入上传流程。这样用户基本感觉不到“计算”这一步的存在。3.3 前端核心初始化代码下面这段是我在项目里经常用的WebUploader初始化模板直接展示了分片、并发、MD5这些核心项var uploader WebUploader.create({ pick: #picker, // 选择按钮 swf: /path/Uploader.swf, // 低版本浏览器回退 server: /api/upload, // 分片上传接口 // 关键参数 chunked: true, // 开启分片 chunkSize: 3 * 1024 * 1024, // 每个分片3MB threads: 3, // 并发上传数 duplicate: true, // 允许重复选择同一文件 // 可选开启MD5计算 prepareNextFile: true, // 文件类型限制 accept: { title: files, extensions: zip,rar,pdf,mp4,mov,ts,mkv, mimeTypes: application/zip,application/pdf,video/mp4,video/quicktime,application/octet-stream } });初始化后需要注意一个细节WebUploader默认的formData会根据分片生成参数像chunk和chunks但它们默认的名和你要给后端的命名可能不一样。你在uploadBeforeSend方法里可以主动补充和覆盖参数uploader.on(uploadBeforeSend, function (block, data) { data.file_md5 fileMd5; // 全局计算好的文件MD5 data.chunk_index block.chunk; // 当前分片序号 data.total_chunks block.chunks; // 总分片数 data.chunk_size block.end - block.start; // 如果该分片已在后端存在直接跳过的逻辑也在这里判断 });这个钩子非常关键后端是否能用好分片信息完全取决于这里字段是否正确传递。我自己第一次调试时就是忘了把chunk_index改对结果后端按默认参数接分片乱序合并出来的文件全是乱码。4. PHP后端实现三个核心接口手写可复用代码4.1 check接口秒传判断和断点续传的“情报中枢”check接口做的事情有两件第一判断文件是否已经完整存在且状态为完成第二读取该MD5对应的已上传分片列表返回给前端。public function checkAction() { $fileMd5 trim($this-request-get(file_md5, )); // 前端传来的文件MD5 $totalChunks (int) $this-request-get(total_chunks, 0); if (strlen($fileMd5) ! 32) { return $this-json([error 无效的MD5]); } // 优先查记录表 $record AttachmentModel::getByFileMd5($fileMd5); // 情况一文件已经完整存在直接返回秒传 if ($record $record[status] 1) { // 这里可以顺带把文件的访问地址、文件名返回给前端 return $this-json([ can_skip true, // 前端看到true就直接完成 url $record[file_url], file_name $record[file_name] ]); } // 情况二有记录但没传完返回已上传分片列表 if ($record) { $done $record[uploaded_chunks] ? json_decode($record[uploaded_chunks], true) : []; return $this-json([ can_skip false, uploaded_chunks array_values($done) ]); } // 情况三没有记录前端从0开始传 return $this-json([ can_skip false, uploaded_chunks [] ]); }注意一个细节判断“是否可秒传”有些人会直接去扫描磁盘上的临时目录发现分片有100个再挨个比对大小。这是巨大的性能浪费。直接在记录表或最终文件目录判断才是高效的做法。4.2 upload接口分片落盘禁止简单追加处理分片上传请求时最重要的一点是分片落盘写入文件必须使用文件内偏移量而不是直接追加。为什么因为WebUploader并发上传时分片不保证按顺序到达。假如第5片先到了用了追加方式写入文件内容顺序就错了。正确做法是根据分片序号计算这个分片在最终文件中的偏移量然后用fseek跳过去写入。public function uploadAction() { $file $this-request-getFile(file); // 分片文件 $fileMd5 trim($this-request-getPost(file_md5, )); $chunkIndex (int) $this-request-getPost(chunk_index, 0); $chunkSize (int) $this-request-getPost(chunk_size, 0); $totalChunks (int) $this-request-getPost(total_chunks, 0); if (!$file || $file-getError() ! UPLOAD_ERR_OK) { return $this-json([error 分片上传失败]); } // 校验文件MD5格式避免路径注入 if (!preg_match(/^[a-f0-9]{32}$/i, $fileMd5)) { return $this-json([error 参数错误]); } $tmpDir storage_path(uploads/tmp/ . date(Ymd) . / . $fileMd5); // 每个分片单独存为独立文件文件名里带分片序号 $chunkPath $tmpDir . / . str_pad($chunkIndex, 6, 0, STR_PAD_LEFT) . .part; if (!is_dir($tmpDir)) { mkdir($tmpDir, 0777, true); } // 核心将分片移动到目标位置。upload接口返回的是临时路径 // 直接把分片文件移动到临时目录里。 $file-moveTo($chunkPath); // 更新数据库记录把当前分片序号加到uploaded_chunks里 $this-markChunkUploaded($fileMd5, $chunkIndex, $chunkSize, $totalChunks, $file-getSize()); return $this-json([success true, chunk_index $chunkIndex]); }markChunkUploaded这个方法核心是更新uploaded_chunks字段把这个分片序号合并进去。实现上可以用SQL的JSON_INSERT或读出来再写但注意并发时同一行的更新冲突。简单稳妥的方式是给记录加一个唯一的file_md5索引然后采用“先查后更”的方式必要时候做幂等判断分片序号已存在直接忽略更新。有一个很容易踩的坑moveTo在某些PHP框架里需要在调用前先创建目录并确认有写入权限。我项目里的服务器是nginx用户运行PHP之前出现分片上传后目录不可写排查半天发现是storage目录权限归属了root。这种问题日志真的很重要。4.3 merge接口排序、合并、校验缺一不可全部片传完后前端会调用另一个接口比如merge。这个接口的职责是把tmpDir下所有.part文件按序号读取拼装成一个完整文件。public function mergeAction() { $fileMd5 trim($this-request-get(file_md5, )); $record AttachmentModel::getByFileMd5($fileMd5); if (!$record) { return $this-json([error 上传记录不存在]); } $tmpDir storage_path(uploads/tmp/ . date(Ymd) . / . $fileMd5); if (!is_dir($tmpDir)) { return $this-json([error 分片目录不存在]); } $partFiles glob($tmpDir . /*.part); // 按文件名自然排序对应分片序号 usort($partFiles, function ($a, $b) { $indexA (int) basename($a); $indexB (int) basename($b); return $indexA - $indexB; }); $destPath storage_path(uploads/final/ . $fileMd5 . _ . uniqid() . .dat); // 关键合并文件不能一次性读入内存 $destHandle fopen($destPath, wb); foreach ($partFiles as $partFile) { $handle fopen($partFile, rb); while (!feof($handle)) { $chunk fread($handle, 1024 * 1024); // 每次读1MB fwrite($destHandle, $chunk); } fclose($handle); } fclose($destHandle); // 校验文件大小上传完成的文件总字节数必须等于各分片字节数之和 $actualSize filesize($destPath); if ($actualSize ! $record[file_size]) { unlink($destPath); return $this-json([error 文件大小校验失败请重新上传]); } // 更新记录状态 AttachmentModel::markCompleted($fileMd5, $destPath); return $this-json([success true, url /download?file_md5 . $fileMd5]); }合并时的内存问题是PHP最容易忽略的。之前见过有人直接把所有分片用file_get_contents读出来再拼接一个2GB文件直接把PHP进程搞崩。上面代码里用的方式是流式读取每次读1MB写下1MB内存占用非常低实测一个5GB的文件合并时PHP内存占用不超过20MB。合并完成后还有个细节清理临时分片目录。很多人忘了这一步日积月累会留下一堆没用的分片文件占满磁盘。我在mergeAction末尾会把$tmpDir整个目录删除掉。另外还需要一个定时任务兜底——用户传一半不传了分片目录会一直留在磁盘上我项目里是每天凌晨跑一次脚本把超过24小时的临时目录和记录清掉。4.4 秒传的隐藏陷阱MD5碰撞与文件内容校验关于秒传很多人直接依赖前端计算的MD5但实际上这里有个安全点MD5并不是完全可靠的大型文件的MD5在极低的概率下可能碰撞而且恶意用户可以直接伪造一个“和服务器已有文件相同MD5但内容不同”的请求从而实现绕过上传直接拿到文件地址。真实的强校验方案可以在秒传判断之后再额外比较文件大小。MD5相同且文件大小也相同的文件碰撞概率已经非常非常低日常项目够用了。但如果你的文件是合同、报表这类重要数据还是建议合并完后再逐字节校验一次或者在秒传成功时直接给用户返回一个“先存储地址再核实”的异步校验任务。这个话题扯深了一句话秒传能提升体验但别把MD5当成100%可信的凭据。5. 并发顺序、服务端配置与磁盘规划5.1 分片并发上传时顺序问题的本质WebUploader并发上传多个分片后端收到的顺序并不一定是从0到N顺序到达。我的经验是设计后端时尽量不依赖顺序分片存储时以独立分片文件存而不是直接写最终文件。合并时统一按序读取分片文件拼接。合并前检查分片数量是否等于总分片数数量不够直接报错。从上面的代码可以看出这套设计天然规避了顺序问题。但有一个细节分片上传接口的幂等很重要同一片如果因为网络超时被WebUploader重传了两次后端不应该重复记录或造成文件错乱。我在markChunkUploaded里加了判断分片序号已存在就直接返回成功不重复写记录。5.2 Nginx、PHP-FPM与PHP配置一次调到位后端代码写好了配置跟不上还是白搭。我项目里用于超大附件上传的服务端配置核心有这几个Nginx的client_max_body_size不要只依赖PHP层限制。即使分片后单次请求只有3MB但万一某个请求不是分片而是整体上传会出现413 Request Entity Too Large这行配置起码要设到比单分片大一点比如10m。PHP的post_max_size建议设置为20M大于分片大小。PHP的upload_max_filesize同样调整比如20M。PHP的max_execution_time合并大文件或对超大文件做校验时执行时间可能超过一分钟建议在合并接口内用set_time_limit(0)临时放开。不过要慎用只能放在明确需要长时间执行的脚本中不要全局放开。public function mergeAction() { set_time_limit(0); // 合并文件关闭超时限制 ignore_user_abort(true); // 客户端断开也继续执行合并避免合并到一半断掉 // ... 合并逻辑 }服务端能用分片就分片但不要因为本地测试能传2GB就觉得生产没问题。生产环境还需要关注Nginx进程的proxy_read_timeout如果服务器前面还有一层反向代理这个时间不够也会在合并这种长耗时请求时把连接掐断。5.3 磁盘空间检查一个容易被忽略的上游问题超大附件意味着磁盘占用大尤其是临时分片目录和最终文件在同一块盘上的部署。我见过一次系统故障用户传一个8GB的数据库备份到一半磁盘写满PHP报“No space left on device”分片目录里残留了一半文件前端的进度条却一直显示0%。解决思路很简单upload接口写入分片前用disk_free_space拿到剩余空间小于一定阈值比如分片大小的2倍就直接返回“服务器空间不足”的提示。同时利用系统监控预警磁盘用量上传接口只是做一个兜底保护。6. 常见问题与排查记录6.1 为什么提示上传成功了合并后文件却打不开这是最常见的问题根因基本是分片顺序错乱。排查步骤按这个顺序走检查前端uploadBeforeSend里chunk_index是否从0开始是否和block.chunk对齐。检查后端合并时glob的排序方式看是不是按数字排序而不是字符串排序。默认glob返回结果在序号超过9以后会出现“10排在第2位”的字典序错误所以上面代码里用了usort重新排序这个点不对是文件损坏的头号嫌疑。检查合并时是否在每个分片之间多写入了多余字节比如用了file_put_contents的追加模式导致重复。合并过程必须严格按偏移量拼接不要留任何额外内容。6.2 断点续传时前端仍然把所有分片都传上来了这个现象很气人接口设计明明支持断点续传但用户刷新页面后前端还是从第0片开始传。排查重点在两方面前端当时是否把后端check返回的uploaded_chunks传给WebUploader做跳过WebUploader本身没有“跳过某个分片”的现成API常规做法是把已有的分片序号放在uploadBeforeSend里拦截uploader.on(uploadBeforeSend, function (block, data) { if (uploadedChunks.indexOf(block.chunk) -1) { // 通知组件跳过这个分片不发起请求 return false; } });检查浏览器是否重新计算了file_md5。只要前端MD5变了后端查不到任何记录当然从头传。解决办法是同一个文件的MD5要缓存住页面刷新后从本地存储里取或者重新加载页面时重新计算。6.3 分片传完卡在“合并中”日志也没报错这个问题往往在后端但表现为前端一直转圈。日志没报错通常是因为输出缓冲或框架异常没被捕获。排查思路确认merge接口是否把set_time_limit(0)写上没写的话大文件合并超过30秒直接被干掉。确认临时分片目录的权限是Web运行用户可以读写的。确认glob返回的part文件数量等于实际的total_chunks数量对不上合并逻辑会报错。可以在merge开始时先把分片数量打印到日志里一眼就能看清缺了哪一片。6.4 上传过程中途断了用户重新上传时后端记录里没有该MD5这个情况我踩过一次用户刷新页面后重新传同一个文件check接口查询记录表返回空。原因是我之前在上传流程里用了“分片都传完才插入记录表”的错误做法导致传了一半时根本没记录。正确做法是第一次上传分片时就创建记录status置为0之后每次更新uploaded_chunks字段。这样才能支撑断点续传的“断点查询”。6.5 前端MD5计算非常慢影响体验一个4GB文件即使分片计算MD5在普通PC上也能感受到几秒的等待。但用户感知最明显的不是等待而是等待时没有进度提示。WebUploader的md5插件是支持进度事件的初始化时监听md5_progress把计算百分比显示在UI里体验会好很多。另外可以考虑把MD5计算和文件选择分开选完文件后立刻显示“计算中”计算完成再提交到check接口。7. 最后分享一点经验很多人做超大附件上传习惯一上来就搜“PHP 断点续传”之类的代码然后发现能跑的案例大多没有考虑并发顺序、临时目录清理、合并内存控制这些细节。我的建议是先把流程画出来——从选择文件到MD5计算、到check、分片上传、合并、清理——每个节点想清楚“状态在哪里记录”“失败怎么恢复”再开始写代码。这套流程其实任何一个成熟的上传组件都适用只是WebUploader把前端的部分做好了后端还是要自己踏实设计。我现在的模板项目里基本就是这篇文章里的结构一个upload表三个接口临时分片目录按日期加MD5归档合并用流式写入。一年多下来生产环境传过最大的一个文件是12GB的监控视频十几个人同时上传也没有出过严重问题。如果你准备在自己的项目里做这个功能我建议第一版不要贪多先把分片上传跑通加上断点续传秒传可以留到有用户反馈“传重了文件”时再补。一步到位反而容易让自己陷在MD5、合并、校验的细节里出不来。
阅读完成 · 觉得有帮助?
咨询建站