☰
WebUploader+PHP分片上传:老OA系统大附件优化实践
2026/10/18 13:00:51 网站建设 项目流程

最近在维护一个单位的老OA系统时,被附件上传问题折腾得不轻。业务同事天天在群里反馈:图纸、合同扫描件、项目报告,动不动就是几十上百MB,一点上传就转圈,转着转着就报错,甚至直接把浏览器搞崩。排查下来,系统的前端用的是老牌的WebUploader,后端是PHP,走的是最朴素的“单个文件整体上传”模式。这个组合在几年前跑小附件完全没问题,但面对超大附件,传输效率和稳定性就双双露馅了。这篇文章就基于这次真实改造经历,完整聊聊如何用WebUploader+PHP的分片传输方案,把老OA系统的超大附件上传从“能不能传上去”优化到“既快又稳”。

先说结论:这个方案适合正在维护PHP老系统的开发同学。WebUploader虽然官方早已停止更新,但它分片、并发、队列能力足够成熟,PHP后端配合接收分片和合并文件也完全不复杂。整个改造不需要引入Node、不需要重构前端工程,能在最短时间内让大附件上传体感发生质变。

1. 为什么老OA系统的大附件上传会卡死

1.1 问题现场与用户真实反馈

先说几个我在项目中实际遇到的场景。有同事上传一份80MB的施工图纸,系统直接转圈十几分钟,最后弹了个“网络错误”的框,等于白传。还有人传一份50MB的PDF合同扫描件,浏览器标签页直接卡死,只能强制关闭重来。最离谱的是,有用户把一个200MB的监控视频往OA里拖,结果服务端PHP直接报内存不足错误,连带着把系统页面都搞成了白屏。

这些问题的共性是:附件越大,失败率越高,且失败后没有任何断点续传能力,用户必须从头再传一遍。在一次内部统计里,大于50MB的附件首次上传成功率只有不到70%,这放在办公场景里是非常影响业务的。特别是一些需要走流程审批的紧急文件,卡在上传环节,后面的审核、归档全都不动,业务部门怨声载道。

1.2 卡死的三个核心瓶颈

之所以会出现这种局面,并不是某一处配置错了,而是整个上传链路层层受限。把链路拆开看,主要有三个瓶颈。

第一层是网关或Web服务器的限制。当时系统前面挂了一层Nginx,默认的client_max_body_size是1MB,虽然运维部门调过,但调到了100MB。按说够用,可一旦附件超过这个值,直接返回413错误,用户根本不知道是服务器限制,只看到“上传失败”。

第二层是PHP运行时的限制。upload_max_filesize、post_max_size、max_execution_time、memory_limit这四个参数像四道紧箍咒。单个大文件整体上传时,PHP要在一次请求里接收完整个文件内容,post_max_size不够就截断,memory_limit不够就直接崩,max_execution_time不够就被强制杀死。这还只是单文件请求,如果同时有几个人上传大文件,PHP-FPM的进程直接被打满,整个OA系统都会变慢。

第三层是网络链路本身的脆弱性。内网虽然带宽不算小,但一个100MB文件通过单连接传输,中途只要有一次网络抖动、一个TCP包重传超时,整个请求就断掉了。WebUploader在非分片模式下,对单一大文件的重试表现很差,基本就是全量重来。换句话说,老OA系统的大附件上传,本质上是在用单线程、单请求的方式去对抗大文件、弱网络的客观现实,自然会卡死。

1.3 为什么选WebUploader而不是换新框架

在做方案选型时,也考虑过换现代前端上传组件,比如用XHR2原生的upload事件,或者接第三方SDK。但仔细一评估就放弃了。这套OA系统的前端停留在jQuery时代,没有一个现代化的构建流程,引入新版组件库意味着要搭一套Webpack或Vite环境,还要处理各种依赖兼容问题,改动面巨大,风险不可控。

WebUploader的优势在于它足够“老”,老到能完美兼容jQuery、兼容IE时代的各种坑。它原生自带分片、并发上传、队列管理、进度回调,这些正好是我们需要的。更关键的是,它的接入方式是简单的JS+CSS,引入就能用,不需要打包工具,对老系统的改造侵入性最小。通俗点说,这不是选了一个最好的技术方案,而是选了一个最合适的改造方案。

2. 分片方案怎么设计才合理

2.1 分片大小选多少

确定用WebUploader做分片后,第一个要拍板的问题就是分片大小。这个参数直接决定传输效率,不能拍脑袋。

分片过小,比如1MB,一个100MB文件要切成100片,每片都带着HTTP请求头、上传接口逻辑、磁盘写入操作,请求往返的开销会被极度放大,传输效率反而不高。分片过大,比如20MB,虽然请求次数少了,但一旦某个分片传输失败,重传代价就很大,而且单分片传输时间长,网关超时的风险也会增加。

实际项目里我用了一个简单估算公式:分片大小 ≈ 内网单线程有效带宽 × 单分片期望耗时。假设内网有效带宽约8MB/s,我期望单个分片在2到3秒内传完,那分片大小就在16MB到24MB?这里有个误区,因为WebUploader是并发上传的,单分片耗时可以放宽,但还要考虑服务端PHP进程压力和接口处理速度。做了一轮实测对比后,我最终把分片定为4MB。这个值比较中庸:100MB文件切成25片,请求次数可控,单片传输时间短,就算是弱网环境下失败重传的代价也完全不痛。

分片大小100MB文件分片数单分片传输耗时(10MB/s带宽)适用场景
2MB50约0.2秒弱网、服务器性能差
4MB25约0.4秒一般内网,推荐使用
8MB13约0.8秒内网带宽好,服务器处理快

2.2 并发线程数设置多少

WebUploader的threads参数控制同时上传的分片数量。这个参数不能一味调大。并发太高时,前端同时发起多个AJAX请求,这些请求会共享同一台PHP-FPM服务器的进程池。如果十几个并发请求同时打过来,PHP-FPM的进程全部被上传接口占据,其他OA页面的请求就会排队,整个系统都会卡。

根据服务器的硬件情况,4核8G的机器,我将threads设为3到5之间。实测下来,3并发对服务器压力最小,但100MB文件传输耗时大概要15秒。调到5并发后,时间可以下降到10秒左右,但PHP-FPM的CPU占用会上升约15%。考虑到OA系统还有其他业务,最终标准配置定为“3并发+分片4MB”,在稳定性和速度之间取一个平衡点。

还要注意一个细节:浏览器对同一域名的并发连接数限制(HTTP/1.1下通常是6个),WebUploader内部走的是AJAX请求,如果同时再打开其他页面,很容易把连接数占满。设置并发数时,一定要给其他业务留一点余量。

2.3 全文件MD5校验到底做不做

WebUploader默认会对每个分片做MD5校验,这是为了确保分片数据传输完整性。但很多人在改造时容易加戏,想在传输前对整个文件算一次MD5,然后传给服务端做全文件比对——这在超大附件场景下是个坑。

全文件MD5的计算是CPU密集型任务。一个100MB的文件,在普通办公电脑上算完MD5可能要十几秒到几十秒,这期间页面会卡顿,用户会以为是浏览器死了。如果算到一半手滑关了页面,一切重来。在分片模式已经保证每片完整性的前提下,全文件MD5的意义被大大稀释了。

我当时采取的办法是:不做全文件MD5,只依赖WebUploader的分片MD5校验,合并后再做“大小校验+首尾分片内容校验”。如果文件严重错乱或缺失,合并后的大小和首尾内容一定对不上,立刻就能发现。只有在分发特别重要的正式文件时,才额外做一次服务端的全文件MD5,但也是异步计算,不能阻塞上传流程。

2.4 断点续传的必要性

大文件传输,断点续传是最影响用户体验的功能。WebUploader官方实现断点续传依赖的是浏览器端的localStorage,也就是把已上传分片的信息存在用户本地。但单位OA的使用场景很特殊:很多人共用电脑、浏览器经常清理、系统还可能部署在用IE内核的国产浏览器上,localStorage随时可能丢失,一旦丢失,断点续传就失效了。

更稳的方案是“服务端分片续传”。也就是服务端保留临时分片文件,前端在开始上传前,向后端接口查询“这次上传已经有哪些分片”,然后跳过这些已上传的分片,只传缺失的部分。这个方案的实现并不复杂,后端多提供一个查询接口即可。后面章节我会给出具体实现办法。

但也要注意,服务端临时分片不能无限期保留,必须设置过期时间。设计上,超过24小时未合并的分片要自动清理,避免磁盘被无用的残留文件占满。

3. 前端实操:WebUploader关键配置与封装

3.1 最小可用的WebUploader配置

接入WebUploader的第一步是引入资源,从官方CDN或本地静态目录载入js和css,然后初始化实例。下面这段配置是经过实测可以直接用的,每一步都有明确目的。

var uploader = WebUploader.create({ swf: '/static/js/Uploader.swf', server: '/api/oa/upload/upload_chunk.php', pick: '#picker', accept: { title: 'Files', extensions: 'doc,docx,pdf,xls,xlsx,zip,rar,jpg,png', mimeTypes: '*.*' }, chunked: true, // 开启分片 chunkSize: 4 * 1024 * 1024, // 分片大小4MB threads: 3, // 并发上传数 fileVal: 'file', // 后端接收分片的文件字段名 formData: { uploadId: '' // 动态生成的uploadId,下面会讲 }, fileSingleSizeLimit: 2 * 1024 * 1024 * 1024, // 单文件最大限制2GB duplicate: true, // 允许选择重名文件 auto: false // 先加入队列,手动触发上传 }); uploader.on('beforeUpload', function(file) { // 给每个文件生成一个唯一uploadId var uploadId = Date.now() + '_' + Math.floor(Math.random() * 100000); uploader.options.formData.uploadId = uploadId; uploader.options.formData.fileName = file.name; uploader.options.formData.fileSize = file.size; // 如果要用服务端断点续传,在这里先请求后端,查询uploadId对应的已上传分片 // 把已上传的分片编号列表传给WebUploader,让它跳过这些分片 // 具体代码见下面的“断点续传实现” }); uploader.on('uploadSuccess', function(file, response) { // 分片都上传完了,WebUploader会自动回调这个事件 // 在这里通知服务端执行合并操作 if (response && response.status === 'success') { $.post('/api/oa/upload/merge.php', { uploadId: uploader.options.formData.uploadId, fileName: file.name, fileSize: file.size, chunks: Math.ceil(file.size / uploader.options.chunkSize) }); } }); uploader.on('uploadError', function(file, reason) { console.error('上传出错', file, reason); });

这段配置里,auto:false是刻意关闭自动上传,等用户点一个上传按钮再开始跑批量队列。对于一些需要用户先填写文件说明、再提交的场景,这样更可控。

3.2 自定义uploadId的设计

WebUploader自带一个file.id,是整个组件内部的全局递增ID,但它对服务端来说没有实际意义,也无法用于断点续传。所以我在beforeUpload事件里重新生成了一个uploadId,格式是“时间戳+随机数”。这个ID是整个上传过程的唯一标识,后续每个分片请求都会带上它,服务端根据它来组织临时分片。

这个uploadId在断点续传场景下还有一个妙用:如果用户删掉文件重新选择同一个文件,beforeUpload会重新生成一个新的uploadId,旧的分片就无法复用了。此时可以加一层“文件指纹匹配”,用文件名+文件大小+最后修改时间生成一个粗略指纹,相同指纹就延续之前的uploadId,不同就新建。这个做法不需要MD5计算,代价极小,但实现了近似断点续传的效果。

3.3 分片命名的坑

服务端接收分片后,保存临时文件的名字不能直接用原始文件名。这是我在项目里踩过的坑。中文文件名经过PHP接收后,编码可能是GBK也可能是UTF-8,不同系统间的处理方式还不一致;文件名里如果带了路径分隔符或特殊字符,轻则文件存错位置,重则可引发安全风险。

正确的做法是服务端收到分片后,统一用uploadId加分片序号来命名,也就是{uploadId}_{chunkIndex}.part。整个过程完全不依赖原始文件名,原始文件名只在合并阶段写入最终文件的元信息即可。这样不仅安全,后期排查分片残留时也一目了然,哪个用户传的、传到哪一片,看文件名就知道。

3.4 合并触发与重试机制

WebUploader的分片上传完成后,会自动触发uploadSuccess事件,但这并不代表整个上传流程结束。真正的文件合并需要后端二次接口来触发。这里有一个细节:uploadSuccess事件在分片上传成功后立即触发,此时最后一个分片可能刚写完,为了防止合并时文件还没落盘完整,我个人习惯在uploadSuccess里加一个短暂延时,比如500ms,再请求合并接口。

合并请求本身也要处理失败重试。如果合并接口因为服务端繁忙返回超时,前端不能干等,需要有一个重试按钮或自动重试逻辑。我当时的做法是:合并失败时,前端弹出一个提示,同时保留uploadId,用户点“重试合并”,前端重新调用一次合并接口,并不会重新上传分片。这个机制救了无数次急,因为在文件传输完成后,卡在合并环节是最冤的情况。

4. 后端PHP怎么扛住分片压力

4.1 接收分片的接口实现

前端配置完成后,后端就是重头戏。接收分片的接口要做三件事:鉴权、落盘、响应结果。

首先,接口里必须校验用户登录状态,不能让未登录的人往服务器传文件。其次,需要判断本次分片是否合法,包括分片序号是否在合理范围内、分片大小是否超过分片上限等,防止有人伪造请求刷爆磁盘。核心代码如下:

<?php // upload_chunk.php session_start(); // 鉴权:确保用户已登录 if (empty($_SESSION['user_id'])) { http_response_code(401); exit(json_encode(['status' => 'error', 'message' => '请先登录'])); } $uploadId = isset($_POST['uploadId']) ? preg_replace('/[^0-9a-zA-Z_]/', '', $_POST['uploadId']) : ''; $chunkIndex = isset($_POST['chunk']) ? intval($_POST['chunk']) : -1; $chunks = isset($_POST['chunks']) ? intval($_POST['chunks']) : 0; if ($uploadId === '' || $chunkIndex < 0 || $chunks <= 0) { exit(json_encode(['status' => 'error', 'message' => '参数不合法'])); } // 限制分片大小,超过8MB直接拒绝 if ($_FILES['file']['size'] > 8 * 1024 * 1024) { exit(json_encode(['status' => 'error', 'message' => '分片大小超限'])); } // 临时目录按uploadId隔离,避免单目录文件过多 $tempBase = '/data/oa_upload_temp/'; $tempDir = $tempBase . $uploadId . '/'; if (!is_dir($tempDir)) { mkdir($tempDir, 0755, true); } $destFile = $tempDir . $chunkIndex . '.part'; if (!move_uploaded_file($_FILES['file']['tmp_name'], $destFile)) { exit(json_encode(['status' => 'error', 'message' => '分片保存失败'])); } // 返回当前已接收的分片列表,便于前端断点查询 $received = array_map(function ($file) { return intval(pathinfo($file, PATHINFO_FILENAME)); }, glob($tempDir . '*.part')); exit(json_encode(['status' => 'success', 'received' => $received]));

这里最关键的是用uploadId创建独立目录,把所有分片都放在同一ID下面。这样做安全性最好,目录内文件数量不会无限膨胀,而且后续合并时只需扫描这一个目录。

4.2 合并分片的最优姿势

合并分片是后端性能最敏感的一环。如果处理不当,很容易把PHP进程内存和CPU抬到极限。不少新手会直接用file_get_contents把每个分片读入内存,再拼接成一个大字符串,一次性写入目标文件——这个写法在几十MB文件下还能凑合,但到了200MB、500MB就直接内存溢出了。

正确的合并方式是用流式读写,一次只读一小块,往目标文件里追加,PHP自己只占极小内存。参考代码如下:

<?php // merge.php session_start(); if (empty($_SESSION['user_id'])) { http_response_code(401); exit(json_encode(['status' => 'error', 'message' => '请先登录'])); } $uploadId = isset($_POST['uploadId']) ? preg_replace('/[^0-9a-zA-Z_]/', '', $_POST['uploadId']) : ''; $fileName = isset($_POST['fileName']) ? trim($_POST['fileName']) : ''; $fileSize = isset($_POST['fileSize']) ? intval($_POST['fileSize']) : 0; $chunks = isset($_POST['chunks']) ? intval($_POST['chunks']) : 0; $tempDir = '/data/oa_upload_temp/' . $uploadId . '/'; // 检查目录里是否有完整的分片序列 $received = []; for ($i = 0; $i < $chunks; $i++) { $partFile = $tempDir . $i . '.part'; if (!file_exists($partFile)) { exit(json_encode(['status' => 'error', 'message' => '缺少分片: ' . $i])); } $received[] = $partFile; } // 最终存储目录,按月份归档 $saveDir = '/data/oa_attachments/' . date('Ym') . '/'; if (!is_dir($saveDir)) { mkdir($saveDir, 0755, true); } // 拼接一个安全的存储文件名,这里用时间戳+随机数 $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); $allowedExt = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'zip', 'rar', 'jpg', 'jpeg', 'png']; if (!in_array($ext, $allowedExt)) { exit(json_encode(['status' => 'error', 'message' => '文件类型不允许'])); } $savePath = $saveDir . date('YmdHis') . '_' . mt_rand(1000, 9999) . '.' . $ext; // 流式合并:fopen + fread + fwrite,逐块写入 $destHandle = fopen($savePath, 'wb'); if (!$destHandle) { exit(json_encode(['status' => 'error', 'message' => '无法创建目标文件'])); } $totalSize = 0; foreach ($received as $partFile) { $srcHandle = fopen($partFile, 'rb'); if (!$srcHandle) { fclose($destHandle); exit(json_encode(['status' => 'error', 'message' => '分片打开失败'])); } while (!feof($srcHandle)) { $buffer = fread($srcHandle, 8192); fwrite($destHandle, $buffer); $totalSize += strlen($buffer); } fclose($srcHandle); } fclose($destHandle); // 合并后做大小校验,防止传输出错 if ($totalSize !== $fileSize) { unlink($savePath); exit(json_encode(['status' => 'error', 'message' => '文件大小校验失败'])); } // 清理临时分片目录 array_map('unlink', glob($tempDir . '*.part')); rmdir($tempDir); // 入库记录,返回访问路径 file_put_contents('/data/oa_upload_temp/upload_log.txt', date('Y-m-d H:i:s') . " uploadId:{$uploadId} file:{$fileName} size:{$totalSize} path:{$savePath}\n", FILE_APPEND); exit(json_encode(['status' => 'success', 'path' => $savePath]));

这段代码有几个细节值得注意。fread的缓冲块设成8192字节是吸取了经验教训,这个值对磁盘IO性能相对友好。合并后校验总大小是必须的,因为分片上传过程中可能出现分片丢失或错序,大小对不上就立刻删除目标文件,防止把损坏文件入库。

4.3 内存、超时与并发控制

在PHP层面,分片模式天然把单个请求的负载降了下来。以前post_max_size必须设置到1GB才能传大文件,现在分片模式下,单次请求最多传一个4MB的分片,加上一些请求头,post_max_size设为8MB就绰绰有余。同理,upload_max_filesize也可以降到8MB或10MB,这对服务器是极大的释放。

真正需要注意的是合并接口。合并大文件是一个相对耗时的操作,虽然流式读写已经大幅减少了内存占用,但CPU和磁盘IO消耗依然可观。我在代码里加入了set_time_limit(0),避免PHP在合并200MB文件时被默认的30秒超时掐掉。同时,memory_limit保持256MB即可,因为流式读写基本不吃内存。

并发这块,PHP-FPM的进程池要够用,但也不宜盲目调太多。对于4核8G的服务器,pm.max_children设为20到30之间比较合理。每个上传接口处理时间短,一旦分片上传完毕,进程立即释放。并发5个上传任务同时进行,占用的进程数大概是15个左右,其余业务请求依然有资源可用。

4.4 临时目录与垃圾清理策略

分片临时目录如果不做清理,磁盘迟早被占满。很多人在改造时最容易忽略这个环节。用户在传大附件时中断上传、关闭浏览器、断电,都会在temp目录留下若干半成品分片。我在项目中遇到过一次,一个月的量积累下来,临时目录吃掉了整整36GB磁盘空间,直接把业务服务器磁盘撑满了。

清理策略我放在了一个定时任务里,每天凌晨执行一次,删除最近24小时未被修改过的临时目录。判断依据是分片文件的mtime,超过24小时没有新分片写入的目录,视为“僵尸上传任务”,整个目录直接删除。这个策略实现简单,效果也最直接。后来还补了一条:用户主动取消上传时,前端调用一个cancel接口,服务端当场清理该uploadId的所有临时分片,避免垃圾堆积。

5. 效率优化的几个进阶手段

5.1 合并时的IO节奏优化

流式合并虽然正确,但如果分片数量特别多、文件特别大,还会遇到另一个问题:大量的小块fread和fwrite在Linux默认的IO调度下,会产生很多小型的随机写入操作,最终文件的写入效率并不高。一个经验做法是调节读缓冲的大小,从8192字节提升到65536字节甚至1MB。实测在机械硬盘环境下,1MB的缓冲块比8KB的缓冲块合并速度能提升30%到50%。当然,如果用的是SSD,差距没那么明显,但缓冲块越大,意味着每次系统调用能搬运更多数据,对减少CPU开销也有帮助。

5.2 校验逻辑分级处理

合并时全量校验所有分片的MD5,在大文件场景下也是不小的开销。前面提到过,我日常只做大小校验,这个校验成本几乎为零。但如果业务要求高可靠性,可以做“分级校验”:默认只校验大小和首尾分片的MD5;如果系统检测到近期上传失败率偏高,可以临时开启全分片MD5校验,作为故障期间的加强手段。这样平时性能最优,需要时又能调高可靠性。注意,分片MD5计算在WebUploader前端已经做了一遍,服务端如果再做全量MD5,相当于一进一出两次CPU开销,容易拖垮服务器,务必谨慎启用。

5.3 存储层渐进式升级

改造初期,合并后的文件直接存本地磁盘,这是最省事的方案。但本地磁盘有两个隐患:一是单块磁盘容量有限,附件归档多了容易扛不住;二是如果这台服务器故障,所有附件都会丢。单位OA在下一次改造时可以考虑把存储迁移到共享存储或对象存储,但这一步完全不必和分片传输改造绑在一起。

类似渐进式改造的经验是:先在应用层把上传接口和存储路径抽象一层,将“保存路径”设计成可配置的,这样后面换成对象存储时,改动点会非常小。老系统的改造就怕大动干戈,把核心业务逻辑搞乱。分的越细,风险越小。

5.4 日志埋点与分析

排查效率问题时,日志是最直接的线索来源。我在改造时加了一个极简的文本日志,每次上传完成记录一行:上传ID、文件名、文件大小、分片数、传输耗时、合并耗时、最终路径。这些数据积累到一定量后,可以做一次简单的统计,看看大部分文件的耗时分布,以及哪些环节最慢。

有一次性能优化就是从日志里发现的端倪:某段时间内合并耗时突增,但文件大小并没有显著变化。查下来发现是共享存储所在磁盘上有大量其他项目在跑IO,挤占了我们合并操作的数据读写。通过日志定位到性能拐点,配合存储侧调整队列,问题很快得到解决。不要小看一个文本日志,很多生产环境的问题只有靠数据才能定位。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因排查手段解决方案
分片上传到100%卡住不动合并接口超时或报错看Nginx/PHP-FPM日志,手动请求merge接口merge代码加set_time_limit(0),前端增加重试合并
大文件传着传着被拒绝网关上还有client_max_body_size限制检查整个链路的代理层配置调大参数或确认分片模式下单请求体没超限
合并后文件打不开分片序号排序错误或分片缺失检查临时分片目录中文件数量合并代码严格按chunkIndex升序合并
上传接口偶尔500临时目录权限不对或磁盘满检查存储挂载和写权限为临时目录单独划分空间并加监控
上传过程中系统变慢PHP-FPM进程被上传任务占满使用top查看php-fpm进程数调低threads数或限制同时上传人数
清理临时文件总是失败目录权限或PHP进程用户不一致查看cron日志用系统级定时任务调用CLI脚本,别走Web请求
用户反馈“断点续传无效”localStorage被清或浏览器换机询问使用场景改用服务端查询已传分片实现续传

6.2 踩坑过程:分片错乱如何处理

有一段时间,合并出来的文件偶尔会出现中间段落内容错位、但大小却完全一致的情况。排查了很久,最后发现问题出在一个隐性逻辑上:某台老旧办公机的浏览器并发上传时,部分分片的请求被代理层做了重试,同一个分片被服务端接收了两次,而且两次请求的响应顺序不同,导致服务端分片文件被第二次写入覆盖成了其他分片的内容。

当时已经把分片命名为{chunkIndex}.part,按道理不会被覆盖错误。深入查找后发现,服务端move_uploaded_file在处理重复请求时,因为临时目录里已经存在同名文件,存在竞态问题。解决办法是:在接收分片时先检查同名文件是否已存在,如果已存在且大小一致,直接跳过写入并返回成功,不做二次覆盖。这个“幂等接收”逻辑非常关键,它能容忍网络层重试,保证一个分片只会被写入一次。

6.3 大附件上传的终极安全防线

老OA系统做附件上传改造,安全始终不能放松。除了前面提到的登录校验、目录隔离、禁止原始文件名之外,还有两个细节要特别注意。

第一,分片不能只信任前端传的chunks,接口里要限制总大小。比如单文件最大2GB,那么chunkIndex不能超过2GB / chunkSize。否则有人可以伪造几百个分片请求,把磁盘写满。

第二,合并后的文件要用finfo检测真实文件类型,而不是简单信任扩展名。常见做法是把不合规的MIME类型全部拒绝。国企内部办公系统虽然不像公网系统那样天天被攻击,但安全评审对文件上传模块的审查一向严格,这些点提前补齐,后面等评审时就不用返工。

7. 实测数据与调优记录

7.1 改造前后的直观对比

以我们最近处理过的一个案例为例。环境是某单位内网,办公网交换机为千兆,但多数电脑网卡是百兆,实际有效带宽约8MB/秒到10MB/秒,服务器为4核8G的老机器,磁盘为SATA SSD。

用80MB的PDF文件做测试:

指标改造前(整体上传)改造后(分片上传)
上传耗时平均13分钟,常超时报错平均12秒左右
失败率约30%低于3%
服务器CPU占用上传时单核接近100%三线程并发时约40%
用户操作体感页面长时间假死,无法做其他事可同时处理其他页面,有实时进度

这个对比虽然和服务器带宽、硬件有关系,但足以说明问题。分片传输不只是“能传了”,而是把整个上传过程从“阻塞型”变成了“异步型”,用户不再需要死死盯着进度条等待。

7.2 调优过程中的几点个人体会

第一,不要一上来就追求最大速度。最初我把并发调到5,分片调到8MB,测试时大文件确实传得快,但PHP-FPM经常被打到高负载,其他OA页面打开都变慢。后来把并发回调到3,虽然传输慢了一点点,整体稳定性却好得多。系统是给人用的,单点性能最优不代表整体最优。

第二,一定要在真实网络环境里测试。开发环境是千兆光纤直连,测不出弱网下的表现。我把测试电脑接到一台百兆交换机上,故意模拟低带宽场景,才发现了分片重试会导致重复写入的隐患,也才明白为什么前同事以前一直抱怨大附件总是传着传着就失败。

第三,WebUploader官方文档虽然并不多,但针对它的兼容性坑、事件机制,网上有足够多的实践总结。遇到问题优先搜索,不要轻易去改它内部的源码。前期项目组有人试图给WebUploader扩展更多功能,最后发现升级组件版本反而更容易导致不可控的回退。

最后再分享一个小技巧:分片目录的命名建议按uploadId做一层哈希,比如取前两位字符再嵌套一层子目录,避免几万个上传任务同时堆在一个目录下,造成文件系统查询效率下降。这个优化在做海量附件归档时效果非常明显,但平时几十个并发任务时感觉不出来。用不用,取决于系统的日均上传量有多大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询