前阵子又有同事在技术群里问: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 | 约30MB | 413 Payload Too Large |
| IIS maxAllowedContentLength | 30000000字节(约30MB) | 404.13 |
| ASP.NET Core FormOptions.MultipartBodyLengthLimit | 约128MB | 413 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/check | POST | 查询秒传状态、已上传分片列表 |
| /api/upload/chunk | POST | 上传某一个分片 |
| /api/upload/complete | POST | 全部结束后触发合并 |
| /api/upload/cancel | POST | 取消当前上传并清理临时分片 |
后面所有断点续传、秒传、进度恢复,都是在这四个接口之上加逻辑。
3. 前端实现:文件夹读取、分片切割与上传队列
前端是这个方案里最容易出问题的环节。拿到文件夹、切分片本身不复杂,难的是把几百个文件的上传任务组织起来,既不让浏览器崩溃,又要保证顺序和进度可追踪。
3.1 拿到文件夹里的全部文件
HTML里没有标准的文件夹选择属性,目前实际可用的是webkitdirectory,配合multiple一起使用:
<input type="file" id="folderPicker" 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加FormData:
async 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 Task<IActionResult> 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 Task<IActionResult> 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.config:
<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="10485760" /> </requestFiltering> </security> </system.webServer>这里我故意没有把限制调成很大的值。分片方案的精髓是用小请求分流,单片5MB,单请求限制10MB就够用。调得越大,越容易被一个异常请求打满内存,得不偿失。如果前端忘了切分片,直接把整个大文件POST上来,限制小还能尽早报错提醒修复,限制大了反而会慢慢拖死服务器。
5.2 MultipartReader与流式读取什么时候用
ASP.NET Core接收IFormFile时,默认行为是把请求体缓冲到内存或磁盘临时文件。单片5MB场景下这点开销无所谓,但如果某个接口必须接收特别大的分片,或者你想彻底避免临时缓冲,就要用MultipartReader做真正的流式读取。
public static async Task<string> 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.Empty<int>(); 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文件返回413 | Kestrel和IIS限制都没调 | 按前文配置单请求限制到10MB,配合5MB分片 |
| 合并出来的文件打不开 | 直接把分片追加到同一个目标文件,多请求并发覆盖写坏 | 改为每个分片独立临时文件,complete时统一合并 |
| 刷新页面后上传进度丢失 | fileId用了随机GUID | 改成路径+大小+修改时间生成的稳定哈希 |
| 中文文件名上传后路径乱码 | 前端相对路径没有统一UTF-8处理 | 前端统一用encodeURIComponent,后端Uri.UnescapeDataString还原 |
_temp目录磁盘被塞满 | 用户频繁取消重试,临时分片残留 | 加24小时TTL后台清理任务 |
| 多实例部署后complete找不到分片 | 分片在A服务器,请求被转发到B服务器 | 按fileId哈希路由或使用共享存储 |
最后分享一个我自己的体会:这一套方案看起来环节多,但真正值得先设计的不是上传代码,而是分片命名规范和接口约定。只要把fileId、chunkIndex、relativePath这三个字段定死了,断点续传、秒传、并发重试都只是在这套坐标上做加法。我前两版方案都死在“后期加需求”,后来先把规范钉死,整个模块才稳定下来。如果你正准备做类似功能,建议直接按这个结构起步,省去后面重构的折腾。