资讯动态

ASP.NET文件夹上传实战:分片断点续传与弱网优化方案

发布时间:2026/10/18 11:18:27 来源:尧图企业网站定制
最近一个月我基本都在跟“文件夹上传”这四个字较劲。项目本身是某内网部署的ASP.NET系统业务上要求用户能一次选中一个几十个甚至上千个文件的目录整个传上来并保留原有目录结构。甲方提了两条硬指标第一不能因为网络抖动就整个重传第二传输效率必须让人等得住。实际上在ASP.NET技术栈里实现文件夹上传并不缺Demo缺的是一个能在弱网、大文件、文件数量多、还有审计要求的环境里稳定跑通的完整方案。这篇就记录我在这类项目里的方案选型和最终落地。1. 军工系统里的文件夹上传难点集中在目录结构和网络链路1.1 文件夹上传不是“多传几个文件”很多人的第一反应是文件夹上传不就是选一堆文件然后循环调用上传接口吗真去做了就会发现完全不是一回事。文件夹上传要保留相对路径比如用户选中一个叫做“项目资料”的目录里面有“图纸”“文档”两个子目录传到服务器后这两个子目录必须还在“项目资料”下面不能全拍平成一层文件。前端拿到的文件对象里会带一个webkitRelativePath属性值大概是项目资料/图纸/A3-01.pdf。这里真正有意义的不是“项目资料”这几个字而是后面的相对路径。上传时要把第一层目录名去掉保留图纸/A3-01.pdf否则服务端很难判断哪个目录名才是真正的根节点。我在第一版就踩过这个坑直接把webkitRelativePath当作相对路径存库结果换一台机器、换一个目录名同一批文件的存储路径就全变了。另一个麻烦是文件数量带来的状态管理问题。单文件上传失败了大不了重传一次文件夹上传呢可能第300个文件传了一半断网重启你要知道前299个文件里哪些成功了、哪些没成功、断在哪个文件的哪个分片。没有一套任务状态记录机制这种场景根本没法收场。1.2 高安全内网环境给传输链路增加的约束这类项目里网络链路不能当普通局域网看。很多内部网络有安全网关或审计设备串在链路上数据要走完一层层检查才会到达应用服务器。表现出来就是延迟不稳定、突发性丢包、带宽被非业务流量挤占。更麻烦的是会话超时往往比公网产品短一个大文件传着传着会话失效后面的分片全部被拒。服务器侧也有看不见的消耗。大量小文件同时落盘会让杀毒软件和文件审计组件进入高负荷状态表现为写入速度肉眼可见地下降。如果后端还跑了实时扫描文件刚写进去就被锁住合并阶段再去读就会报“文件被占用”。这些约束决定了不能按照“理想内网环境”去设计必须默认网络随时可能抽风默认服务器性能会被其他安全组件吃掉一部分。2. 技术选型对比整包上传、控件上传、分片上传为什么我选分片2.1 三条路线的横向对比在真正动手前我先把市面上几种主流方案摆在桌面上做了个对比。下面是实际评估表里面放的是我在这类项目里实实在在考虑过的因素方案优点缺点适用场景整包上传HttpPostedFileBase / IFormFile 直接收文件开发量最小前端一个input就能搞定IIS和Kestrel都有请求体大小限制网络抖动直接超时没有断点续传失败只能全部重来小文件、内网稳定、对可靠性要求不高的系统浏览器插件/桌面控件上传能直接操作本地磁盘读取路径能力强可以做到非常细的控制目标机器要装客户端浏览器兼容性差涉及内网安装软件的流程合规成本极高版本更新麻烦旧系统改造、且允许强制安装客户端的场景前端分片上传 服务端合并浏览器原生支持天然支持断点续传并发度和分片大小可控失败局部重试开发复杂度明显上升前后端要配合设计状态管理大文件、文件数量多、网络链路不稳定、对审计有要求的环境军工类项目最后基本都会落到第三类方案上。原因很简单业务方既不想给每个用户工作站装额外组件又希望在恶劣网络条件下能把几百GB的目录传完。除了分片上传没有别的路能把这两点同时满足。2.2 分片粒度和并发度的理论依据选分片方案后紧接着就是两个参数单片多大、并发几个。这个没有统一答案跟网络环境和文件大小分布强相关。分片太大网络一抖动一个分片重传的成本就会变得很高分片太小每个分片都要走一遍HTTP握手、服务端创建文件句柄、写磁盘、返回响应大量时间花在“非数据传输”上。我在内网环境里测过1MB以下的分片会让整体吞吐急剧下降因为小请求占用的服务端资源比例太高了。经验值是这样网络比较稳定的千兆内网单片可以取10MB到20MB弱网、要过安全网关、延迟不稳定的环境单片取2MB到5MB比较稳妥。并发度也不是越大越好我试过把并发调到10以上服务端磁盘IO先开始排队响应时间变长反而触发前端超时重试。一般建议客户端并发控制在3到6个之间先小后大根据实测失败率动态调整。2.3 为什么没选HTML5拖拽直接传原文件HTML5拖拽上传拿到File对象后虽然也能做进度条但本质上还是每个文件一个普通上传请求。它有两个问题一是没有断点续传能力二是没有服务器端临时目录和合并校验的配合。甲方明确要求“传一半断了重新开机能接着传”这就必须有分片级的确认和记录机制。原生拖拽上传帮不了忙只能自己造轮子。3. 前端实现目录遍历、分片调度、断点续传与进度上报3.1 目录遍历和相对路径的保留目录选择用input typefile webkitdirectory这是目前浏览器里比较通用的方式不需要任何插件。用户选中目录后通过event.target.files能拿到目录下的所有文件并且每个文件都带有webkitRelativePath属性。处理相对路径时我的做法是先把最外层目录剥掉。比如用户选中的目录名是“测试资料”那么一个文件可能叫测试资料/子目录/abc.pdf。服务端不关心外层到底是“测试资料”还是“别的名字”只关心子目录/abc.pdf这一段。所以前端要把第一个/之前的内容去掉再连同文件大小、文件哈希、分片信息一起放到上传清单里。3.2 分片与任务队列分片部分代码不复杂核心就是File.prototype.slice。下面是一个很基础的分片实现const CHUNK_SIZE 5 * 1024 * 1024; // 5MB一个分片 function buildChunks(file) { const chunks []; const total Math.ceil(file.size / CHUNK_SIZE); for (let i 0; i total; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ index: i, blob: file.slice(start, end) }); } return chunks; }实际跑起来之后才发现真正的工作量不在切片而在调度。文件数量一多不能一股脑把所有分片全部Promise.all并发出去不然几十个文件、几百个分片同时打过来服务端先撑不住浏览器也撑不住。我改成维护一个任务队列固定开几个Worker循环取任务async function runWorker(queue, concurrency) { const workers []; for (let i 0; i concurrency; i) { workers.push((async () { while (queue.length 0) { const task queue.shift(); await uploadChunk(task); } })()); } await Promise.all(workers); }这个做法的好处是任何一个分片失败都只会影响它自己不会把整个上传批次带崩。失败的分片可以塞回队列重新上传而已经完成的其他分片不需要动。3.3 断点续传的状态记录断点续传的关键是本地状态记录。我用localStorage保存一个上传批次对象里面记录每个文件的相对路径、文件大小、分片总数、已经上传成功哪些分片。重新打开页面、重新选择同一个目录时先读取本地记录服务端再返回已接收分片列表两者一对比能跳过的分片直接跳过只传缺失的部分。记录结构大概是这样的{ uploadId: b3a2e5e0-8f40-4f1c-9b92-3e5cc1d2e7a1, files: [ { relativePath: 图纸/A3-01.pdf, size: 104857600, hash: e3b0c44298fc1c149afbf4c8996fb924..., totalChunks: 20, uploadedChunks: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] } ] }上传之前先调用服务端的“检查已上传分片”接口用Set把返回的已存在分片过滤掉。上传一个分片成功后立即更新uploadedChunks。这样哪怕整个浏览器崩溃重新打开后依然能从断的地方继续。3.4 进度计算的两种口径界面上要给用户两个进度一个是文件级别的比如“12/300个文件”另一个是字节级别的比如“29.3%”。文件级别的意思是完成上传的文件占文件总数比例字节级别的意思是所有文件里真正传到服务器的字节比例。只显示字节进度其实是不够的因为大量小文件场景下总字节可能没多少但文件数量很多用户看到进度条一直不走会以为卡死了。两个口径都展示再配上当前正在上传的文件路径体验会好很多。4. 服务端接口设计分片接收、临时目录、合并与哈希校验4.1 分片接收接口的职责边界服务端我按三个接口来拆创建上传会话、接收分片、合并文件。这样每个接口只做一件事出了问题也容易定位。接收分片的接口核心逻辑是校验会话有效性、校验分片序号、把分片落盘到该会话专属的临时目录。分片文件命名直接用序号比如00000001.dat不要用原始文件名避免文件名里的特殊字符造成路径遍历问题。C#这边用的是ASP.NET Core Web API接收分片的代码大致是[HttpPost(/api/upload/chunk)] public async TaskIActionResult UploadChunk([FromForm] ChunkUploadRequest req) { if (!_sessionService.IsActive(req.UploadId)) return Unauthorized(上传会话不存在或已过期); var sessionDir Path.Combine(_tempRoot, req.UploadId, chunks); Directory.CreateDirectory(sessionDir); var chunkPath Path.Combine(sessionDir, ${req.ChunkIndex:D8}.dat); await using var stream new FileStream( chunkPath, FileMode.Create, FileAccess.Write, FileShare.None, 4 * 1024, FileOptions.Asynchronous); await req.File.CopyToAsync(stream); return Ok(new { received true, nextIndex req.ChunkIndex 1 }); }这里有几个细节值得说明。FileShare.None保证同一时刻只有一个进程写这个分片FileOptions.Asynchronous让写入走异步IO避免阻塞线程池缓冲区用4KB到8KB就够不用设太大。这些参数在代码审查时经常被问也是实际压测里能拉开性能差距的地方。4.2 合并接口的工程顺序所有分片上传完成后前端调用合并接口。合并不是简单地把分片拼在一起完整的工程顺序应该是服务端读取该批次的清单文件检查每个文件的每个分片是否都存在。按分片序号顺序把所有分片内容写入目标文件。合并完成后计算目标文件的SHA256哈希。将哈希值跟前端上传前算好的哈希比对。比对通过把文件从临时目录移动到正式目录并写入业务数据库。清理临时分片文件和本地状态记录。合并的核心代码很直白await using var output new FileStream( targetPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 * 1024 * 1024, FileOptions.Asynchronous); for (var i 0; i totalChunks; i) { var chunkPath Path.Combine(sessionDir, ${i:D8}.dat); await using var chunk new FileStream( chunkPath, FileMode.Open, FileAccess.Read, FileShare.Read, 1 * 1024 * 1024, FileOptions.Asynchronous); await chunk.CopyToAsync(output); }FileMode.CreateNew很重要一旦发现目标文件已经存在就报错可以防止重复合并覆盖已经上传好的文件。4.3 为什么“先合并后校验”而不是“边收边写”我见过有同事设计成“每个分片收下来就直接追加写目标文件”这样省掉临时目录和二次拷贝看似效率更高。但问题在于你永远不知道当前这批分片是不是最终版本中间某个分片可能因为网络原因损坏了目标文件已经被写进去一半这时候要么把目标文件删了重来要么要记录“哪个偏移量是坏的”复杂度暴涨。先落地分片、全部校验通过再触发合并等于在“接收”和“入库”之间放了一个校验点。哪怕某个分片坏了最多重传这一个分片目标文件还没开始写不存在半成品状态。代价是临时目录需要额外磁盘空间我一般按预计上传总量的1.5倍预留这部分上线前会跟运维明确沟通。4.4 秒传与索引复用在涉密要求高的环境里同一份文件经常被不同用户反复上传比如标准文档、制度模板。如果每次都真的传一遍既浪费带宽又浪费存储。我在设计方案时加了一个“秒传”逻辑前端计算文件SHA256后上报服务端在哈希索引表里查如果存在相同哈希且来源可信的文件直接返回“秒传成功”然后把文件路径映射到本次业务的目录中。哈希索引表只存文件哈希、文件大小、首次上传时间、归属业务目录不存全文内容。命中秒传时写一条审计记录确保将来追溯时知道这个文件被哪个用户、哪个批次复用。这样一来重复文件连一个字节都不用传在大批量同质化文件上传的场景下收益非常可观。5. 极端情况下的稳定性兜底并发控制、回滚与集群部署5.1 同批次并发过多导致服务端IO打满把并发度全交给客户端是不可靠的因为用户可能同时打开几个浏览器标签页操作或者一个页面里好几个上传组件同时跑。服务端必须对同一个上传会话的并发分片数量做限制。我在服务端用SemaphoreSlim控制同一个UploadId同时处理的分片请求数量超过限制的请求先排队等待。C#代码大概是这样private static readonly ConcurrentDictionarystring, SemaphoreSlim SessionLocks new(); public async TaskIActionResult UploadChunk(ChunkUploadRequest req) { var semaphore SessionLocks.GetOrAdd(req.UploadId, _ new SemaphoreSlim(4, 4)); await semaphore.WaitAsync(); try { // 落盘逻辑 } finally { semaphore.Release(); } }客户端并发、服务端并发两层限制配合起来才能保证一个用户的操作不会影响同一个服务器上其他业务的响应速度。5.2 合并失败的自动回滚合并过程如果中途进程崩溃最怕的是临时目录和正式目录两边各有一半文件。我在数据库里维护一张upload_session表字段至少包括批次ID、状态、创建时间、文件总大小、已完成大小、最后更新时间。状态机设计成三个状态Uploading、Merging、Completed。每个分片接收成功后更新已完成大小合并开始前把状态改成Merging合并完成且哈希校验通过后改成Completed。后台有一个定时任务每10分钟扫描一次状态为Merging但最后更新时间超过30分钟的会话判定为异常清理该会话的临时目录并把状态改回Failed允许用户重新触发。这样设计的好处是任何一个环节异常都不会留下“看起来正常但实际不可用”的半成品文件。哪怕进程崩溃重启后后台任务会自动打扫战场。5.3 多节点部署时临时目录的路由策略如果系统是负载均衡多节点部署每个节点的本地磁盘互不可见就会出现一个很隐蔽的问题前面10个分片落在节点A后面10个分片落在节点B合并时无论去A还是去B都找不到完整数据。我当时的处理是先把临时目录放到共享存储上所有节点都能访问。如果架构上实在没有共享存储那就必须让同一上传批次的所有请求都路由到同一个后端节点。反向代理层按UploadId做一致性哈希路由保证同一个批次的请求落在同一台机器。这个问题如果上线前不考虑压测时才会被分片404折磨得怀疑人生。5.4 磁盘空间与临时文件清理分片落盘会产生大量小文件一个1GB的文件切20个5MB分片每个分片单独一个文件。上传完成后合并合并完应立刻把分片目录删掉。如果用户传了一半就放弃这些临时分片会永远躺在磁盘上占空间。我写了一个后台清理任务扫描临时目录下所有会话文件夹删除超过24小时且状态不是Uploading的目录对于一直处于Uploading但超过48小时没有新分片写入的会话也直接清理。临时目录所在磁盘应设置监控使用率超过80%时主动告警防止磁盘写满导致整个系统不可用。6. 审计合规与安全扫描在效率和安全之间找平衡6.1 服务端二次校验文件扩展名和内容前端限制可选文件类型只是体验优化不能当安全措施。服务端必须对每个文件做扩展名白名单校验否则随手改个扩展名就能绕过去。白名单用HashSet维护每次接收分片时校验一次合并时再校验一次private static readonly HashSetstring AllowedExtensions new(StringComparer.OrdinalIgnoreCase) { .pdf, .docx, .xlsx, .jpg, .png, .rar, .zip }; if (!AllowedExtensions.Contains(Path.GetExtension(req.FileName))) return BadRequest(文件类型不允许上传);扩展名校验之外至少对文件头做一次魔数校验。比如PDF文件的头部固定是%PDFJPEG的头部是FF D8 FF。魔数校验能挡住那种“把脚本改名为.jpg”的低级绕过。6.2 上传前后扫描的时机选择在分片阶段做全量杀毒扫描是不现实的每个分片5MB一个100GB的目录就是2万个分片每个分片都丢给杀毒引擎扫一遍服务端的性能会直接崩掉。更合理的做法是分两个阶段合并前对每个文件的文件头做抽样检查主要确认文件类型和魔数一致合并后对完整文件做一次全量扫描。全量扫描很耗时是上传流程里不可压缩的一部分。在界面上要明确告诉用户“文件已上传完成正在做安全检查”否则用户看到上传进度到达100%却迟迟等不到入库完成的提示会误以为系统卡死。文件较大的时候全量扫描消耗的时间可能比传输时间还长这个预期必须在需求评审阶段就跟业务方对齐。6.3 审计日志应该覆盖哪些信息审计日志至少要包含这样几项操作账号、上传批次ID、文件相对路径、最终存储路径、文件大小、SHA256哈希、上传开始时间、上传结束时间、合并时间、来源IP。甚至还包括秒传命中记录。数据库里建议把审计表和上传业务表分开存放因为业务表可能因为重新组织而清理审计表则不能随便删。我在项目里把这些日志写到一个单独的upload_audit_log表并将表空间设置为禁止应用层DELETE。这不是为了防内部攻击而是为了满足“出现安全事件后能完整追溯”的底线要求。6.4 会话隔离与权限校验上传开始前创建会话时服务端必须把用户身份、允许写入的目标目录绑定到会话上。之后每一个分片请求都要重新校验“这个用户是否仍对该目录有写权限”。因为权限状态是可能实时变更的不能只在建会话时校验一次。这样设计能防止一种典型问题用户开始上传一个几百GB的目录传到一半管理员收回了这个用户对该目录的写权限但分片还在继续写入。虽然不太可能造成恶意破坏但确实是不该出现的逻辑漏洞。7. 实测数据与调优过程从几百个小文件到几GB大文件的表现7.1 默认配置下的两个直接教训第一次联调时我还没接到后端就发现大文件直接报413错误。查了半天发现IIS的maxAllowedContentLength默认值只有约30MB。解决办法是在web.config里调大security requestFiltering requestLimits maxAllowedContentLength1048576000 / /requestFiltering /security这里设的是1GB如果单文件最大超过1GB再对应调整。同时ASP.NET Core进程内Kestrel也有自己的请求体大小限制默认同样是30MB左右需要配置builder.Services.ConfigureKestrelServerOptions(options { options.Limits.MaxRequestBodySize 1_073_741_824; // 1GB });这两个地方任何一个漏掉都会出现“前端显示上传成功、后端却没收到数据”的诡异现象而且日志里往往只有一条不太起眼的413。7.2 弱网环境下的重试与超时设计前端用fetch上传分片时默认是没有超时时间的某个请求会一直挂在那里。在网络抖动场景下一个分片卡住可能十几分钟没反应用户只能干等着。必须用AbortController给每个分片请求加超时async function uploadChunk(task, timeout 60000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); try { const form new FormData(); form.append(uploadId, task.uploadId); form.append(chunkIndex, task.index); form.append(file, task.blob, task.fileName); const resp await fetch(/api/upload/chunk, { method: POST, body: form, signal: controller.signal }); if (!resp.ok) throw new Error(HTTP ${resp.status}); } finally { clearTimeout(timer); } }超时后的重试不能立刻重试我按指数退避来第一次间隔1秒第二次2秒第三次4秒最多加到8秒。超过5次重试仍然失败就把这个分片标记为failed先绕过去等所有分片传完后再集中重试。7.3 分片大小、并发数与实际吞吐的数据我搭了一套模拟环境用工具模拟“百兆局域网 安全网关过滤”的网络特征分别测了不同分片大小和并发组合的表现分片大小客户端并发平均吞吐分片失败率现象5MB211 MB/s0.5%稳定但吞吐偏低5MB518 MB/s1.8%吞吐最高失败率略高20MB515 MB/s2.1%单体吞吐高失败重传代价大2MB516 MB/s0.3%失败率低但HTTP请求数多最后我选了5MB分片、客户端并发3作为默认配置在一个比较稳妥的位置吞吐损失不大失败率和重试压力都可控。用户在弱网环境里我会把并发降到2。这套参数在后续几次真实批量上传中表现稳定。7.4 大量小文件场景的优化项目里经常出现一个文件夹里几千个小文件每个只有几十KB。如果这些文件也走切片流程每个文件至少产生一个“创建会话-检查分片-上传-合并”的完整事务开销反而比直接传大文件还大。后来我加了自适应逻辑文件大小大于8MB才切片小于8MB的整个文件一次上传。这样既保住了大文件的断点续传能力又没有被小文件的数量拖垮。还有一个经验是给队列加优先级小文件优先上传大文件排后面。因为小文件数量多、单个体积小先把它们清空用户看到文件进度数字在快速前进体验会更好。大文件卡在网络传输阶段时小文件还可以见缝插针地穿插上传整体利用率更高。7.5 弱网压测里的一个意外第一版代码在模拟“5%丢包 延迟50ms”环境里压测时表现非常难看1000个文件传了大半天还在原地打转。问题不在分片逻辑而是fetch请求在丢包时长时间不返回任务队列被一批“挂死”的请求堵住了。加了AbortController超时机制和指数退避重试之后同样条件下1000个文件的目录上传时间降到了12分钟左右。这个测试给我提了个醒在弱网场景里宁可牺牲一点吞吐去换确定性。一个60秒超时、自动重试的分片远比一个“可能成功也可能永远Pending”的请求要可靠。上传链路最忌讳的就是没有退出机制的死等。结尾一点个人体会文件夹上传这种功能网上现成组件不少但真要在军工类项目这种对稳定性、审计、网络约束都有硬性要求的环境里落地还是要回到最基础的分片、断点续传、状态管理和校验这几件事上。我个人的体会是不要指望一个控件解决所有问题更不要把整个上传逻辑全塞进Controller里跑。把上传拆成“客户端调度、服务端暂存、合并校验、审计记录”四个独立环节每个环节只做一件事后面出问题都好查。另外一个容易被忽视的点是上线前一定要在弱网和丢包环境下做压测而不是只在办公室千兆网里点两下觉得“很快”就算完了。上传链路往往是系统里最敏感、最容易出问题的部分值得多花点时间打磨。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑