恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C#大附件文件夹上传实战:分片上传与断点续传方案
首页
资讯中心
/
C#大附件文件夹上传实战:分片上传与断点续传方案
C#大附件文件夹上传实战:分片上传与断点续传方案
发布时间:2026/10/21 12:33:48
前阵子又有同事在技术群里问C#中怎样完成大附件文件夹上传到Web页面网上的答案要么是让人压缩成zip要么只讲单文件上传真遇到一千多个文件的文件夹就完全无从下手。这个问题我前后做过三版方案最后的思路特别明确不要试图把大附件一次性POST上去而是把文件夹拆成文件、把大文件拆成分片用可控并发的方式逐个传最后由服务端按顺序合并。这篇文章把这套方案的完整代码、接口设计和调参细节都写出来给正在做Web上传功能、被大文件超时和413折腾过的C#开发者参考。1. 卡点拆解大附件和文件夹上传到底难在哪里没实际做过大文件上传的人第一反应常常是“在页面上加一个大大的input file不就行了”。真正去传一个几百MB的文件夹时才会发现问题不是一个标签能解决的。HTTP协议本身不限制请求体大小但服务器、中间件、框架各自都有默认限制浏览器和用户也没办法接受长时间的无反馈等待。1.1 服务器对请求体积的三重限制C# Web项目最常见的部署方式就是Kestrel或者IIS这两层各自有独立的请求体大小限制。除此之外ASP.NET Core对表单里的multipart body也有一个上限。我整理了一张表方便你对照排查限制项默认值超出后的表现Kestrel MaxRequestBodySize约30MB413 Payload Too LargeIIS maxAllowedContentLength30000000字节约30MB404.13ASP.NET Core FormOptions.MultipartBodyLengthLimit约128MB413 Request Entity Too Large很多人只调了其中一个结果换到IIS部署后又开始报错就是因为漏掉了IIS那一层。Kestrel配置的是普通HTTP请求体IIS的maxAllowedContentLength是IIS请求筛选器层面的限制Kestrel允许了也过不了IIS这一关。哪怕你用Nginx做反代还有一个client_max_body_size等着你。1.2 内存、超时与用户体验是隐藏瓶颈就算你把这些限制全部调到2GB大文件上传也不会变好。原因很简单一个大请求体进入ASP.NET Core默认会先缓冲到内存超过阈值再落到磁盘临时文件。假设三五个用户同时传2GB文件即使没有触达请求体上限服务器的内存和磁盘IO也会先扛不住。超时是另一个隐蔽问题。整体上传一个1GB文件时浏览器fetch请求一直处于pending状态。如果中间隔了代理服务器、网关或者负载均衡任何一层的空闲超时时间到了都可能直接把连接断开。最麻烦的是客户端根本不知道是继续等待还是已经失败用户看到的只有个转圈图标。这种方案下进度条也没法做。浏览器虽然能拿到上传事件的progress但那是整个请求的进度对一个“把所有文件打包成一个请求”的方案来说进度粒度太粗用户无法知道还剩几个文件。文件夹场景比单文件更尴尬因为文件数量多任何一个文件卡住整批都会卡住。1.3 文件夹上传比单文件多出来的难题单文件上传只需要处理“一个流”文件夹上传则要把所有子目录结构、文件数量、总大小都考虑进去。拿HTML的input标签来说普通模式根本不支持选文件夹必须依赖webkitdirectory这一类非标准属性。拿到文件列表之后每个文件还带有一个类似images/2025/photo.jpg的相对路径如果不记录这个路径上传到服务器后所有文件就会平铺在一个目录里目录结构直接丢失。进度统计也更复杂。文件夹里可能有几百个小文件同时还有几个大视频文件。如果按文件为单位一个一个排队上传前面一个小文件传完了还要再等后面大文件慢慢传用户看到进度条停在45%不动会很焦虑。最合理的做法是把每个文件再切成更小的分片统一放进一个并发任务池里排队谁先传完谁先释放连接。2. 方案选型为什么分片上传是当前最稳的路线面对这个需求可选的路线其实不止一条。我的第一版方案是让用户选择文件夹之后前端用JS把整个目录里的文件打包成zip再上传后来发现这条路只适合小目录第二版是做一个C#桌面客户端功能能做得很完整但部署成本高。最终稳定使用的还是“浏览器端分片 Web API接收合并”。2.1 三种可落地方案的横向对比方案优点缺点适用场景整体压缩成zip上传实现简单一次请求服务端要解压、无法精确看进度、大压缩包仍会超限小目录备份C#桌面客户端上传可多线程、可全链路控制需要安装客户端、不是Web页面企业内网受控环境浏览器分片上传免安装、保留目录结构、可续传前端逻辑复杂、需要后端配合标准Web页面推荐如果是给外面用户用的Web系统浏览器分片是唯一不用让用户装额外软件、又能扛住大文件的方向。桌面客户端虽然能力强但“上传到Web页面”这个前提就把它排除了。压缩包方案看起来简单实际上是把你自己的问题转移给了服务器多个用户同时传大zip服务器解压的CPU开销和临时磁盘占用都很难看。2.2 分片大小、并发数怎么定选定了分片路线紧接着就是两个核心参数单片多大、同时传几片。我踩出来的经验是单片大小取5MB并发数取3到5。原因有三。第一大部分服务端默认请求体限制在30MB附近5MB一个分片留出了很大的余量不需要把Kestrel和IIS的限制改得太夸张。第二分片越小失败后重试的代价越低但请求数量会爆炸分片越大请求数量少但一次失败就要重传更多数据。5MB在大多数网络环境下是性价比比较高的值。第三并发数不是越多越好浏览器对同一域名有连接数限制服务端磁盘在多个分片同时写入时也会出现IO竞争。实测下来普通服务器并发开到5以上传输速度提升不明显反而会出现大量等待。一个比较稳妥的公式是单片大小 min(服务器单请求限制 / 2, 10MB)并发数 机器CPU核心数的2倍左右但最大不超过5。比如我把Kestrel限制设为10MB前端单片就是5MB正好能塞进限制里。2.3 整体架构与核心接口设计方案确定后整个系统的结构是前端页面普通HTML/JavaScript或者Vue/React都行负责选文件夹、切分片、并发上传后端是ASP.NET Core Web API负责接收分片、存临时文件、最后合并。服务端目录规划成两个区域_temp/{fileId}/{分片序号}存放未合并的临时分片files/{相对路径}合并后的最终文件接口我固定为四个后续所有扩展都围绕它们来做接口方法作用/api/upload/checkPOST查询秒传状态、已上传分片列表/api/upload/chunkPOST上传某一个分片/api/upload/completePOST全部结束后触发合并/api/upload/cancelPOST取消当前上传并清理临时分片后面所有断点续传、秒传、进度恢复都是在这四个接口之上加逻辑。3. 前端实现文件夹读取、分片切割与上传队列前端是这个方案里最容易出问题的环节。拿到文件夹、切分片本身不复杂难的是把几百个文件的上传任务组织起来既不让浏览器崩溃又要保证顺序和进度可追踪。3.1 拿到文件夹里的全部文件HTML里没有标准的文件夹选择属性目前实际可用的是webkitdirectory配合multiple一起使用input typefile idfolderPicker webkitdirectory multiple /选中文件夹后input的files属性会变成一个包含所有文件的FileList每个File对象上带一个webkitRelativePath就是文件相对所选文件夹的路径。遍历时这样处理document.getElementById(folderPicker).addEventListener(change, (e) { const rawFiles Array.from(e.target.files); const tasks rawFiles.map((file) { const relativePath (file.webkitRelativePath || file.name).replace(/\\/g, /); return { file, relativePath, fileId: simpleHash(relativePath file.size file.lastModified), }; }); console.log(共发现文件, tasks.length); });这段代码有两个细节值得注意。第一webkitRelativePath在Windows的Chrome上也有可能是反斜杠统一转成/后端合并时才好处理。第二fileId不能用随机UUID因为断点续传要求同一个文件下次打开时还能找到之前传过的分片。我用路径大小修改时间生成一个稳定哈希这样刷新页面后还能继续传。兼容性方面Chromium内核的浏览器Chrome、Edge体验最好。如果项目必须兼容特别老的浏览器只能在页面提示用户换浏览器或者退化到多选文件模式。3.2 用File.slice做分片拿到File对象后利用File.slice接口可以按字节把文件切成多个Blob。这个接口是流式的不会把整个文件一次性载入内存所以即使文件有几个GB内存也安全。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB function splitFile(task) { const { file, relativePath, fileId } task; if (file.size 0) { return [{ fileId, chunkIndex: 0, totalChunks: 1, relativePath, fileName: file.name, fileSize: 0, data: file, }]; } const chunks []; let offset 0; while (offset file.size) { const end Math.min(offset CHUNK_SIZE, file.size); chunks.push({ fileId, chunkIndex: chunks.length, totalChunks: Math.ceil(file.size / CHUNK_SIZE), relativePath, fileName: file.name, fileSize: file.size, data: file.slice(offset, end), }); offset end; } return chunks; }我加了一个小优化空文件也要生成一个分片否则complete合并时会出现0个分片判断逻辑容易写成死循环。正常文件切分后每个分片带上fileId和chunkIndex这两个字段就是整个系统的坐标。3.3 可控并发的上传队列把所有文件的分片混合在一起排成一个统一队列而不是“一个文件传完再传下一个”。这样大文件的尾部分片可以和小文件的分片交替传输进度条会更平滑。async function runWithConcurrency(tasks, limit, worker) { const results new Array(tasks.length); let cursor 0; async function next() { const index cursor; if (index tasks.length) return; results[index] await worker(tasks[index]); await next(); } const workers Array.from({ length: limit }, () next()); await Promise.all(workers); return results; }上传单个分片用fetch加FormDataasync function uploadChunk(chunk, signal) { const form new FormData(); form.append(fileId, chunk.fileId); form.append(chunkIndex, chunk.chunkIndex); form.append(totalChunks, chunk.totalChunks); form.append(relativePath, chunk.relativePath); form.append(fileName, chunk.fileName); form.append(fileSize, chunk.fileSize); form.append(chunk, chunk.data, chunk.fileName); const res await fetch(/api/upload/chunk, { method: POST, body: form, signal, }); if (!res.ok) throw new Error(分片 ${chunk.chunkIndex} 失败: ${res.status}); return res.json(); }上传开始前先调用check接口拿到已上传分片列表把这些分片直接跳过。上传期间把成功完成的Blob大小累加到uploadedBytes配合之前算好的totalBytes就能算出整体进度。4. 后端实现ASP.NET Core接收分片与合并前端负责拆后端负责收和拼。后端代码我放在一个Controller里核心就是三个接口接收分片、查询状态、合并文件。4.1 接收分片的Web API编写分片上传的请求模型长这样public class ChunkUploadModel { public string FileId { get; set; } ; public int ChunkIndex { get; set; } public int TotalChunks { get; set; } public string RelativePath { get; set; } ; public string FileName { get; set; } ; public long FileSize { get; set; } public IFormFile Chunk { get; set; } null!; }对应的Action[HttpPost(api/upload/chunk)] [RequestSizeLimit(10 * 1024 * 1024)] public async TaskIActionResult UploadChunk([FromForm] ChunkUploadModel model) { var tempDir Path.Combine(_storageRoot, _temp, model.FileId); Directory.CreateDirectory(tempDir); // 分片统一命名为 000000、000001方便按序号合并 var chunkName ${model.ChunkIndex:D6}; var chunkPath Path.Combine(tempDir, chunkName); await using (var fs new FileStream(chunkPath, FileMode.Create, FileAccess.Write)) { await model.Chunk.CopyToAsync(fs); } var received Directory.GetFiles(tempDir).Length; return Ok(new { received, total model.TotalChunks }); }这地方有两个关键选择。第一FileMode.Create表示同名分片重复上传时会覆盖而不是追加。如果前端因为网络闪断重发同一个分片覆盖是正确行为追加会导致文件损坏。第二分片文件名用D6格式化也就是000000、000001这种形式合并时按字符串排序就是正确的顺序不需要额外处理。4.2 加一个complete接口主动触发合并我最开始做第一版时是在“最后一个分片到达”时自动触发合并后来发现这个逻辑有竞态问题。前端并发上传多个分片网络里可能存在“后面的分片先到服务器”的情况。如果服务端看到分片数量够就立刻合并可能漏掉还在路上的其他分片合并出来的文件是坏的。更稳的做法是后端只负责接收和保存分片前端把所有分片都上传完成后主动调用一个complete接口服务端在这里校验分片完整性然后合并。[HttpPost(api/upload/complete)] public async TaskIActionResult Complete([FromBody] CompleteUploadModel model) { var tempDir Path.Combine(_storageRoot, _temp, model.FileId); if (!Directory.Exists(tempDir)) return BadRequest(未找到分片记录); // 读取已上传分片序号 var partFiles Directory.GetFiles(tempDir) .Select(Path.GetFileNameWithoutExtension) .Select(int.Parse) .OrderBy(x x) .ToArray(); // 检查数量和序号是否完整 bool complete partFiles.Length model.TotalChunks partFiles.SequenceEqual(Enumerable.Range(0, model.TotalChunks)); if (!complete) { return BadRequest(new { message 分片不完整请补传缺失分片, uploadedChunks partFiles }); } // 校验总大小 long totalSize partFiles.Sum(i new FileInfo(Path.Combine(tempDir, ${i:D6})).Length); if (totalSize ! model.FileSize) return BadRequest(分片大小与期望不符文件可能已损坏); var safePath ValidateRelativePath(model.RelativePath); var targetPath Path.Combine(_storageRoot, files, safePath); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); await using (var output new FileStream(targetPath, FileMode.Create, FileAccess.Write)) { for (var i 0; i model.TotalChunks; i) { var partPath Path.Combine(tempDir, ${i:D6}); await using var input new FileStream(partPath, FileMode.Open, FileAccess.Read); await input.CopyToAsync(output); } } Directory.Delete(tempDir, true); return Ok(new { path $/files/{safePath} }); }complete接口做了两件很多教程里没有强调的事一是校验分片序号是否连续二是校验全部分片总大小是否等于文件原始大小。这两步能提前发现传输过程中的丢片和坏块而不是等用户下载完成后才发现文件打不开。4.3 路径安全与临时文件清理RelativePath直接拼到文件路径里是有风险的用户传一个../../Windows/xx就能上传到意料之外的目录。必须在接收前端传过来的相对路径时做校验private string ValidateRelativePath(string relativePath) { var cleanPath relativePath.Replace(\\, /).TrimStart(/); var rootFull Path.GetFullPath(Path.Combine(_storageRoot, files)); var fullPath Path.GetFullPath(Path.Combine(rootFull, cleanPath)); if (!fullPath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException(非法的相对路径); return cleanPath; }临时分片如果上传失败会一直留在服务器上。我建议加一个后台任务扫描_temp目录清理最后写入时间超过24小时的目录。磁盘空间告急时这个清理任务比任何提示都管用。5. 服务端配置请求体限制、超时与流式读取代码写对了配置不对照样报错。很多人把分片方案搬回去后跑本地正常部署到服务器就413问题基本都出在服务端配置。5.1 Kestrel和IIS的限制到底怎么改在Program.cs里可以对Kestrel做全局配置builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 10 * 1024 * 1024; });如果部署在IIS还要改web.configsystem.webServer security requestFiltering requestLimits maxAllowedContentLength10485760 / /requestFiltering /security /system.webServer这里我故意没有把限制调成很大的值。分片方案的精髓是用小请求分流单片5MB单请求限制10MB就够用。调得越大越容易被一个异常请求打满内存得不偿失。如果前端忘了切分片直接把整个大文件POST上来限制小还能尽早报错提醒修复限制大了反而会慢慢拖死服务器。5.2 MultipartReader与流式读取什么时候用ASP.NET Core接收IFormFile时默认行为是把请求体缓冲到内存或磁盘临时文件。单片5MB场景下这点开销无所谓但如果某个接口必须接收特别大的分片或者你想彻底避免临时缓冲就要用MultipartReader做真正的流式读取。public static async Taskstring SaveRequestFileAsync(HttpRequest request, string targetPath) { var boundary request.ContentType.Split(;) .Select(x x.Trim()) .Where(x x.StartsWith(boundary)) .Select(x x.Substring(boundary.Length)) .FirstOrDefault(); if (string.IsNullOrEmpty(boundary)) throw new ArgumentException(请求缺少multipart boundary); var reader new MultipartReader(boundary, request.Body); var section await reader.ReadNextSectionAsync(); while (section ! null) { var disposition section.GetContentDispositionHeader(); if (disposition.IsFileDisposition()) { await using var fs new FileStream(targetPath, FileMode.Create); await section.Body.CopyToAsync(fs); return targetPath; } section await reader.ReadNextSectionAsync(); } return string.Empty; }这个写法避免了IFormFile的中间缓冲文件数据直接落盘。代价是需要自己从Content-Type里解析boundary稍微繁琐一点。我的建议是只要单片控制在10MB以下用IFormFile就行代码简单、好维护当单次上传能达到几十MB甚至上百MB时再考虑流式读取。5.3 临时目录的并发与回收策略临时目录在单机单实例部署下没有问题但如果你把服务横向扩容成多台实例就会出现分片存在A服务器的磁盘上complete请求被负载均衡转发到B服务器B找不到分片。这个问题没有优雅的本地磁盘解法要么把分片写入共享存储要么在网关做按fileId的哈希路由保证同一个fileId的请求都落在同一台实例。临时目录的另一个隐患是磁盘空间。我见过一个失败案例同事用随机GUID当fileId每传一次就生成一批临时分片用户反复取消重试服务器磁盘被塞满。解决方案就是我在前面提到的TTL清理按目录的最后写入时间做判断超24小时直接删。6. 进阶能力断点续传、秒传与实时进度基础版能跑通之后紧接着就是三个用户高频期待的能力上传到一半断了能不能续、同一个文件要不要重复传、进度条能不能实时看到。6.1 秒传的MD5方案我这里说的“秒传”其实是文件级去重。实现思路是前端算整个文件的MD5后端维护一个文件指纹和存储路径的映射check接口拿MD5去查如果发现服务器上已经存在相同内容的文件就直接标记为上传完成让前端跳过整个文件。[HttpPost(api/upload/check)] public IActionResult CheckFile([FromBody] FileCheckModel model) { if (_fileIndex.TryGetValue(model.Md5, out var existingPath)) { return Ok(new { exists true, path existingPath }); } var tempDir Path.Combine(_storageRoot, _temp, model.FileId); var uploaded Directory.Exists(tempDir) ? Directory.GetFiles(tempDir) .Select(Path.GetFileNameWithoutExtension) .Select(int.Parse) .OrderBy(x x) .ToArray() : Array.Emptyint(); return Ok(new { exists false, uploadedChunks uploaded }); }注意大文件算MD5本身很耗时一个1GB的文件可能要算几秒甚至十几秒。大多数场景下没必要做内容级秒传用前面说的“路径大小修改时间”生成fileId服务端在complete时检查目标文件是否已存在也能实现类似的跳过效果代价要小得多。6.2 断点续传的前后端配合断点续传的核心是check接口返回的uploadedChunks数组。前端拿到这个数组后在构造分片队列时把已上传的分片过滤掉然后只传缺失的部分。如果用户中途关闭了浏览器下次打开同一个文件夹前端再次生成同样的fileId再次调用check就能从上次的位置继续。前端还可以把任务描述存到localStorage刷新页面后自动恢复上传界面而不是让用户重新选文件夹。需要提醒的是如果服务端临时分片已经被24小时清理任务删掉了check接口会返回空数组。前端不要在这种情况下“跳过检查直接续传”否则会出现前端以为传完了、服务端其实一个分片都没有的尴尬。6.3 进度展示与取消操作总进度的计算公式很简单const percent Math.round((uploadedBytes / totalBytes) * 100);uploadedBytes在每次分片成功返回后累加totalBytes是所有文件大小之和。这里有个容易被忽略的细节累加时要用Blob的实际大小也就是chunk.end - chunk.start而不是文件大小除以分片数因为最后一个分片往往是不足5MB的零头。取消上传用AbortController即可const controller new AbortController(); // 把 controller.signal 传给所有 fetch // 用户点击取消时调用 controller.abort();取消后服务端的临时分片会留在_temp目录里由后台清理任务兜底。如果后续想更实时可以引入SignalR在服务端合并完成后向前端推送通知但对大多数后台管理系统来说前端分片回调带来的进度已经足够平滑没必要增加一套实时通信链路。7. 实测表现与踩坑清单方案并不是写完代码就结束了。我在本地开发机上做了完整的上传测试也踩了不少文档里不会写的坑整理出来给你省时间。7.1 一组实测数据测试环境是本地4核8G开发机SSD硬盘千兆局域网服务端就是ASP.NET Core默认配置加上述接口。测试结果如下场景文件情况分片设置耗时服务端内存峰值大量小文件文件夹860个文件共约120MB小于1MB的文件不切片约22秒约180MB单个大视频3.2GB单文件5MB分片并发5约70秒约240MB混合文件夹200个图片 2个1GB视频5MB分片并发4约55秒约260MB大量小文件场景让我发现一个重要优化点如果单文件本身不到1MB再切成5MB分片只会白白增加HTTP请求数量。更好的做法是小于分片大小的文件直接整体作为一个分片上传每个文件一次请求这样大文件享受分片能力小文件也不浪费连接。7.2 我踩过的坑和处理方式现象原因处理方式上传1GB文件返回413Kestrel和IIS限制都没调按前文配置单请求限制到10MB配合5MB分片合并出来的文件打不开直接把分片追加到同一个目标文件多请求并发覆盖写坏改为每个分片独立临时文件complete时统一合并刷新页面后上传进度丢失fileId用了随机GUID改成路径大小修改时间生成的稳定哈希中文文件名上传后路径乱码前端相对路径没有统一UTF-8处理前端统一用encodeURIComponent后端Uri.UnescapeDataString还原_temp目录磁盘被塞满用户频繁取消重试临时分片残留加24小时TTL后台清理任务多实例部署后complete找不到分片分片在A服务器请求被转发到B服务器按fileId哈希路由或使用共享存储最后分享一个我自己的体会这一套方案看起来环节多但真正值得先设计的不是上传代码而是分片命名规范和接口约定。只要把fileId、chunkIndex、relativePath这三个字段定死了断点续传、秒传、并发重试都只是在这套坐标上做加法。我前两版方案都死在“后期加需求”后来先把规范钉死整个模块才稳定下来。如果你正准备做类似功能建议直接按这个结构起步省去后面重构的折腾。