
做办公系统集成的朋友十有八九都被大附件上传折磨过。合同扫描件动辄几百MB培训视频一两个G图纸包、监理报告、年报材料全是大家伙。传统表单上传在这些文件面前基本就是碰运气浏览器崩溃、请求超时、传了两小时报个网络错误用户第一反应就是找IT。我自己前后接过几套OA系统的大附件改造试过各种方案最后稳定跑下来的就是 WebUploader 做前端分片、PHP 做后端接收合并这一套组合。这篇文章把整个过程完整拆开讲参数怎么配、接口怎么写、并发怎么控、合并怎么提速还有踩过的坑和最终性能数据。如果你是正在做 OA 系统、内部管理系统、企业网盘这类项目的开发或运维或者只是被“传一个大文件传不上去”这种需求搞到头大这篇应该能帮你省不少试错时间。方案不复杂但很多细节文档里不会写我尽量用大白话讲清楚。1. 先搞清楚传统上传到底卡在哪1.1 三个最典型的失败场景先说最常见的三种情况你在任何单位里大概率都见过。第一种是“转圈一小时最后报错”。文件从用户电脑传到服务器走的是单一 HTTP 请求。只要中间网络抖动一下、代理超时一下、服务器 PHP 进程超时一下整个传输直接断掉。断掉之后前面传的所有字节全部作废用户只能重头再来。第二种是“配置问题导致一开始就失败”。Nginx 默认的client_max_body_size只有 1MBPHP 的upload_max_filesize默认 2MBpost_max_size默认 8MB。大文件直接 413 或者被 PHP 拒收用户看到的只是一个莫名其妙的失败页面。第三种是“服务器压力被瞬间打满”。几十个人同时上传大文件每个请求都占着一个 PHP-FPM 进程内存和 CPU 一起飙。单进程处理几百 MB 上传本身就是一件很笨重的事并发一高系统就卡。分片上传能解决的就是这三种“传输层”问题。它的思路很简单把一个大文件砍成若干个几 MB 的小块一块一块上传。每一块都是一个独立的 HTTP 请求失败了只重传那一块不用整文件重来。1.2 分片传输解决了什么、不解决什么这里我得说句实话分片上传并不能减少总传输字节数带宽瓶颈依然存在。该传 500MB 还是 500MB网络慢还是慢。它真正解决的是两个问题可靠性和可恢复性。可靠性在于每个分片很小单个请求失败的概率远低于一个大请求。可恢复性在于某几个分片失败后只需要补传这几个分片而不是把整个文件重新传一遍。再加上多线程并发上传可以更充分地利用带宽尤其是一些对单连接限速的网络环境并发几路往往能把速度提上去。所以选型之前先摆正预期分片上传解决的是“传得上去、断了能续”的问题不是“让 1Mbps 变成 100Mbps”的魔法。想明白这一点后续验收就不会被领导拿“怎么还是这么慢”来质问。2. 整体架构与关键参数设计2.1 从选片到合并的完整数据流整套方案跑起来的流程是这样的用户选择文件后前端 WebUploader 把文件切分成固定大小的分片每个分片生成一个独立的请求。每个分片请求带着文件唯一标识、分片序号、总分片数等信息发给后端 PHP 接口。后端把每个分片单独写入临时目录文件名带上序号比如7f3a...a2_0.part、7f3a...a2_1.part。前端把所有分片传完后主动调用一个“合并接口”通知后端把所有.part文件按顺序拼成一个完整文件。合并完成后后端把文件移动到正式附件目录写入数据库附件记录关联业务单据然后清理临时目录。我在实际项目里验证过几个做法这里先说结论合并操作不要放在最后一个分片上传时自动触发而是由前端显式调用合并接口。原因很简单自动触发看着方便但最后一个分片到达时前面可能还有分片因为网络延迟没写完。你无法确定服务端是否已经收全一旦带着缺片的状态去合并轻则合并失败重则生成一个损坏的文件。显式调用合并接口至少前端能保证“所有分片都收到成功响应了我才叫你合并”。2.2 分片大小、并发数与超时时间怎么定分片大小是整个方案里最关键的参数之一定多少合适取决于你的网络和文件场景。我给出自己验证过的经验值局域网 / 内网带宽充足分片 10MB 到 20MB并发 3 就够。跨地域、带宽一般分片 5MB并发 2 到 3。网络条件较差分片 2MB 到 5MB并发 2。为什么并发不要盲目开高很多人觉得并发越高越快实际上 WebUploader 每开一路并发就是一个并行 HTTP 请求。如果服务器 PHP-FPM 进程数有限比如只有 50 个进程50 个人同时上传且每人开 8 路并发光上传请求就占满全部进程OA 系统里其他业务请求全部排队甚至报 502。我见过因为这个把生产搞挂的案例之后统一设成最大 3 路并发。超时时间上前端 Ajax 和 Nginx 的proxy_read_timeout都要调。分片传输中每个请求本身很快5MB 在 10Mbps 下约 4 秒但网络差的时候可能拖到十几秒。proxy_read_timeout默认 60s 通常够用但如果你们有更差的链路建议直接设成 300s别在这个参数上抠。另外有个容易被坑的配置Nginx 的client_max_body_size。很多教程说改成 2G 就能传大文件其实那是给“整文件上传”用的。分片方案下每个请求体只有一个分片的大小所以这个参数只需要大于“分片大小 表单字段的少量字节”就行。比如分片 5MB设成10m就绰绰有余。设太大反而有安全隐患别人可以往你接口塞任意大小的包。2.3 前后端协议约定分片方案要落地前后端必须约定好一套公共字段否则两边各写各的联调就能耗掉半天。我常用的字段约定如下字段含义示例fileId文件唯一标识前端生成贯穿整个上传过程7f3ac9e2...chunkIndex当前分片序号从 0 开始0, 1, 2...chunkTotal总分片数100fileName原始文件名用于合并后还原合同扫描件.pdffileSize文件总字节数524288000bizType业务类型用于后续关联contractfileId 我这里特意强调一定要让前端生成而不是后端生成。因为断点续传时用户刷新页面后前端还需要用同一个 fileId 继续上传如果 fileId 是后端生成的刷新后就丢了。前端生成可以用随机字符串加时间戳保证唯一即可。3. 前端 WebUploader 配置实战3.1 核心配置项逐项拆解WebUploader 是老牌开源上传组件稳定、文档全、坑也有人帮你踩过。核心配置我直接给一段能用的示例var uploader WebUploader.create({ swf: /static/Uploader.swf, server: /api/oa/attachment/uploadChunk, pick: #picker, accept: { title: 附件文件, extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,zip,rar,mp4,avi }, chunked: true, chunkSize: 5 * 1024 * 1024, threads: 3, fileSizeLimit: 2 * 1024 * 1024 * 1024, duplicate: true, formData: { bizType: contract }, fileVal: file, auto: true });几个配置说一下chunked: true是开启分片chunkSize是分片大小。注意单位是字节5MB 就是5 * 1024 * 1024。threads是并发数前面说了别贪多3 路最稳。fileSizeLimit是文件大小上限我们当时定的是 2GB。超过这个大小直接在前端拦截不做无谓的服务端请求。duplicate: true是允许选择同名文件。OA 场景里用户经常传“新建文档.pdf”这种重名文件不开这个选项第二次选择会被拦截用户会一脸懵。formData里的内容是每个分片请求都会带的固定参数适合放业务类型这类公共信息。还有一个容易被忽略的fileVal默认是file后端$_FILES[file]就能收到。如果你后端用了别的参数名这里要对应改。3.2 断点续传与秒传的实现断点续传是分片上传里最有价值的功能。用户传到 60% 断网了重新选择文件后直接接着传不用从头再来。WebUploader 官方提供了一个 MD5 插件可以计算整个文件的 hash。逻辑是选择文件后先算 md5把 md5 作为 fileId 传给后端后端查一下有没有同名 hash 的记录。如果文件已经完整存在直接返回“秒传成功”根本不用传分片。如果文件传了一部分后端返回已收到的分片序号列表前端只传缺失的部分。这个逻辑做出来用户体验非常好。但我要提个醒大文件全量计算 md5 很耗时。在普通办公电脑上一个 500MB 的文件算 md5 可能要十几秒甚至更久。用户会以为页面卡死了。所以我们当时的做法是文件小于 256MB 才做全量 md5超过的部分用“文件大小 首尾各 256KB 的 hash 文件名”拼一个伪唯一标识。这样速度极快虽然理论上存在极小概率的冲突但在 OA 内部系统这个场景里完全够用。3.3 进度交互与异常重试上传进度条是必做的不然用户看着页面没反应过一会儿就来问是不是卡了。WebUploader 提供uploadProgress事件可以把已传大小和总大小算出来显示百分比。重试机制也要注意。默认上传失败后WebUploader 会把该分片标记为失败但不会自动重试。我们的做法是监听uploadError做最多三次重试仍然失败就提示用户“网络异常请检查后重试”同时保存当前进度。用户重新选择同一个文件时因为 fileId 没变后端能识别出已有分片直接续传。这里有个细节重试时要把失败的分片重新加入队列而不是重新上传整个文件。用uploader.retry()方法即可它会自动只重试失败的分片。4. 后端 PHP 接口与合并性能优化4.1 分片接收接口与临时目录规划后端是这套方案的重头。先看分片接收接口伪代码如下public function uploadChunk() { $file $_FILES[file] ?? null; $fileId $_POST[fileId] ?? ; $chunkIndex (int)($_POST[chunkIndex] ?? 0); $chunkTotal (int)($_POST[chunkTotal] ?? 0); $fileName $_POST[fileName] ?? ; if (!$file || !$fileId || $file[error] ! UPLOAD_ERR_OK) { return $this-error(参数错误); } if ($file[size] 12 * 1024 * 1024) { return $this-error(分片大小超过限制); } $tmpDir $this-getTmpDir($fileId); if (!is_dir($tmpDir)) { mkdir($tmpDir, 0755, true); } $chunkPath $tmpDir . / . $chunkIndex . .part; // 已存在的同名分片校验大小一致就视为重复上传直接返回成功 if (file_exists($chunkPath) filesize($chunkPath) $file[size]) { return $this-success([duplicate true]); } if (!move_uploaded_file($file[tmp_name], $chunkPath)) { return $this-error(分片保存失败); } return $this-success([received $chunkIndex]); }临时目录的规划很有讲究。我给每个 fileId 建一个独立子目录分片文件放在子目录里命名就是“序号.part”。这样有几个好处一是后续合并时按序号遍历目录即可清晰二是清理时直接删整个目录不会误删其他文件。目录层级建议这样/storage/oa_tmp/ 7f3ac9e2/ 0.part 1.part 2.part ...临时目录一定要放在存储空间充足的磁盘上并且和正式附件目录分开。我见过有人把临时分片和正式附件放一起结果清理脚本误删了正式文件的惨案。4.2 并发写片的竞态处理分片上传是并发请求多个分片同时到达你可能会担心竞态问题。这里有个好消息不同分片写不同文件天然互不干扰。0.part和1.part是两个独立文件同时写没有任何冲突。真正需要处理的是“同一个分片被重复上传”的情况。网络超时导致前端重试时用户断开重传时同一个chunkIndex可能来了两次。上面代码已经处理先检查文件是否存在、大小是否一致一致就直接返回成功不重复写。更严格的方案可以再计算已存在文件的 md5 和当前上传分片的 md5 做比对但实际场景中“文件存在 大小一致”已经能覆盖绝大多数情况md5 比对会额外消耗 CPU没必要。还有一个竞态出现在合并阶段如果两个合并请求同时被触发或者合并进行中又有分片上传进来会乱。应对办法是在合并接口里加一个标志位。我习惯用数据库记录状态附件记录里有一个status字段0表示上传中1表示已合并完成。合并接口进来先查状态如果已经是1直接返回成功避免重复合并。4.3 合并提速方案与内存控制合并接口看着简单写不好就是性能灾难。最容易踩的坑是用file_get_contents把每个分片读进内存再拼接。一个 1GB 的文件100 个分片这么写内存占用轻松破 2GBPHP 进程直接 OOM。正确姿势是用流式复制一次只读一点点内存占用恒定public function mergeChunks() { $fileId $_POST[fileId] ?? ; $chunkTotal (int)($_POST[chunkTotal] ?? 0); $fileName $_POST[fileName] ?? ; $bizType $_POST[bizType] ?? ; $tmpDir $this-getTmpDir($fileId); $targetDir $this-getAttachDir($bizType); $targetPath $targetDir . / . $this-generateUniqueName($fileName); $fp fopen($targetPath, wb); if (!$fp) { return $this-error(无法创建目标文件); } for ($i 0; $i $chunkTotal; $i) { $chunkPath $tmpDir . / . $i . .part; if (!file_exists($chunkPath)) { fclose($fp); unlink($targetPath); return $this-error(第{$i}个分片缺失无法合并); } $chunkFp fopen($chunkPath, rb); stream_copy_to_stream($chunkFp, $fp); fclose($chunkFp); unlink($chunkPath); } fclose($fp); $this-clearTmpDir($tmpDir); // 写入数据库关联业务单据 $this-saveAttachmentRecord($fileId, $fileName, filesize($targetPath), $bizType); return $this-success([path $targetPath]); }stream_copy_to_stream是 PHP 内置的流复制函数底层走的是固定缓冲区内存占用和文件大小无关。实测 500MB 文件、100 个分片在普通服务器上合并耗时大概 3 到 8 秒完全可接受。合并过程中如果发现缺片我强烈建议删除已合并的半成品文件返回明确错误。前端收到错误后会重新触发上传缺失分片然后再调一次合并。这样比在缺片状态下强行继续合并要安全得多。还有一个优化点合并耗时在几秒到十几秒之间如果用户量大建议把合并操作放到异步任务队列里执行前端轮询合并状态。但 OA 系统内部用户量通常不大同步合并 合理超时已经够用。我们当时就用的同步合并没出过问题。4.4 与 OA 业务数据表的关联设计分片方案的后端不能只处理文件还要把文件和业务关联起来。OA 系统里每个附件最终要挂到某个申请单、合同、项目、审批流程下面。我在项目里的表结构大致这样CREATE TABLE oa_attachment ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, file_id VARCHAR(64) NOT NULL COMMENT 前端生成的文件唯一标识, file_name VARCHAR(255) NOT NULL COMMENT 原始文件名, file_path VARCHAR(500) NOT NULL COMMENT 存储路径, file_size BIGINT UNSIGNED NOT NULL COMMENT 文件字节数, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型contract/meeting/train..., biz_id VARCHAR(64) DEFAULT NULL COMMENT 业务单据ID合并后由业务方回填, status TINYINT NOT NULL DEFAULT 0 COMMENT 0上传中 1已合并 2失败, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_file_id (file_id), KEY idx_biz (biz_type, biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;上传每个分片时不需要写数据库只有合并成功后才插入一条正式记录。这样避免了几百次分片请求每次都写库把数据库压力降到最低。后续业务表单保存时前端把fileId传给业务接口业务接口根据file_id回填biz_id完成关联。这个流程简单可靠也方便追查“某个申请单到底传了哪些附件”。5. 实测数据与问题排查实录5.1 一组真实的性能对比数据下面是我们当时针对 500MB 文件在内网环境做的测试数据分片方案上线前后的对比场景传统表单上传WebUploader 分片上传内网千兆带宽充足约 40 秒但单连接受限于应用层处理偶尔卡顿约 35 秒3 路并发稳定跨地域专线10Mbps理论 400 秒实际经常中途失败成功率约 60%430 秒左右断点续传兜底成功率 99% 以上网络波动频繁无线失败率高需整体重传只重传失败分片平均补传 2 到 5 片并发 30 人同时上传服务器负载高偶发 502负载平稳PHP-FPM 进程占用可控看这个表你可能会问内网场景分片也没快多少啊确实带宽充足时分片的核心价值不是提速而是稳定性。而跨地域、无线这类网络差的环境分片方案的价值就非常明显了。服务器方面我们当时用的普通虚拟机配置CPU 2 核、内存 4GB跑 PHP-FPM Nginx。分片上传期间 CPU 占用率均值不到 30%最耗时的合并操作也就持续几秒整体对 OA 系统其他业务影响很小。5.2 高频问题速查表把我在多个项目里遇到的高频问题汇总成一张速查表按“现象 / 原因 / 解决方案”排列现象原因排查与解决上传刚开始就报 413Nginxclient_max_body_size过小调整为分片大小的 2 倍如分片 5MB 就设10m第一个分片就报“分片大小超过限制”PHPupload_max_filesize/post_max_size小于分片大小调整 PHP 配置upload_max_filesize20Mpost_max_size25M合并时提示“第 N 个分片缺失”网络抖动导致部分分片请求实际未成功前端却认为成功前端增加失败重试合并失败后前端自动补传缺失分片合并后文件损坏大小不对并发上传时同名分片被覆盖加“文件存在且大小一致则跳过”的判断合并时内存爆炸使用file_get_contents拼接分片改用stream_copy_to_stream流式合并临时目录文件越来越多磁盘快满用户上传一半放弃没有清理机制增加定时任务删除创建时间超过 24 小时的临时目录用户刷新页面后无法续传fileId 由后端生成刷新后丢失改为前端生成 fileId并持久化到本地存储上传文件超过 2GB前端fileSizeLimit限制按业务需求调整上限或改用更大分片并调整后端限制5.3 几个印象深刻的坑第一个坑是分片设太小导致的“inode 耗尽”。最初我们把分片设为 1MB想着分片小、单请求快结果 50 个人同时传大文件临时目录里瞬间堆了几万个.part文件。文件系统 inode 被占满整个服务器都写不进新文件了。这个问题的表象是“所有上传都失败”但df -h看磁盘还充足容易排查半天。后来把分片改为 5MB并加了定时清理再没犯过。第二个坑是合并过程的“半成品文件”。早期代码在合并时没有做缺片检查某个分片丢了合并照样继续生成的文件中间缺了一块大小对不上但文件能打开用户事后才发现内容不完整。后来所有合并都严格检查分片完整性缺一片就删除半成品并报错宁可失败也不能产出坏文件。第三个坑是文件名编码。OA 系统里用户上传的文件名千奇百怪中文、括号、空格、特殊符号都有。直接拿原名存到服务器路径里Windows 服务器上还好Linux 上某些特殊字符会让后续下载接口 404。我们统一在保存时用时间戳 随机串 原扩展名生成存储文件名原始文件名只存数据库。下载时通过接口读数据库拿原文件名设置响应头让浏览器显示正确名字。这个改动彻底消灭了文件名相关的坑。第四个坑是前端并发数设得太高。有一次我们图快把threads设成 8结果 20 个用户同时上传就把 PHP-FPM 进程池占满了OA 系统首页都打不开。排查后限制为 3同时给上传接口单独划分了一组 PHP-FPM 进程池跟业务接口隔离。这样哪怕上传流量再大也不影响系统其他功能。6. 收尾一些个人经验最后说几句大实话。这套 WebUploader PHP 分片方案并不前沿但它是经过大量真实场景验证、稳定可靠的一套做法。我在几个内部系统里都用它做过超大附件上传从几百 MB 的合同扫描件到几个 G 的教学视频线上跑了一两年没有再接到过“文件传不上去”的工单。如果你要在这个基础上继续扩展我个人觉得值得做的方向有三个一是把合并改成异步任务前端轮询进度适合用户量更大的场景二是把正式文件落地到对象存储服务减轻本地磁盘压力三是在秒传逻辑上做得再细一点比如对文件内容做分段采样 hash让秒传命中率更高。不管怎么扩展核心原则不要丢分片要独立、合并要校验、临时目录要清理、状态记录要完整。把这四点守住这套方案在绝大多数内部系统场景里都能跑得很稳。