1. 为什么工程建筑行业的文件夹上传总是卡在几百兆这道坎
做工程建筑行业的系统,前端上传功能从来不是"加一个<input type="file">"就能交差的。我们项目里,用户要传的是施工图DWG、建筑信息模型RVT、现场影像资料、工程量清单,往往一次就是一个完整的项目文件夹,里面套着"01-勘察设计→施工图→结构→图纸文件",一个目录层级能到四五层,文件数量几百上千个,总大小动不动几个GB。这种需求用普通的上传组件跑,十次有八次要出问题:传一半连接断开、浏览器直接无响应、服务器返回404.13,或者文件虽然传上去了,目录结构全丢了,一堆图纸平铺在一个目录里根本没法看。
为什么普通上传扛不住?先说几个最基础的瓶颈,后面再讲具体怎么破。
第一,HTTP请求体大小限制。ASP.NET默认允许的请求体大小是4MB(旧版)到28.6MB(不同版本有差异),IIS的maxAllowedContentLength默认约30MB。超过这个数,请求还没到你的控制器代码,就被服务器挡在门外了。所以很多人说"我明明改了maxRequestLength为什么还是401/404",多半是只改了一层。
第二,同步上传与浏览器内存。使用传统表单或XMLHttpRequest整体上传一个大文件,浏览器会把整个文件读进内存再往外发,几百MB还好,超过1GB,页面基本就卡死了,用户体验是"传着传着浏览器变白屏"。
第三,目录结构的上传协议不支持。HTML原生的文件选择框可以加webkitdirectory属性让用户选整个文件夹,前端也拿得到File对象和webkitRelativePath,但HTTP上传本身是文件级别的,并没有"文件夹"这个概念。如果不在前端自行把相对路径编码进上传参数、不在后端按路径重建目录,用户选了一个文件夹,最后收到就是一堆文件名,目录树丢得干干净净。
第四,连接稳定性。工程现场的网络环境比写字楼差得多,设计院、工地项目部、异地分支机构,经常是慢速上行、高延迟、还时不时断线。一次性传一个大文件,中间断一次就得从头再来,这种体验用户是不可能接受的。
所以,.NET MVC要支持工程建筑行业的大文件夹上传和目录结构,本质上要解决的不是"一个上传接口怎么写",而是一整套方案:服务端阈值放开、前端目录树数据采集、分片上传、后端临时目录管理、目录重建落盘、断点续传与秒传。这篇文章就按我们实际项目踩坑的顺序,把每一步讲透。
2. .NET MVC服务端的上传阈值配置:web.config和IIS两层都要动
2.1 先搞清ASP.NET管道的两层限制
ASP.NET MVC运行在IIS之上,一个上传请求要通过两层关卡,每一层都有自己的限制,只改其中一层,问题依旧。
第一层是ASP.NET运行时。配置项在web.config的<httpRuntime>节点里:
<system.web> <httpRuntime targetFramework="4.7.2" maxRequestLength="2147483647" executionTimeout="3600" requestValidationMode="2.0" /> </system.web>maxRequestLength:单位是KB,默认4096(4MB)。这里调到2147483647,对应.NET层允许的最大值约2GB。为什么是2GB?因为这个配置项内部换算成字节后是int.MaxValue级别的值,超过这个上限,.NET Framework的请求缓冲机制会出现不可预期的问题。如果业务确实要单文件超过2GB,请务必走分片上传,不要指望这个配置。executionTimeout:单位是秒,默认110秒。传大文件时请求执行时间远超110秒,一旦超时,ASP.NET会直接终止请求,表现为"上传到一半页面报错"。工程文件上行慢,这里建议直接设置3600秒起。requestValidationMode="2.0":这行很关键。.NET 4.0以后默认开启请求数据验证,上传请求的数据可能被当作潜在危险输入拦截。上传大文件时如果不处理,部分请求会被HttpRequestValidationException打回来。
第二层是IIS请求过滤。从IIS 7开始,即使ASP.NET运行时放行了,IIS自身的Request Filtering模块还会拦一道。报错状态码通常是404.13或413。
<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="2147483648" /> </requestFiltering> </security> </system.webServer>maxAllowedContentLength单位是字节,默认约30000000(约28.6MB)。这里设置成2147483648(2GB),理由同上:超过2GB的内容长度,IIS的过滤模块处理起来会有性能和稳定性风险,更稳妥的做法是分片。
2.2 改完配置还要动什么
改完web.config,先确认应用池用的是Integrated模式,Classic模式对请求过滤的行为有差异,我们踩过一次:经典模式下requestFiltering的配置排优先级和集成模式不同,导致改了半天没生效。
然后有一个容易被忽略的地方:应用池超时回收。IIS应用池默认"空闲超时"20分钟,如果上传过程中没有新的请求进来,应用池可能把工作进程收掉,正在传的分片全部作废。在IIS的应用池高级设置里,把"空闲超时"改成0(永不超时),"回收"选项里的固定时间间隔也可以适当拉长,或者配合分片上传的心跳机制解决,后面细说。
2.3 配置生效后如何验证
不要一上来就传几个GB,先用一个100MB文件试探,再用500MB文件,逐步加压。如果返回404.13,说明IIS层没过,检查system.webServer;如果返回500或Maximum request length exceeded,说明ASP.NET层没过,检查system.web。如果返回404.8或者直接被请求过滤截断,多半是URL路径本身包含非法字符——这个在工程文件里尤其常见,因为DWG文件名里可能出现#、%、空格,这些字符在URL编码不完整时会触发IIS的URL过滤规则。
提示:如果项目部署在负载均衡后面,代理服务器(Nginx、HAProxy)的
client_max_body_size也要一并调整,否则请求会在到达IIS之前就被代理层拦截。我们贵阳那个项目就是这样,IIS配置全改完了,前端依然传不上,最后排查到Nginx默认1MB的限制。
3. 保住目录结构:前端选型与文件相对路径的采集
服务端配置放开只是第一步,对工程建筑行业来说,"目录结构保真"才是真正的核心需求。图纸管理系统里,一个项目的文件夹结构往往是组织了几十年的管理规范,什么文件放在什么目录下是有约定的,比如"施工图/建筑专业/一层平面图.dwg"和"施工图/结构专业/一层梁配筋图.dwg",系统收到后必须按原目录落位,不然后续的图纸检索、版本管理全乱套。
3.1 浏览器目录选择的三种数据获取方式
前端拿到目录结构的方式,现在主流有三类。
方式一:<input webkitdirectory>
<input type="file" webkitdirectory multiple id="folderPicker" />用户选择整个文件夹后,input.files里是所有文件的File对象,每个对象带webkitRelativePath属性,像这样:
施工图/建筑专业/一层平面图.dwg 施工图/结构专业/一层梁配筋图.dwg 现场照片/2025-03-12/南侧立面.jpg这个方案最省事,Chrome、Edge、Firefox都支持,IE彻底不用考虑,因为现在工程行业的B端系统基本已经从IE迁移到Chromium内核浏览器了。
方式二:showDirectoryPicker()
这是File System Access API里的方法,Chrome和Edge支持,可以拿到目录句柄后逐层遍历创建文件句柄,再逐个读取。它的优势是可以拿到目录本身的信息,比如空目录也能识别出来,还能做拖拽目录上传。劣势是浏览器兼容性不如webkitdirectory,而且实现代码量多不少。
方式三:自研拖拽上传,解析DataTransferItem
把整个文件夹从系统资源管理器拖到页面上,通过dt.items递归遍历,也能拿到目录树。这个方案交互最好,但兼容性最差,需要比较多的polyfill。
我们最终选的是webkitdirectory为主、拖拽识别为辅。对工程行业用户来说,让他点一下"选择文件夹"比教他拖拽更靠谱,而且webkitRelativePath生态成熟,后台上传组件(WebUploader、Plupload)都有现成的字段映射。
3.2 上传组件怎么把目录结构带回去
工程上用的比较多的开源方案是WebUploader和Plupload。我们需要确认两件事:分片能力、以及分片上传时能否携带自定义路径参数。
WebUploader的分片参数是chunked: true,chunkSize按业务设,比如2MB一片。关键在formData里塞目录信息:
uploader.on('fileQueued', function (file) { // file.relativePath 来自 webkitRelativePath 或自定义计算 uploader.opt('formData')['relativePath'] = file.relativePath; });但要注意,WebUploader在分片请求时,formData是跟着每一片一起提交的。也就是说,后端每次收到分片,都能拿到这个文件相对路径、当前分片序号、总片数,这就够了。目录树无需一次性传过去,只要每个文件的分片能准确告诉后端"我是哪个路径下的第几片",后端就能逐步重建。
如果你不想用现成的上传组件、自研一套,目录结构的数据设计也很简单。前端遍历fileList后,为每个文件生成一个描述对象:
{ "relativePath": "施工图/结构专业/一层梁配筋图.dwg", "fileSize": 805306368, "chunkSize": 2097152, "chunks": 384, "chunkIndex": 0, "fileMd5": "d41d8cd98f00b204e9800998ecf8427e", "chunkMd5": "c4ca4238a0b923820dcc509a6f75849b" }3.3 空目录怎么处理
工程项目的文件夹里经常有空目录,比如"施工图/给排水专业/待深化图纸"这种还没放任何文件的占位目录。webkitdirectory遍历时只产生文件项,空目录不会出现在fileList里。
处理方式有两个:要么在前端遍历时额外请求目录树接口拿到所有空目录列表,一并提交;要么干脆接受"空目录不建"的现状,在上传完成后,用户在系统里手动新建目录。B端系统多数接受后者,毕竟空目录价值不大。但如果你的业务严格要求目录结构完全一致,建议前端额外做一次目录句柄遍历,把空目录的路径列表保存在一个JSON里,跟随第一个分片一起提交,后端重建目录时先建空目录,再落文件。
4. 后端目录重建与文件落盘:分片接收、临时目录与合并
4.1 控制器层接收参数约定
服务端我建议把上传接口设计成两个:
/api/upload/init:上传前初始化。前端传文件相对路径、文件大小、分片大小、文件Md5,后端返回一个上传会话ID(uploadId),并检查该文件是否已存在(秒传逻辑)。/api/upload/chunk:接收单个分片。参数包括uploadId、chunkIndex、totalChunks、相对路径,文件本体是HttpPostedFileBase。
为什么要有init接口?一方面做秒传判断,另一方面可以提前在服务端创建该文件的目录占位,避免边传边建目录带来的并发创建冲突。
控制器的核心代码大概是这样:
[HttpPost] public ActionResult UploadChunk() { var relativePath = Request.Form["relativePath"]; var uploadId = Request.Form["uploadId"]; var chunkIndex = int.Parse(Request.Form["chunkIndex"]); var totalChunks = int.Parse(Request.Form["totalChunks"]); var file = Request.Files["file"]; // 安全校验:路径必须相对且合法,禁止 .. 和盘符 if (!IsSafeRelativePath(relativePath)) { return Json(new { ok = false, msg = "非法路径" }); } // 每个文件的分片统一落在 临时目录/{uploadId} 下,按 chunkIndex 命名 var chunkDir = Server.MapPath($"~/App_Data/_uploading/{uploadId}"); Directory.CreateDirectory(chunkDir); file.SaveAs(Path.Combine(chunkDir, chunkIndex.ToString())); // 最后一篇到达时,触发合并 if (chunkIndex == totalChunks - 1) { var mergeResult = MergeFile(uploadId, relativePath, totalChunks); return Json(mergeResult); } return Json(new { ok = true, received = chunkIndex }); }分片文件命名我直接用了序号,没有把文件名带进去。因为uploadId已经隔离了每个文件的所有分片,序号就够了。带文件名反而增加路径拼接时被注入的风险。
4.2 目录安全校验必须做,不能跳过
工程文件名五花八门,..、反斜杠、盘符都可能出现。在文件落盘之前,绝对禁止直接把前端传的路径拼到服务器路径后面。我们项目里吃过一次亏:用户传了一个文件夹,里面有个文件名带了..,拼接后文件直接被写到了web根目录下,虽然当时没造成实质性破坏,但这是个很吓人的教训。
安全校验函数至少要做这些检查:
private bool IsSafeRelativePath(string relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) return false; // 统一替换反斜杠 relativePath = relativePath.Replace("\\", "/"); // 不允许绝对路径、不允许盘符、不允许上级目录 if (Path.IsPathRooted(relativePath)) return false; if (relativePath.Contains(":")) return false; if (relativePath.Split('/').Any(seg => seg == "..")) return false; // 过滤非法文件名字符 var invalidChars = Path.GetInvalidFileNameChars(); foreach (var segment in relativePath.Split('/')) { if (segment.IndexOfAny(invalidChars) >= 0) return false; } return true; }对B端系统来说,校验不通过的直接拒绝上传比偷偷改文件名更稳妥,因为目录结构是业务约束,用户需要知道自己的文件哪里不合规。
4.3 最后一篇到达时的合并策略
合并文件建议用FileStream按顺序逐片写入目标文件,不要一次性把所有分片读进内存:
private ActionResult MergeFile(string uploadId, string relativePath, int totalChunks) { var chunkDir = Server.MapPath($"~/App_Data/_uploading/{uploadId}"); var rootDir = Server.MapPath("~/Uploads"); // 完整的目标路径 = 上传根目录 + 相对路径 var safeRelPath = CleanRelativePath(relativePath); var targetFullPath = Path.Combine(rootDir, safeRelPath); var targetDir = Path.GetDirectoryName(targetFullPath); Directory.CreateDirectory(targetDir); using (var fs = new FileStream(targetFullPath, FileMode.Create, FileAccess.Write)) { for (var i = 0; i < totalChunks; i++) { var chunkPath = Path.Combine(chunkDir, i.ToString()); if (!System.IO.File.Exists(chunkPath)) { return Json(new { ok = false, msg = $"分片缺失: {i}" }); } using (var input = new FileStream(chunkPath, FileMode.Open, FileAccess.Read)) { input.CopyTo(fs); } // 合并完一片删一片,避免磁盘瞬间翻倍 System.IO.File.Delete(chunkPath); } } // 合并完成后,校验文件大小,然后清理临时目录 var fi = new FileInfo(targetFullPath); if (fi.Length != RequestedFileSize(uploadId)) { return Json(new { ok = false, msg = "文件大小校验失败,请重新上传" }); } Directory.Delete(chunkDir, true); return Json(new { ok = true, path = safeRelPath }); }这里有一个设计细节:合并时逐片删除分片文件。如果不删,一个5GB的BIM模型会被拆成2GB分片占用临时目录,加上目标文件5GB,服务器磁盘瞬间需要7GB以上。逐片删能把临时占用控制在单分片大小范围内,这是我们在实际项目里反复优化后的做法。
4.4 合并与验证的边界情况
合并结束后一定要校验文件大小。前端初始化接口传了fileSize,后端在init时把它存在内存缓存或数据库里,合并后对比。大小一致不保证文件一定完整,但对磁盘IO出问题的情况是有效的预警。如果项目对完整性要求更高,可以在前端流式读取文件时边算整体MD5,分片上传时带chunkMd5,合并后再对整个文件算一次MD5对比。代价是CPU和耗时,工程文件普遍较大,看业务接受度,我们只在单个文件大于1GB或者客户有明确要求时才启用全量MD5校验。
5. 大文件夹上传中那些不大不小却容易整崩的坑
5.1 并发分片连接池把服务端压垮
文件夹里有几百个文件,每个文件再来几十个分片,前端如果开启并发上传,瞬间会发起大量HTTP请求。IIS默认的maxConcurrentRequestsPerCpu、应用池队列长度、TCP端口耗尽,任何一个被打满,上传就会表现为"前几个文件正常,后面的全部排队超时"。
我们第一次实测的时候,前端设了threads: 3(同时3个分片请求),上传一个包含460个文件的目录,跑到第200个文件左右,应用池CPU 100%,请求全面超时。排查了很久,发现不是服务器性能不够,而是每个分片请求都在做Request.Files的文件流解析,加上SaveAs磁盘写操作,高频并发下IO队列堆积。
解决方案分两部分:
一是前端限流。不管文件夹里有多少文件,建议设置全局并发数为2~3,并且每个文件内部的分片串行上传。这样虽然速度会慢一些,但不会把服务端打崩。对工程场景来说,稳定比快重要。
二是后端限流与异步化。在global.asax里给控制器加上[AsyncTimeout],把耗时操作放到异步方法里,避免线程池饥饿。另外,如果条件允许,分片文件落盘可以走内存映射或直接写在独立磁盘上,避免和系统盘、日志盘抢IO。
5.2 超时链路:浏览器、代理服务器、应用池、负载均衡
上传一个1.8GB的文件夹,即使分片上传,整个上传过程可能持续30分钟以上。如果某个环节有超时限制,中间就会被掐断。
我们梳理出的超时点至少有四个:
- 浏览器:页面长时间挂起,浏览器可能在请求层超时。分片上传天然规避了这个问题——每一片都是独立请求,单片通常几秒到十几秒就完成,不会触发浏览器超时。
- 代理/负载均衡:Nginx的
proxy_read_timeout默认60秒,分片上传单片如果超过60秒,代理就会断开。工程现场上行带宽不足时,2MB分片也可能传很久。建议把client_body_timeout、proxy_read_timeout都调大到300秒以上,并且根据现场网络实际情况调整分片大小。 - ASP.NET执行超时:
executionTimeout已经在第2节调大过。 - 应用池空闲回收:这个坑比较隐蔽。用户传完一个分片后,可能停顿了一会才传下一片,如果超过应用池空闲超时,工作进程被回收,所有临时目录里的分片还在,但内存缓存里的uploadId和文件大小信息全丢了。解决方式:把闲置超时设为0,或者不要依赖内存缓存,把上传会话信息写到数据库或Redis。
5.3 临时目录磁盘爆炸
工程文件动辄几个GB到几十GB,如果每个人上传过程中都占用一份临时目录,磁盘很快会满。我们有个项目上线第一周,服务器100GB数据盘被打满,原因就是一批用户同时传大文件,临时目录里堆积了数不清的分片。
应对方案:
- 定时清理任务:写一个后台任务(建议用
Hangfire或Windows服务),定期扫描App_Data/_uploading目录,删除最后修改时间超过24小时且未完成合并的目录。 - 合并后及时清理:第4节已经提到逐片删除。
- 独立磁盘分区:上传临时目录和大文件存储目录单独挂盘,不要和系统盘、数据库盘共用。哪怕是云服务器,也要单独购买数据盘挂载。
5.4 断点续传和秒传的"实际体验"
分片上传天然支持断点续传,但要做对,核心在init接口的状态判断:
[HttpPost] public ActionResult InitUploadFile() { var relativePath = Request.Form["relativePath"]; var fileSize = long.Parse(Request.Form["fileSize"]); var fileMd5 = Request.Form["fileMd5"]; var targetFullPath = Path.Combine(UploadRoot, relativePath); // 秒传:目标文件已存在且大小一致 if (System.IO.File.Exists(targetFullPath)) { var existsFile = new FileInfo(targetFullPath); if (existsFile.Length == fileSize) { return Json(new { ok = true, uploadId = "", needUpload = false }); } } // 断点续传:临时目录里已有部分分片 var uploadId = ComputeUploadId(fileMd5, relativePath); var chunkDir = Server.MapPath($"~/App_Data/_uploading/{uploadId}"); var uploadedChunks = new List<int>(); if (Directory.Exists(chunkDir)) { foreach (var chunkFile in Directory.GetFiles(chunkDir)) { uploadedChunks.Add(int.Parse(Path.GetFileName(chunkFile))); } } return Json(new { ok = true, uploadId = uploadId, needUpload = true, uploadedChunks = uploadedChunks }); }前端拿到uploadedChunks,已经传过的分片直接跳过,没传的继续,这样用户中途关掉页面、重新打开、重新选同一个文件夹,就能从断点继续。判断"同一个文件夹"靠fileMd5和relativePath组合,所以前端在遍历文件时一定要稳定计算文件Md5,不能每次都变。
工程行业一个常见的误解是:用户把文件夹A传了一半,把文件夹A改名成A2再传,服务端会不会认为是新任务?会。因为relativePath变了,uploadId也会变。所以断点续传要求用户重传时,选择的目录路径不能变。
6. 实测验证与调优建议
6.1 一组实测数据
我们的项目环境:.NET Framework 4.7.2 + MVC 5,部署在两台Windows Server 2019(4核8G,千兆内网,系统盘和数据盘分离),前端WebUploader分片2MB,全局并发3。
- 单文件850MB的RVT模型,分片512片,总耗时约12分钟(受限于现场上行带宽),中间断开两次,断点续传后成功。
- 一个包含942个文件、总大小约5.6GB的勘察设计文件夹,目录层级5层,首次上传耗时约58分钟,期间服务端处理后端消息队列无积压,应用池CPU稳定在30%~50%。
- 一次极端测试:模拟客户端在上传过程中拔网线,临时目录中残留分片。重启上传后,init接口返回已有分片列表,跳过已传分片,最终完整落盘,目标文件MD5与源文件一致。
这些数据说明方案是可行的,但要注意,瓶颈通常在客户端上行带宽和服务端磁盘IO,代码层面可优化的空间反而没有想象中大。
6.2 分片大小的选择
分片大小直接影响两个指标:单片的超时风险,以及分片请求的数量。我们测试过1MB、2MB、4MB、8MB四种规格:
| 分片大小 | 单文件850MB的分片数 | 弱网环境下单片失败率 | 服务端IO压力 |
|---|---|---|---|
| 1MB | 850 | 较低,但总请求数过多 | 高 |
| 2MB | 512 | 低 | 中 |
| 4MB | 213 | 低 | 中 |
| 8MB | 107 | 弱网下单片超时概率上升 | 较低 |
我们最终选了2MB。原因是:2MB分片在4G、Wi-Fi信号不佳的环境下单片传输时间一般在10~30秒内,既不会频繁超时,也不会因为请求数量太多造成服务端压力。如果你的用户主要走内网环境,可以放宽到4MB或8MB。
6.3 上传进度怎么算才让用户信服
文件夹上传的进度不能只算一个文件的进度,用户关心的是"整个文件夹传了百分之多少"。计算公式建议这样:
总进度 = 已传文件的总大小 / 所有文件的总大小 × 70% + 当前文件已传大小 / 当前文件总大小 × 30%(仅计算当前正在上传的文件)这个权重不是固定的,可以根据平均文件大小调整。文件数量多且单个文件偏小的时候,每个文件完成数对进度的贡献更大;文件大且数量少的时候,当前文件进度占比应该提高。我们在工程文件夹场景(大量DWG图纸文件,平均每份30MB~80MB)用的就是70%和30%的权重,用户普遍反馈进度条"跟得上真实速度"。
6.4 上传完成后的目录结构校验
我们遇到过一种情况:分片全部上传完成、文件也合并了,但有个别文件因为文件名非法被安全校验拦截,导致整个目录缺了一个子目录。用户并没有第一时间发现,等检索图纸的时候才发现少了文件。
所以在上传完成后,前端拿到所有relativePath列表,请求一个校验接口:
[HttpPost] public ActionResult ValidateUploadedFiles(List<string> expectedPaths) { var missing = new List<string>(); foreach (var relPath in expectedPaths) { var safeRelPath = CleanRelativePath(relPath); var fullPath = Path.Combine(UploadRoot, safeRelPath); if (!System.IO.File.Exists(fullPath)) { missing.Add(relPath); } } return Json(new { ok = true, missing = missing }); }前端拿到missing列表后,给用户一个明确提示:"这些文件上传未完成,请重新选择目录补传",并把缺失文件单独列出来。这样一个闭环下来,目录结构丢失基本可以被拦住。
最后再分享一个实际操作中的体会
我在这个项目之前一直以为大文件夹上传的核心是并发、是速度、是服务端性能,做完之后才意识到,对工程建筑行业来说,目录结构保真和安全校验的优先级远高于上传速度。用户宁愿多等几分钟,也不希望传到系统里的图纸目录一团乱麻。所有降速、限流、校验的机制,都是为了让最终落盘的文件树和用户本地的文件夹一致。
另外有一个小技巧:上传完成后的落盘目录不要直接暴露给用户访问,中间加一层虚拟路径映射或者受控下载接口。工程图纸是敏感数据,直接通过静态文件URL访问的话,URL一旦泄漏,整个项目文件夹都能被遍历下载。上传和下载权限分开控制,是这类系统上线前最值得检查的一件事。