写这篇东西时,我正在调一个分片上传的接口:前端报了个“文件已上传”的提示,但后端合并时发现少了最后一个分片。用户那边一直催,网盘里躺着好几个GB的监控视频等着传。这种“明明提示秒传,结果合并失败”的场景,搞过WebUploader的人估计都碰到过。
这个坑背后,其实就是PHP配合WebUploader做超大附件上传时,秒传和断点续传的核心问题:前端切了多少片、后端到底收到了哪几片、合并时按什么规则拼回去、怎么判断是否真的能秒传。标题里问的是“如何实现”,但实际开发中真正要解决的,是一整套分片状态管理的问题。这篇文章不做那种讲完原理就完事的科普,我把前后端完整流程、接口设计、关键参数和踩过的坑都整理出来,直接照着搭就能跑。
1. 先把问题说清楚:秒传、断点续传到底在解决什么
1.1 传统附件上传为什么总在“大文件”上卡住
很多PHP项目一开始做上传,都是前端直接丢一个<input type="file">,后端用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_md5 | varchar(32) | 文件唯一标识,也是分片目录名 |
| file_name | varchar(255) | 原始文件名 |
| file_size | bigint | 文件总字节数 |
| total_chunks | int | 总分片数 |
| uploaded_chunks | text | 已上传分片编号,逗号分隔或JSON数组 |
| status | tinyint | 0上传中 1上传完成 |
| file_path | varchar(255) | 合并后的完整文件路径 |
| created_at | datetime | 创建时间 |
这张表承担两件事:查看进度时,查询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、合并、校验的细节里出不来。