
1. 为什么你的捅穿点不在带宽先厘清超大附件上传的真实瓶颈先讲一个真实场景。某单位的内部OA系统上线新附件模块用户反馈“超过300MB的文件基本传不上去”不是网速慢而是传到一半页面直接白屏或者提示“网络连接已断开”更气人的是重传还得从头再来。当时我们排查排水第一反应是路由器带宽不够结果拿一台千兆内网主机测试500MB文件下载速度能跑到90MB/s上传却始终卡在十几MB/s还时有失败。问题根本不出在网络上而是出在Web上传形态上。传统上传依赖浏览器对input typefile的处理文件被一次性读入内存再通过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 前端是否还要继续用WebUploaderWebUploader的问题很清楚——多年没有大版本更新还依赖jQuery。但它在内网附件场景里的优势依然明显生态成熟、资料多、团队接手容易、不依赖外部CDN。如果强制要求前后端分离或者全面拥抱Vue/React可以选更现代化的上传组件但在“不折腾”的原则下WebUploader在这类系统里还能继续服役一段时间。倘若要替换建议替换为原生XMLHttpRequest封装的上传组件分片、并发、断点续传的核心逻辑自己写反而更好控。组件库能提供的是交互细节进度条样式、文件类型图标、拖拽区域工程上真正麻烦的分片管理还是要自己实现。没有银弹只有取舍。我在这个项目里把分片大小、并发数、合并锁、审计日志都落地之后再也没接到过附件传不上的工单。后来复盘最开始的“带宽不足”结论完全是误判——真实瓶颈是单链接长传太脆弱而分片方案的每一处设计都指向“缩短单个请求的生命周期”这一朴素原则。如果你手里也有类似的内部系统附件困扰建议先别急着换硬件、买昂贵的传输加速服务按这份思路把分片链路搭起来大概率能省下一大笔预算也把用户从“传一半又失败”的崩溃中解放出来。