1. 为什么你的捅穿点不在带宽:先厘清超大附件上传的真实瓶颈
先讲一个真实场景。某单位的内部OA系统上线新附件模块,用户反馈“超过300MB的文件基本传不上去”,不是网速慢,而是传到一半页面直接白屏,或者提示“网络连接已断开”,更气人的是重传还得从头再来。当时我们排查排水,第一反应是路由器带宽不够,结果拿一台千兆内网主机测试500MB文件,下载速度能跑到90MB/s,上传却始终卡在十几MB/s,还时有失败。问题根本不出在网络上,而是出在Web上传形态上。
传统上传依赖浏览器对<input type="file">的处理:文件被一次性读入内存,再通过HTTP协议整体提交给服务端。这项机制有两个先天短板。第一,大型文件传播过程中,任何一分钟的网络抖动都会导致连接断开,一旦断开,服务端已经接收的部分直接废掉,必须重传整个文件;第二,PHP接收数据时,post_max_size、upload_max_filesize、max_execution_time这些配置项全部受限,超出限制不是静默截断就是直接报错。最要命的是,内网OA这类系统经常挂在多级交换机、防火墙后面,中间设备超时阈值一旦生效,再大的带宽也救不了。
分片传输的思路由此出现:把一个大文件切成若干个小块(分片),逐个上传,每个分片独立请求、独立校验、独立入库。一个分片失败了只需要重传这一个slice,而不是整个文件;上传过程还可以多个分片并发,占用一样的连接数,但是单请求的存活时间大幅缩短,不会再触发中间设备的超时断开。分片全部到达后,服务端按顺序拼接,就能得到完整的原始文件。
WebUploader这类库本质上就是把这套逻辑做成了前端组件:自动切片、并发上传、上传失败自动重试、进度条展示。配合PHP后端实现接收分片和合并分片,就能在现有OA服务器上形成一个完整可用的超大附件通道,不需要额外买单独的分布式存储,也不用改底层的文件处理方式。
适合参考这篇经验的人是有限的:正在维护以PHP为主的内部业务系统、又不想引入一套重型对象存储中间件的开发者。如果你手里是公网环境、用户量大、文件普遍超过1GB,那是另一套架构体系,后面会讲为什么,但本文核心是面向内网OA这类中大规模附件场景。
1.1 直接拖拽上传的“假大附件”方案为什么不可行
有些团队图省事,直接堆高upload_max_filesize,前端不切片,还加了拖拽上传的交互。这在演示环境里很吸睛,但在真实内网环境完全行不通。原因不只是PHP参数的问题。
当用户把2GB的文件拖进网页时,浏览器要先把这个文件的数据从本地磁盘读入内存,再通过表单构造一份multipart数据。内存中同时存在原始文件的一部分和编码后的数据,内存峰值能达到文件本身大小的两倍左右。普通内网PC的浏览器进程占用2-4GB内存并不奇怪,传到一半电脑风扇狂转,浏览器标签页直接崩溃的事务不少见。我踩过一次,一个3GB的三维模型文件,传到87%时浏览器标签页无响应,重启后发现前功尽弃。
再有就是服务端中间件。某些单位的内部网关会对请求体大小做严格限制,超过512MB直接返回403。这种设备不是你能临时改的,走流程审批可能等一周。而分片请求每个片一般控制在4MB到20MB,完全绕开网关的限制条件。所以“分片”表面上看是为了并发提速,实际上更是为了绕过链路中各类隐藏限制。
1.2 WebUploader与PHP协同工作的整体链路
我在这个项目里采用的标准链路是:前端页面集成WebUploader组件,设置chunked: true,组件负责将文件按固定字节数切割,然后逐个通过Ajax请求提交到同一个PHP入口文件。PHP服务端先接收分片数据,以唯一的MD5值为目录名存储分片文件;当检测到全部分片都已到达时,PHP脚本再按分片索引顺序拼接文件,并生成完整的附件记录写入OA数据库。
前端WebUploader负责的事情包括:读取文件元数据、计算文件唯一标识(通常用文件大小+名称+最后修改时间组合,也可以各自算MD5)、管理并发队列、处理每个分片的上传状态,以及提供进度条所需的事件回调。PHP后端则纯粹负责两件事:收片和并片。两件事都做对了,用户的眼里就是“一个大文件像普通附件一样,拖进去就能传”,只是进度条多了分片进度的细节。
这也是为什么在“国产内网系统”这类场景里,WebUploader至今还有生存空间。它的实现不依赖任何云厂商SDK,不去外部服务,保留服务端本身的文件存储策略,符合很多单位对数据留存的要求。
2. 上线前必须定死的三组参数:分片大小、并发数与校验策略
分片上传不是一开就完事,参数设计直接决定体验是“基本能用”还是“确实好使”。我们第一版就把分片大小设成了1MB,想着越小越稳妥,结果每个2GB的文件要生成两千多个分片,服务端落盘的一千多个临时目录,加上HTTP请求本身的包头消耗,效率低到不能忍。后来调成8MB,整体吞吐量提升了将近四倍。
2.1 分片大小怎么定:先看网络基建再看文件类型
一个通用的参考基准:分片大小应该是“预计上行带宽的1至3秒传输量”。假设内网上行实测为20MB/s,分片大小16MB到20MB比较合适;上行只有2MB/s的场所,建议分片不要超过8MB,否则单片传输耗时过长,又回到了“一次请求传很久”的老路上。
同时要考虑服务器和客户端的IO能力。有些OA服务器还在用机械硬盘,分片过小导致的随机小文件写入会放大磁盘寻道开销;分片过大又会让重传成本增加。折中方案:普通Office文档、PDF、压缩包,分片大小4MB;视频、模型文件等天生纵深且不爱动的文件,可以调到16MB。WebUploader支持动态配置chunkSize,把“根据文件类型动态设置分片”做成一个小逻辑,是很值得做的事。
我们最终采取的具体配置是:
- 文件小于100MB:不切片,直接整传,省去合并开销;
- 文件在100MB到1GB之间:分片大小8MB;
- 文件超过1GB:分片大小16MB,并发数降到2,避免内网某一段链路被瞬时打满。
前端判断用几行代码就能完成,后端也无需改变逻辑。
2.2 并发数的玄学:不是越大越快
WebUploader的threads参数控制同时上传的分片数量。默认给到3是均衡,但我试过调大,结果有意思:并发数从3调到5,总体上传时间反而变长。后来抓包发现,内网虽然带宽充裕,但OA服务器与客户端之间的TCP连接数、中间设备的会话表项数是有限的。并发太多,大量分片请求同时在路上排队,丢包以后又同时重发,互相竞争恢复连接,反而造成整体拥塞。
更隐蔽的问题是内存。WebUploader在做并发上传时,会将待上传分片的数据读入浏览器的内存Buffer;并发数为3时内存增长有限,改到8以后,页面在低配办公机上能涨出1GB以上内存。这里是真正的性能漏斗。
比较合理的策略是:并发数取2到4之间,优先推荐3;服务端带宽或客户端性能较弱时,动态降到1或2。速度不是靠并发堆出来的,而是靠“别让任何单次请求存活太久”换来的。
2.3 MD5校验:整文件还是分片,别想当然
MD5校验是两个层面的问题:一是前端在把文件切分后,每个分片是否需要各自计算MD5;二是全文件合并完成后,是否还要做整文件校验。
如果只做整文件MD5校验,那分片传输过程中任何一片发生数据错乱,直到合并完才暴露,此时必须重传全片,效率优势全部归零。所以在分片设计里,每个分片都应在发送前计算一次MD5,服务器收到后对比,不一致就返回错误码,前端自动重传当前分片。这是分片方案的地基,不能省。
但这里有个性能雷区:为每个分片计算MD5需要读取整个分片的数据,如果分片大小是16MB,前端每传一片之前都先遍历一次本地文件数据,CPU和IO都有开销。WebUploader本身不做分片级MD5,你可以选择两种做法:一种是在服务端计算,PHP接收数据后直接md5_file()判断;另一种是前端先用FileReader读取分片数据并计算,再随表单POST一起提交。前端计算能早一步筛掉坏块,但会增加内存占用。
我们实际用的是一套简化方案:分片上传时不校验,服务端只记录分片大小,合并完成后,再对整个文件执行一次MD5,和前端在上传前预计算的整文件MD5做比对。中间偶发的数据损坏概率极低,而校验成本只发生一次。如果对完整性要求更高(涉密资料、审计要求),则把分片级MD5打开,但并发数必须降下来。
3. PHP服务端如何把几百个分片安全地合并成一个文件
参数说了半天,到服务端才是真功夫。PHP本身是解释型脚本,处理大文件合并容易写崩,但掌握好IO方式和文件锁的几个原则,它完全扛得住内网OA这种并发量不太激进的场景。
3.1 上传接口的目录设计与回收逻辑
服务端接收分片的第一步是决定临时文件放在哪里。我的建议是不要把分片直接扔进web可访问目录,否则对方能通过猜测URL直接下载他人已传的部分文件。正确做法是在应用根目录之外建一个data/chunks目录,并在nginx层禁止访问该目录。
目录的命名以“文件唯一标识(MD5或自定义token)”为一级目录,分片序号为文件名。例如:
data/chunks/ 86a7f3c2b1.../ 0.part 1.part 2.part注意:这里一定要把“分片序号”和“分片内容”解耦。分片序号来自前端上传的chunk字段,服务端不应自己推测顺序,要信任前端传的序号,但要在合并时检查序号是否连续、是否有重复,任何一个异常都必须中断合并并记录日志。
临时分片目录必须带回收机制。内网里总有人传一半就关页面断电,如果不清扫,data/chunks会以每天几十GB的速度膨胀。我写了这个一个定时任务:每30分钟扫描一次分片目录,凡是最后修改时间超过24小时且未生成完整文件的目录,整个删除。这比靠业务逻辑主动清理可靠得多,因为业务逻辑根本覆盖不到“进程被强杀”的场景。
3.2 断点续传:续的到底是什么,需要后端配合什么
“断点续传”这个词在WebUploader体系里要拆开理解。前端可以通过chunk判断哪些分片已经成功,只重传失败的那些,这已经比整文件重传好很多。但要让数据真正“续传”而不是“重传”,后端还得配合提供一个查询接口:返回某个文件唯一标识下已经有哪些分片序号。
这个接口的作用不只是优化体验。企业OA里某位同事从办公楼A走到办公楼B,网络切换导致上传中断,他再点一次“重新上传”时,WebUploader会通过接口知道哪些分片已经在服务器上,只传缺失的部分。实现时用一个简单的数据库表记录,或者用文件系统判断分片文件是否存在都行;在高并发下推荐数据库计数,因为依赖文件系统file_exists频繁调用会拖慢合并统计。
查询接口返回的应该只是一个数组,里面是该文件已存在的分片序号列表:
{ "code": 0, "data": { "fileMd5": "86a7f3c2b1...", "chunks": [0, 1, 2, 5, 6, 7] } }前端拿到这个数组,把3、4补传完,然后通知服务端触发合并。
3.3 合并流程:用追加写而不是读入内存再写
到了合并环节,最常见的坑是把所有分片读入一个数组再拼接。我用PHP这么写过一次,1.5GB的文件,内存直接爆掉。正确做法是用fopen以追加二进制模式打开目标文件,再逐个以只读方式打开分片文件、循环把分片内容读入一个小的缓冲区(例如1MB)并写入目标文件,直到读完所有分片。
关键代码逻辑如下:
$target = fopen($finalPath, 'wb'); for ($i = 0; $i < $chunkCount; $i++) { $chunkPath = $chunkDir . '/' . $i . '.part'; if (!file_exists($chunkPath)) { // 缺少分片,终止合并 fclose($target); throw new RuntimeException('分片不完整'); } $src = fopen($chunkPath, 'rb'); while (!feof($src)) { // 每次只搬运1MB,避免内存峰值过高 fwrite($target, fread($src, 1024 * 1024)); } fclose($src); // 合并完一片就删一片,减少磁盘占用 unlink($chunkPath); } fclose($target);要注意的是fwrite返回的字节数可能小于fread的字节数,严谨的写法里要使用循环补写逻辑;但不做这个处理,绝大多数场景也能跑通。不过对写严谨代码有要求的读者,建议补一个独立的写函数,遇到磁盘写满或者IO错误时能精细处理。
合并时还有个容易被坑的地方:多用户同时提交同一个文件的合并请求。比如用户A传完最后一片后立刻点击“完成”,他可能因为网络延迟按了两次提交;两个进程同时执行合并逻辑,目标文件会被反复覆盖或者互相读到不完整的分片目录。必须在合并入口加一个文件锁,用flock加独占锁:
$lock = fopen($chunkDir . '/merge.lock', 'c'); if (!flock($lock, LOCK_EX | LOCK_NB)) { // 已有合并任务执行,直接返回 throw new RuntimeException('合并已在执行'); }无脑加锁会增加代码复杂度,但对合并这类一次性操作来说,这个成本完全值得。
3.4 合并后的文件存储:目录打散与同步落库
完成合并后,目标文件不能直接堆在一个目录里。内网再小的单位每月也会产生几万个附件,全部放在同一层目录下,文件系统查找速度会变慢,运维排查也痛苦。我习惯按日期或按MD5前两位做目录打散:
data/files/2025/06/12/{md5前两位}/filename.pdf落库时关联的字段除了文件路径,还应该有文件大小、MD5、分片总数、合并耗时、上传人ID、上传时间。这些字段在后续等保审计、文件查重、数据迁移时都有用,别嫌麻烦。
我在合并完成的那一瞬间会再执行一次clearstatcache(),因为前面针对分片目录的一堆文件操作可能让PHP文件系统缓存不准确,不清理的话,filesize()可能返回错误值。
4. 实战踩坑清单:从Nginx配置到等保适配的最难一环
纯功能打通之后,真正折磨人的是部署环境适配。我踩过的坑里,按影响从大到小排列如下,每个都值得仔细看一眼。
4.1 Nginx默认配置的隐藏限制
很多OA系统的nginx配置只改了client_max_body_size,这是第一个坑。该项控制的是请求体大小,如果你保持32MB,分片即便只有8MB,理论上不受限;但如果前端某个请求恰好走了未分片逻辑,直接上传一个150MB的附件,后端就被nginx拦截了,返回413错误。
但改大client_max_body_size又会产生另一个问题:proxy_read_timeout和fastcgi_read_timeout。PHP-FPM处理一个请求如果耗时超过默认的300秒,请求会被nginx掐断。合并一个4GB的文件如果是机械硬盘,耗时可能超过300秒;一旦nginx先断开连接,PHP脚本会被强制终止,合并失败且日志难以排查。
我的处理方式分两层:
- 上传接口:
client_max_body_size调到100MB,应付未分片的小文件即可;分片单个很小,不会触发限制。 - 合并接口:单独建一个
merge入口,并在nginx的location里配置:
location ~ ^/upload/merge { client_max_body_size 10m; fastcgi_read_timeout 1800; proxy_read_timeout 1800; }合并接口因为只接收几个元数据参数,请求体本身很小,不需要大client_max_body_size,但处理时间必须给足。这正好避开“请求体大小”和“执行时间”两个限制的叠加效应。
4.2 PHP-FPM的并发进程与内存上限
内网OA并发不会像公网产品那么夸张,但上传这个动作有一个特殊性:每个上传请求都要占用一个PHP-FPM进程,而这个进程在等待前端发送分片数据时是阻塞的。如果分片大小8MB、并发3,意味着同时有3个用户各3个进程在等待IO。等到第四个用户传来第五片时,PHP-FPM可能已经没空闲进程了,其他普通办公接口也会跟着卡住。
所以请不要把pm.max_children调成默认值就完事。我见过某运维图省事把最大子进程设成200,结果内存占用直接飙到一个危险量级。合理的做法是根据服务器内存反推:单进程PHP-FPM按40MB估算内存,再加MySQL、nginx等固定开销后,给出一个保守的pm.max_children,再配合pm.start_servers、pm.min_spare_servers、pm.max_spare_servers做弹性调整。
上传场景本身的另一种解法是让上传走独立PHP-FPM池,和普通接口隔离,这样即使某个大文件合并阻塞了池内进程,主站接口不受影响。这套配置在php-fpm.conf里可以单独加一个pool定义,值得内网环境投入一次。
4.3 等保环境里最容易轻忽的审计要求
涉内部系统的单位多半要面对等保合规,附件上传是审计重点。分片上传天然有个特点:文件在服务器上分阶段落盘,中间到处都是临时文件,审计起来非常麻烦。如果你有任何“临时文件就够了、不用管细节”的错觉,听过一次学习教育就能纠正过来。
我在设计时把审计字段前置:每次分片上传成功,不论是否最终合并,都记录一条日志,包含文件名、文件MD5、文件名后缀(即使改名,后缀也保留)、分片序号、IP、操作人、时间。日志写入数据库的独立表。合并完成后,再写一条完整记录,关联临时目录ID。
这样做的好处不只是合规。一旦合并失败,开发人员可以拿着临时目录ID去查全部分片状态,知道是哪个分片早期出错,排查效率提高很多。代价是多写两张表和几个日志函数,换取的可维护性远大于成本。
还有一点需要明确:用户上传行为不落地,前后端都不能出现临时文件名中带真实的登录用户名。文件名里尽可能用用户ID纯数字,避免任何可能泄露账号信息的风险;头像、文件名这类信息都适合脱敏处理后存储。这是实际审查时会被同事挑刺的小细节。
5. 值得继续优化的方向:队列合并、对象存储与原生分片接口
做到这一步,一套内网OA的超大附件上传功能已经能稳定运行。但后面还有三个方向,我认为在资源允许时值得迭代,也顺手把实测中体会到的取舍写出来。
5.1 把合并操作从HTTP请求中解放出来
合并逻辑在PHP-FPM里跑,最大的限制是执行超时和内存上限。虽然通过nginx超时时间可以兜底,但本质上是拿一个Web进程在干数据搬运的活。更稳的架构是把合并动作放入异步队列:前端上传完最后一个分片后,告知后端登记合并任务;后端把任务写入数据库队列,一个常驻脚本轮询队列,发现可分片目录齐全就执行合并。
这样做的直接好处是,无论合并一个4GB还是8GB的文件,都不再影响Web进程的正常响应。用户可以立刻看到“上传完成,正在处理附件”的提示,合并过程通过另一个状态接口轮询更新。内网并发本来就不高,一个简单的while轮询就够了,不需要引入额外的队列软件。这段代码我写的时候并不复杂:一张表存任务状态,脚本来定时处理即可。
5.2 内部对象存储迁移时要保留哪些接口
如果后续单位决定上对象存储(比如私有化的OSS或MinIO一类),分片上传的架构迁移成本并没有想象中高。对象存储本身就提供分片上传接口,很多组件的接口设计都和WebUploader的语义相似:先初始化上传,然后并发上传分片,最后合并。因此核心改动是把“PHP写本地文件”替换成“调用对象存储SDK的分片方法”,前端分片逻辑大体不用动。
不过要提前想清楚:对象存储桶权限、内网CNAME绑定、文件下载时的鉴权机制,都会直接影响OA系统现有的附件预览、在线预览逻辑。建议先做一个大批量附件迁移演练,再动线上路径。
5.3 前端是否还要继续用WebUploader
WebUploader的问题很清楚——多年没有大版本更新,还依赖jQuery。但它在内网附件场景里的优势依然明显:生态成熟、资料多、团队接手容易、不依赖外部CDN。如果强制要求前后端分离或者全面拥抱Vue/React,可以选更现代化的上传组件,但在“不折腾”的原则下,WebUploader在这类系统里还能继续服役一段时间。
倘若要替换,建议替换为原生XMLHttpRequest封装的上传组件,分片、并发、断点续传的核心逻辑自己写,反而更好控。组件库能提供的是交互细节(进度条样式、文件类型图标、拖拽区域),工程上真正麻烦的分片管理还是要自己实现。没有银弹,只有取舍。
我在这个项目里把分片大小、并发数、合并锁、审计日志都落地之后,再也没接到过附件传不上的工单。后来复盘,最开始的“带宽不足”结论完全是误判——真实瓶颈是单链接长传太脆弱,而分片方案的每一处设计都指向“缩短单个请求的生命周期”这一朴素原则。如果你手里也有类似的内部系统附件困扰,建议先别急着换硬件、买昂贵的传输加速服务,按这份思路把分片链路搭起来,大概率能省下一大笔预算,也把用户从“传一半又失败”的崩溃中解放出来。