C#.NET大文件夹上传方案:分片上传与断点续传实战
直接切入正题。我接手过一个内部系统需求描述很简短“用户要把整个项目目录拖进网页里传上去服务器上要能看到一模一样的文件夹结构。” 听起来跟平时传几个文件没啥区别但做过上传功能的人都知道这句话背后全是坑。文件一多、一大浏览器和服务器之间那些平时被隐藏的边界条件就全冒出来了每个都能卡你好几天。先说结论C#.NET后端可以做整套方案前端插件我最后没用现成组件而是自己封装了一个原生JavaScript插件。原因后面详细说但核心判断是——大文件夹上传的关键不在“上传文件”本身而在“传输协议的设计”协议理清楚了代码反而简单。这篇文章适合正在做B端管理系统、网盘类应用、项目管理后台的开发者尤其是被甲方要求“支持整个文件夹上传”但又不知道从何下手的同学。我会把目录结构保留的方案、前后端接口设计、C#.NET后端处理大文件流式写入的实现以及我在实际项目中踩过的坑全部整理出来。1. 为什么大文件夹上传是个必须重新设计的事1.1 传统文件上传的天然瓶颈传统input typefile multiple只能让用户逐个选文件压根不支持选文件夹。就算你用webkitdirectory让用户选中了整个目录浏览器默认行为依然是把目录里的所有文件一次性拼到一个multipart/form-data请求里发出去。一个2GB的文件夹几万个文件一次性提交结果通常是下面这三个中的一个请求直接被拒因为服务器、中间件、网关层层都设了体积上限IIS的maxAllowedContentLength、Kestrel的MaxRequestBodySize、Nginx的client_max_body_size请求勉强发了但传到一半网络抖动一下整个请求失败用户从头再来服务器端一次性接收几GB数据内存直接上天临时目录磁盘被打爆。所以大文件夹上传的第一条原则就是不要尝试在一个HTTP请求里搞定所有文件。把文件夹拆成单个文件逐个上传最后在服务端把目录结构重建出来。这条原则听起来简单但它是后面所有设计的地基。1.2 拆成单文件上传后真正的难点浮出水面拆完以后新的问题来了单个文件上传和普通上传没什么区别但“目录结构怎么保存下来”成了全新的挑战。浏览器端File对象可以从webkitRelativePath拿到文件在用户选中文件夹里的相对路径比如src/utils/helper.cs这个信息非常关键但默认的上传组件不会替你处理它。如果不主动把相对路径传回服务器服务端收到的就是一坨平铺的流根本不知道哪个文件该放哪个目录。所以整套方案的核心就是要构建一个“元数据 文件二进制”分离的上传协议——我说的分离不是说必须分成两个请求而是逻辑上你必须明确区分这两类信息的处理方式。2. 前端插件选型与整体方案设计2.1 为什么不用现成组件而是自己封装写这个功能前我调研过不少插件WebUploader、FineUploader、Dropzone这类都仔细看过。它们处理普通文件上传确实成熟但放到大文件夹场景下有几个问题很难绕开目录结构不是一等公民。多数组件的上传单位是“文件”拿到文件夹后会平铺成一个文件数组原始目录层级直接丢失。你需要自己额外记录每个文件的相对路径再走自定义参数传后端这等于组件只帮了一半忙另一半还是得自己写。组件配置的重心不对。通用组件把精力放在多选、拖拽、缩略图、剪裁这些特性上而大文件夹场景真正需要的是并发控制、错误重试、断点续传、内存占用控制这些硬核能力组件默认策略往往不适合混合大小文件混传的负载形态。跟C#.NET后端的协议对齐成本高。现成组件一般面向通用后端鉴权Header、错误码格式、分片协议都要自己适配有时候为对齐协议写胶水代码的时间比自己写的核心还长。所以我最后选择自己封装一个前端插件只留最核心的目录遍历、分片上传、并发控制、失败重试四个模块。代码量不大但每一行都在为后端的实现服务前后端像齿轮一样咬合得很舒服。2.2 整体架构一个上传任务如何组织整个体系分五层层职责核心模块前端交互层拖拽/点击选择文件夹展示任务进度插件入口、UI控制前端遍历层读取目录树提取文件与相对路径webkitdirectory递归遍历前端传输层分片读取文件上传二进制块控制并发分片器、并发队列、重试器后端接收层接收元数据与二进制块校验权限与合法性.NET Web API Controller后端落盘层创建目录结构流式写入文件目录安全模块、文件流写入服务每层之间通过一个自定义协议连接。前端先发送一个“创建上传任务”的请求把整个目录的元数据每个文件的相对路径、大小、最后修改时间一次性上报给后端拿到任务ID然后前端再对每个文件分别执行上传。这里有个设计决策值得多说一句元数据全部先上报而不是随每个文件上传时带一部分。为什么因为后端可以先在数据库或内存里建一棵“预期目录树”上传过程中随时可以对比实际到达的文件还能在全部完成后做校验——这比边传边建目录更容易做丢失检测尤其文件数上万时这方面的体验很重要。2.3 目录结构保留的三种实现路径对比方案前端做法后端做法优缺点A. 扁平上传 文件名带路径把相对路径拼到上传文件名里如uploadId_src_utils_helper.cs拆解名字恢复路径简单但命名规则脆弱文件名一长就有兼容风险B. 单文件附带元数据JSON每个文件上传时在multipart表单中额外携带relativePath字段从form字段读路径创建目录后写入直观且稳定我采用的方案C. 任务级目录树 单文件引用先提交整棵目录树元数据后端返回每个文件的存储ID上传文件时只带存储ID根据ID在预期树中定位目标目录鲁棒性最强适合超大文件夹但实现复杂度最高方案C在跨界场景文件数超过10万、需要秒传/断点续传/服务端校验时是首选但它要求你把“任务”做成完整状态机。我这次的项目文件量级在一两万文件、总大小几GB最终采用了方案B的增强版——也就是先建任务绑定目录树同时每个文件上传依旧携带相对路径。实际跑下来兼顾了可靠性和实现效率。3. C#.NET后端的核心实现细节3.1 Controller层如何优雅接收大文件流后端我用的是ASP.NET Core 6 Web API。接收单文件最标准的做法是用IFormFile但是注意IFormFile会把整个请求体缓冲到内存或临时文件对几十GB、上万文件的场景压力很大。更推荐的做法是直接用Request.Body作为流来读取配合multipart/form-data的边界解析把文件内容直接流式写入最终目标文件。核心Controller代码长这样[HttpPost(upload/{uploadId})] [RequestSizeLimit(200 * 1024 * 1024)] // 单文件最大200MB按需调整 public async TaskIActionResult UploadFile(string uploadId, CancellationToken ct) { // 从multipart中解析出业务字段和文件流 var formModel await Request.ReadFormAsync(ct); var relativePath formModel[relativePath].ToString(); var file formModel.Files[file]; if (string.IsNullOrWhiteSpace(relativePath) || file null) return BadRequest(new { code 40001, msg 缺少相对路径或文件流 }); var taskInfo await _uploadTaskRepo.GetAsync(uploadId, ct); if (taskInfo null) return NotFound(new { code 40401, msg 上传任务不存在 }); // 安全校验相对路径防止目录穿越 if (!PathUtil.IsSafeRelativePath(relativePath)) return BadRequest(new { code 40002, msg 非法路径 }); var rootPath Path.Combine(taskInfo.StorageRoot, uploadId); var fullDir Path.GetDirectoryName(Path.Combine(rootPath, relativePath)); Directory.CreateDirectory(fullDir!); var fullPath Path.Combine(rootPath, relativePath); await using var targetStream new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); await file.CopyToAsync(targetStream, 81920, ct); await _uploadTaskRepo.MarkFileAsUploadedAsync(uploadId, relativePath, ct); return Ok(new { code 0, relativePath }); }几个容易被忽略但特别关键的细节RequestSizeLimit只对单文件请求生效。别把它当成全局上限这个特性在设计上是针对某个Action的。全局上限要单独配置Kestrel的MaxRequestBodySize。FileShare.None保证同一路径不会同时被两个请求写入避免并发写同一文件导致文件损坏。Directory.CreateDirectory是幂等的重复调用不会报错所以多文件并发上传时不需要加锁直接创建目录即可。用FileStream时要传useAsync: true不然磁盘IO在异步代码里会塞线程池的线程并发一高性能直接崩。3.2 路径安全这是最容易翻车的点相对路径来自用户的输入理论上可以传../../etc/passwd之类的字符串。虽然目标环境是Windows Server但路径穿越攻击的破坏力是一样的。一定要做两层校验。第一层是纯字符串校验拒绝所有包含..、空字符串、绝对路径、非法字符的路径public static bool IsSafeRelativePath(string relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) return false; if (Path.IsPathRooted(relativePath)) return false; if (relativePath.Contains(..)) return false; if (relativePath.IndexOfAny(Path.GetInvalidPathChars()) 0) return false; // 额外兜底规范化后再算一次真实相对路径 var fullPath Path.GetFullPath(relativePath); var rootFull Path.GetFullPath(.); return fullPath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase); }第二层是在实际拼接路径后用Path.GetFullPath再次判断最终落盘路径是否在存储根目录之内。这层是防..被编码绕过比如%2e%2e时的最后一道关。我在项目里遇到过一个很隐蔽的坑文件名里如果包含:号或者超长路径在Windows的NTFS下可能导致创建失败。遍历前端目录时有些用户的文件夹命名不规范带个空格、带个中文Path.GetInvalidFileNameChars校验也要加到后端拒绝那些无法在文件系统落地的文件。3.3 并发上传与磁盘写入策略一万个文件串行上传显然不现实但并发太高也会出问题。这里的关键不是“并发多大”而是“磁盘吞吐多大”。我当时的服务器是4核8GB的云主机后端并发数调到5到8之间前端并发也控制在这个量级。实测下来混传体积从几KB到几百MB的文件磁盘IO基本能保持稳定。后端的写磁盘策略我用了三层小文件直接流入FileStream所有小于8MB的文件用常规流式写入延迟低、代码简单。大文件的分片缓冲超过8MB的文件前端会按每个分片8MB进行切片上传见第4节后端每个分片独立接收写完后累加文件大小校验。异步写、同步计数每个文件完成后更新数据库里的“该任务已上传文件数”和“字节数”每50个文件或每1秒批量提交一次避免频繁更新数据库把负载打高。另外强烈建议启用ResponseCompression以及数据库写入的批量提交我遇到过上传任务跑到一半数据库连接数飙满的情况根因就是每传一个文件就更新一次记录一万个文件就是一万次数据库往返加上并发根本扛不住后来改成批量合并提交才稳定下来。3.4 服务器配置的关键上限这一块很多人写完代码就忘了调导致下载FileStream都能跑通的代码一上线就报413。我的项目踩了三个配置坑逐个调整后才稳定配置项默认值我的设置说明Kestrel MaxRequestBodySize30MB左右512MB分片后单请求最大8MB给足余量IIS maxAllowedContentLength30000000字节524288000字节IIS层限制比Kestrel更早生效IIS requestTimeout默认约2分钟30分钟大文件上传必须延长Nginx client_max_body_size1MB512MB如果前面还有Nginx这层限制优先级最高注意如果前端存在Nginx后面Nginx的超时时间也要同步调。proxy_read_timeout至少设置到300s不然流式上传请求超过默认60s会被Nginx掐断。另外还有一个隐性问题IIS应用池回收。文件传一半应用池回收会导致临时目录里所有半成品文件全部丢失。解决方案是要么设置应用池空闲超时时间为0生产环境要评估要么用“任务表 磁盘文件双状态”做断点续传的数据基础我后来选择了后者代码上多写了一些状态恢复逻辑但给用户带来的体验提升非常值。4. 大文件分片上传与断点续传的落地4.1 分片协议让前端和后端对同一片数据有共识当单个文件超过设定阈值我设的8MB时前端会把它切成多个分片。每个分片的上传请求里必须带上这些参数参数示例说明uploadIda3f2-4d31...任务ID绑定目录树和存储根目录relativePathdata/2024/asset.zip目标相对路径chunkIndex0第几个分片从0开始chunkTotal35总分片数fileSize279381242整个文件的字节数chunkSize8388608分片大小固定8MB后端收到分片后不写同一个FileStream而是写到独立的临时分片文件。所有分片传完后前端发一个“合并”请求[HttpPost(merge/{uploadId})] public async TaskIActionResult MergeFile(string uploadId, [FromBody] MergeRequest req, CancellationToken ct) { var chunkDir Path.Combine(_opts.StorageRoot, uploadId, .chunks, PathUtil.HashPath(req.RelativePath)); var targetPath Path.Combine(_opts.StorageRoot, uploadId, req.RelativePath); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); await using var target new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); for (int i 0; i req.ChunkTotal; i) { var chunkPath Path.Combine(chunkDir, ${i}.part); await using var chunk new FileStream(chunkPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true); await chunk.CopyToAsync(target, 81920, ct); File.Delete(chunkPath); } // 可选校验文件总大小是否与预期一致 var actualSize new FileInfo(targetPath).Length; if (actualSize ! req.FileSize) return BadRequest(new { code 40003, msg 合并后大小不一致文件可能已损坏 }); return Ok(new { code 0 }); }合并时顺序拼接分片实现有两个额外好处一是临时分片目录下有每个分片独立存在任何一个分片损坏都不会污染其他分片二是合并过程遇到中途失败断点续传时只需重传缺失的分片不需要整个文件重来。4.2 分片大小怎么定我试过2MB、5MB、8MB、16MB。观察下来8MB是最平衡的——每个分片在公网上传到服务器耗时1到3秒用户体验上没有卡顿感同时分片数量不会爆炸一个2GB文件分256片任务元数据依然轻量。如果带宽很低比如1Mbps上行建议把分片降到2MB。分片太小会让HTTP请求数量急剧增加网络往返延迟会成为瓶颈分片太大会让单请求重试成本变高尤其弱网环境一个500KB的请求可能反复失败。4.3 前端并发控制与失败重试的朴素实现前端插件我参考了异步队列的经典模式。核心逻辑是维护一个并发池固定5个并发槽位每次从任务队列里拉一个文件或分片出来执行上传完成后再拉下一个。class ConcurrencyPool { constructor(limit, taskGenerator) { this.limit limit; this.taskGenerator taskGenerator; this.running 0; this.pending 0; } async start() { const workers Array.from({ length: this.limit }, () this.#worker()); await Promise.all(workers); } async #worker() { while (true) { const task this.taskGenerator.next(); if (task.done) break; await this.#executeWithRetry(task.value, 3); } } async #executeWithRetry(fn, retries) { for (let i 0; i retries; i) { try { await fn(); return; } catch (err) { if (i retries - 1) throw err; await new Promise(r setTimeout(r, 1000 * Math.pow(2, i))); console.warn(上传失败第${i 2}次重试中, err); } } } }细节在于失败重试应该以“分片”为单位而不是以“文件”为单位。一个文件传完一半网络断掉如果整个文件重传之前的分片全浪费了按分片重试只需要重新传当前失败的那一个分片。这个逻辑配合后端的“分片存在性检查”接口还能做秒传校验——如果服务器已有某分片前端直接跳过为断点续传和弱网重传省了大量流量。4.4 断点续传的用户体验设计状态都记录在任务表和分片目录里后断点续传就变成了一个数学问题前端再次发起上传时先向后端询问“这个上传任务的哪些分片已经存在”然后只传输缺失的部分。我设计了一个轻量的状态接口[HttpGet(status/{uploadId})] public async TaskIActionResult GetStatus(string uploadId) { var task await _uploadTaskRepo.GetAsync(uploadId); var finishedFiles await _uploadTaskRepo.GetFinishedFilesAsync(uploadId); return Ok(new { totalFiles task.TotalFileCount, uploadedFiles finishedFiles.Count, uploadedBytes task.TotalBytes, fileStatuses finishedFiles.Select(f new { f.RelativePath, f.Size }) }); }前端拿到fileStatuses后构建一个“已上传集合”遍历目录时直接跳过集合中已存在的文件。这样即使浏览器刷新、电脑重启、网络断掉一整天重新打开页面选择同一个文件夹都能从上次的位置继续而不是从头开始。这是整个方案里用户感知最强的功能之一投入产出比极高。5. 目录结构保留的完整数据流5.1 建立任务级目录树先固话预期结构前端遍历目录时我不仅收集了每个文件的相对路径还把文件夹本身也整理出来。比如项目根/ ├── src/ │ ├── api/ │ │ └── user.cs │ └── utils/ │ └── helper.cs └── docs/ └── readme.md前端遍历后生成一个扁平数组提交给后端{ uploadId: uuid-xxx, fileCount: 4, totalBytes: 123456789, files: [ { relativePath: src/api/user.cs, size: 2048 }, { relativePath: src/utils/helper.cs, size: 4096 }, { relativePath: docs/readme.md, size: 1024 } ] }后端收到后先把任务写入数据库同时根据relativePath中的目录部分在存储根目录下把所有文件夹预创建好。这样即使后续有文件缺失目录结构已经存在便于脚本去检查“空目录”。5.2 不用“扁平数组”而要“字典引用”实现时有个容易被忽视的性能点几万文件的数组在内存里做查找、去重时间复杂度可能很高。我前端在收集时就用Map以relativePath为key存储文件信息后端也用Dictionarystring, UploadFileInfo存储两边都用哈希结构做匹配文件数上万时性能几乎没区别。5.3 后端如何判断“目录树完整”当任务所有文件都标记为已上传后端触发一次完整性校验。此时存储根目录下已经有一棵真实的文件树。我写了一个函数递归遍历真实目录树同时跟数据库里的预期清单对比数据库存在但文件不存在的标记为“丢失”返回前端重新上传文件存在但数据库不存在的标记为“多余”可能是残留两边对上的标记为“完成”。校验结果返回给前端后前端展示一个非常直观的表格每个目录、每个文件的状态一目了然。这一步从用户视角看就是一个“上传完成”的确认但从系统角度看它是整个数据一致性的保障。6. 常见问题与排查技巧实录6.1 上传到一半报413 Request Entity Too Large这个问题的出现频率极高但99%不是后端代码的问题。按从外到内的顺序排查Nginxclient_max_body_size是否已调大IIS的maxAllowedContentLength是否已调整KestrelMaxRequestBodySize请求头中Content-Length超出限制的情况。我当时排查时发现是Nginx挡在前面客户端直接打到Nginxclient_max_body_size默认1MB后端全写了也没用。这个排查顺序务必记牢从最外层往最内层查。6.2 并发高时磁盘随机IO争抢激烈我最初把并发数调到20结果磁盘大量时间花在等待上整体吞吐反而比并发8还慢。优化方式很朴素使用单独的SSD盘存放上传临时目录对写入路径做分区隔离把并发细分到不同磁盘路径小文件和大文件使用不同的队列和线程池资源池避免大文件的长写入阻塞小文件。最终稳定在8并发平均吞吐比20并发时还高。6.3 文件名编码导致的路径问题用户的文件夹名可能有中文、日文、韩文甚至表情符号。multipart/form-data的字段编码默认是UTF-8前端设置好即可。但后端写文件时如果用系统默认编码Windows下可能是GBK文件名就会乱码。解决方式就是写文件路径时统一用Path.Combine不手动拼字符串并且确认DefaultRequestLanguage不影响底层文件API。这个需求下我最后把站点代码明确设置System.Text.Encoding.UTF8前端在FormData里全部用UTF-8字符串。6.4 文件数过多时前端遍历卡顿3万个文件时前端递归遍历目录会出现明显卡顿原因是UI线程被阻塞在递归逻辑里。解决方式是用requestIdleCallback分批遍历每处理100个文件就await一个宏任务setTimeout(0)让出渲染线程。体验上从“页面假死3秒”变成“进度条慢慢滚动”用户容忍度高很多。6.5 任务中断后临时文件残留断点续传会留下临时分片文件如果用户上传到一半彻底放弃这些文件会永远留在磁盘上。解决方案是维护一个“任务生命周期”每个任务有创建时间启动定时清理服务如每天凌晨清理超过24小时且状态不是“已完成”的任务目录临时分片目录也纳入清理范围。这个定时清理很重要否则时间久了磁盘会被碎片堆满。7. 我最后还想分享的几个体会整个方案从设计到落地前后四周左右。说几点实际体会第一目录结构保留的难点不在前端也不在后端而在协议设计。只要把“元数据上报、分片上传、状态查询、合并”这四个请求的字段定义清楚前后端各写各的反而不会出大问题。第二不要把并发数调太高。很多人觉得上传慢就是并发不够结果并发一高数据库连接、磁盘IO、内存全部报警。大文件夹上传的性能瓶颈几乎永远在磁盘IO或者带宽而不是CPU或代码。合理并发数是根据实测调出来的不是拍脑袋定的。第三断点续传和失败重试带来的用户满意度比花哨的进度条高得多。用户真正关心的是传到一半断了不用重来这个功能花两三天就能做好但价值巨大。第四如果以后要扩展到非常大的数据集比如10万以上文件数或者单个文件超过10GB建议把存储层换成对象存储后端只维护元数据和临时分片最终合并由对象存储服务端完成。这套方案的协议设计可以不做大的改动因为分片和任务状态管理的逻辑是通用的。这套方案目前已经稳定跑了半年多期间处理过最大的文件夹是约5万文件、总大小30GB没有出过一次需要人工介入的事故。如果你正在做类似需求建议先从最小的链路跑通——两个文件、一个子目录——把协议调顺再往上堆量效率会高很多。