简介:围绕 ASP.NET Core WebAPI 的文件上传与下载服务展开的示例工程,面向具备一定 C# 基础、希望在 RESTful 接口中落地文件存取能力的后端开发者。代码覆盖 multipart/form-data 表单接收与 IFormFile 处理、基于 GET 的文件回传、Content-Disposition 与 Content-Type 响应头设置、流式读写降低内存占用,并涉及权限校验、路径遍历防护、文件名清理与上传类型限制等安全要点;中间件部分给出上传、缩略图、负载均衡等可复用实现,前端 Demo 与 node 客户端便于联调验证。压缩包共 50 个文件,以 25 个 cs 源码为主体,配合 9 个 json 配置、5 个 csproj 项目文件与 4 个 js 脚本,另含 sln 解决方案、Dockerfile、readme 与页面文件,整体约 206KB,按服务端、前端示例、节点工具分包组织,结构清晰。已有 1924 人学习,可作为搭建或改造文件上传下载接口的参考模板。
1. 从一次 413 报错说起:.net core WebApi 文件上传服务到底该怎么做
某次给一个内部平台补 .net core WebApi 文件上传服务,前端拖进一个 180MB 的安装包,接口直接返回 413,后端日志干净得像刚重启过,连 Action 的断点都没进去。排查一圈才发现,请求根本没有走到业务代码:Kestrel 默认请求体上限 30MB,IIS 的 requestFiltering 又默认卡 30MB,两层门槛谁先拦住都不奇怪。文件下载那边也踩过类似的坑,浏览器拖动进度条时接口返回 200 而不是 206,等于每次续传都从头下一遍。这套东西说白了就是一套可落地的 .netcore Webapi 文件服务:上传侧管住大小限制、分片、断点、秒传和落盘安全,下载侧管住流式响应、Range 请求和并发占用。适合正在给后台系统加附件模块、或者被大文件上传下载折腾过一轮的开发者,新手能照着代码抄,熟手能直接看参数边界。
2. 上传链路选型:IFormFile、MultipartReader 与大小限制的真实边界
上传这件事,第一道分岔口不是业务逻辑,而是「请求体怎么进到进程里」。选错接收方式,后面无论怎么优化都别扭:用 IFormFile 接 2GB 文件,进程内存会很难看;用流式读取接一个几十 KB 的配置文本,纯属给自己加复杂度。另一个容易被忽略的点是限制层级,Kestrel 一层、FormOptions 一层、IIS 一层,三层里任何一层没对齐,表现都是「莫名 413」或者「400 且无日志」。
2.1 三种接收方式的适用边界
常见做法是把接收方式分成三类,按文件体量和使用场景选:
| 方式 | 内存表现 | 适合体量 | 代价 |
|---|---|---|---|
IFormFile模型绑定 | 超过阈值落临时文件,整体可控 | 几十 KB 到一两百 MB | 框架先缓冲一遍,磁盘多写一次 |
MultipartReader流式读 | 只占缓冲区 | 百 MB 到 GB 级 | 要自己解析分段、自己处理文件名 |
请求体直读Request.Body | 只占缓冲区 | 单文件、纯二进制 | 元数据得走 Header 或 Query |
IFormFile是最省事的,框架帮你把 multipart 拆好,参数校验、模型绑定都能用。它在文件超过大约 64KB 时会落到系统临时目录,不会傻乎乎全塞内存,所以「IFormFile 一定爆内存」的说法并不准确,真正的问题是它多了一次临时文件落地,以及临时目录所在盘的容量。下面这段是最小可用版本:
[HttpPost("upload")] [RequestSizeLimit(512L * 1024 * 1024)] // 接口级覆盖,单位字节 public async Task<IActionResult> Upload(IFormFile file, CancellationToken ct) { if (file is null || file.Length == 0) return BadRequest("空文件"); // 客户端可能传 C:\fakepath\xxx.zip,先剥掉路径 var originName = Path.GetFileName(file.FileName); var ext = Path.GetExtension(originName).ToLowerInvariant(); if (!_allowExt.Contains(ext)) return BadRequest("扩展名不允许"); var storedName = $"{Guid.NewGuid():N}{ext}"; var dir = Path.Combine(_env.ContentRootPath, "uploads", DateTime.UtcNow.ToString("yyyyMM")); Directory.CreateDirectory(dir); var tmpPath = Path.Combine(dir, storedName + ".tmp"); var finalPath = Path.Combine(dir, storedName); await using (var fs = new FileStream(tmpPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { await file.CopyToAsync(fs, ct); } // 全部写完才改名,避免半截文件被下载端读到 File.Move(tmpPath, finalPath); return Ok(new { id = storedName, name = originName, size = file.Length }); }几个参数值得说清楚。FileMode.CreateNew而不是Create,是为了防止同名覆盖,一旦撞名直接抛异常,比静默覆盖安全。缓冲区给 81920 字节是FileStream的常用默认值,太小会增加系统调用次数,太大对吞吐提升有限。FileShare.None保证写的过程中没人能读走半截文件。File.Move在同一分区内是元数据操作,接近原子,前面那层.tmp就是后悔药。
体量再往上走,就该换流式。下面这段用MultipartReader边读边写,不产生第二份临时文件:
[HttpPost("upload-stream")] [DisableRequestSizeLimit] // 大小交给 Kestrel 与 FormOptions 管 public async Task<IActionResult> UploadStream(CancellationToken ct) { if (!Request.HasFormContentType || !MediaTypeHeaderValue.TryParse(Request.ContentType, out var mediaType)) return BadRequest("必须是 multipart/form-data"); var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value; var reader = new MultipartReader(boundary, Request.Body); var section = await reader.ReadNextSectionAsync(ct); while (section is not null) { var disposition = section.ContentDisposition; if (disposition is not null && disposition.Contains("filename=")) { var raw = disposition.Split(';') .First(p => p.TrimStart().StartsWith("filename=")) .Split('=')[1].Trim('"'); var safeName = Path.GetFileName(raw); var target = Path.Combine(_uploadRoot, $"{Guid.NewGuid():N}{Path.GetExtension(safeName)}"); await using var fs = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true); await section.Body.CopyToAsync(fs, ct); } section = await reader.ReadNextSectionAsync(ct); } return Ok(); }MultipartReader需要Microsoft.AspNetCore.WebUtilities包,HeaderUtilities来自Microsoft.Net.Http.Headers。它的价值在于section.Body.CopyToAsync是真正的流式拷贝,进程内存占用基本等于缓冲区大小,几百 MB 到几个 GB 都扛得住。代价是文件名、字段都得自己从ContentDisposition里抠,写法不够优雅,但这是可预期的。
2.2 把 413 和 400 掐死在入口
大小限制这件事,三层必须对齐,缺一层就翻车。
第一层是 Kestrel。默认MaxRequestBodySize是 30MB,超了直接在服务器层拒掉,Action 根本进不来:
var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(options => { // 全局放宽到 512MB,个别接口再用特性单独放大或收紧 options.Limits.MaxRequestBodySize = 512L * 1024 * 1024; // 慢连接保护:低于 100B/s 且超过 10 秒直接断 options.Limits.MinRequestBodyDataRate = new MinDataRate(bytesPerSecond: 100, gracePeriod: TimeSpan.FromSeconds(10)); }); builder.Services.Configure<FormOptions>(options => { // multipart 整体上限,必须 >= Kestrel,否则先在这里抛 options.MultipartBodyLengthLimit = 512L * 1024 * 1024; // 单个表单字段的长度,别跟着放大,防止文本字段撑爆内存 options.ValueLengthLimit = 1024 * 1024; options.MultipartHeadersLengthLimit = 64 * 1024; });MultipartBodyLengthLimit默认是 128MB,很多人只改了 Kestrel 却忘了它,结果就是「明明配了 512MB,上传到 128MB 还是挂」。它抛的是InvalidDataException,如果不加异常中间件,日志里往往什么都看不到,这个黑匣子我踩过不止一次。
第二层是 IIS 或反向代理。IIS 的maxAllowedContentLength单位是字节,和 Kestrel 的字节一致,但配置位置在web.config:
<system.webServer> <security> <requestFiltering> <!-- 512MB,单位字节;这个值要跟着发布走,别被环境覆盖 --> <requestLimits maxAllowedContentLength="536870912" /> </requestFiltering> </security> </system.webServer>第三层是接口级特性。全局放宽是有风险的,更稳的做法是全局保持一个中等值,只在真正需要大文件的接口上开:
[HttpPost("big-upload")] [RequestSizeLimit(2L * 1024 * 1024 * 1024)] // 2GB [RequestFormLimits(MultipartBodyLengthLimit = 2L * 1024 * 1024 * 1024)] public Task<IActionResult> BigUpload(IFormFile file) => Upload(file, CancellationToken.None);这里的边界要清楚:RequestSizeLimit管的是整个请求体,RequestFormLimits管的是 multipart 解析,两个都得给够。如果前面挂了 Nginx,client_max_body_size也要同步,否则请求在网关就被挡了,后端连日志都不会有。
2.3 落盘策略与文件名安全
文件名是最容易被忽略的攻击面。直接拿file.FileName拼路径,会同时踩两个坑:一是客户端传C:\fakepath\a.zip这种带路径的名字,拼出来目录不存在;二是构造../../appsettings.json这类名字做路径穿越。标准写法是先Path.GetFileName剥路径,再用 GUID 做磁盘名,原始名只存数据库或回给前端:
// 磁盘名与展示名分离:磁盘用 GUID,展示名清洗后入库 var originName = Path.GetFileName(file.FileName); if (originName.Length > 200) originName = originName[^200..]; // 扩展名白名单,不靠 Content-Type 判断 var ext = Path.GetExtension(originName).ToLowerInvariant(); if (!_allowExt.Contains(ext)) return BadRequest("扩展名不允许"); var storedName = $"{Guid.NewGuid():N}{ext}"; // 按哈希前缀二级分目录,避免单目录几十万文件导致 ls 卡死 var prefix = storedName[..2]; var dir = Path.Combine(_uploadRoot, prefix, DateTime.UtcNow.ToString("yyyyMM")); Directory.CreateDirectory(dir);目录分层这件事,几万个文件时感觉不到,几十万个文件时Directory.GetFiles会明显变慢,某些文件系统上还会拖慢整个目录的元数据操作。按 GUID 前两位分一级、按年月分二级,单目录文件数基本能压到几千以内。
还有一点,Content-Type由客户端给,完全不可信,判断文件类型应该看扩展名白名单加服务端嗅探(读文件头几个字节比对魔数),至少不能只信file.ContentType。上传目录也不要放在网站根目录下可直连的位置,否则一个.html或.svg上传上来就是存储型问题。
3. 分片上传与断点续传:把大文件拆成可重试的块
单请求上传到几百 MB 之后,失败率会明显上升:网络抖动、浏览器超时、代理掐连接,任何一环断了整个文件就得重来。分片解决的正是这个:把文件切成固定大小的块,每块独立重试,服务端按索引落盘,最后合并。顺带还能拿到两个能力,秒传(哈希命中直接返回)和断点续传(问服务端已经收到哪些片)。
3.1 分片协议怎么设计
协议不用复杂,三个接口足够:
| 接口 | 入参 | 出参 | 说明 |
|---|---|---|---|
POST chunk/init | fileName、totalSize、fileHash、chunkSize | uploadId、chunkCount、uploaded[]、instant | 建会话,返回已上传分片 |
POST chunk/{uploadId}/{index} | 二进制体、X-Chunk-Sha256 头 | 空 | 单片上传,可重复覆盖 |
POST chunk/{uploadId}/complete | 无 | fileId | 校验并合并 |
设计上有几个决定要想清楚。chunkSize由前端传而不是服务端定,是为了让前端按自己的内存和网络情况调,服务端只做上限校验(比如不超过 8MB)。fileHash用整文件哈希,前端可以用增量哈希库边读边算,不用把文件整个读进内存。uploaded[]让断点续传变成一次查询,前端跳过已存在的索引即可。
uploadId用 GUID 而不是文件哈希,是为了避免「同一文件多人同时传」时的会话冲突;哈希只用于秒传判定,两者职责分开。
3.2 服务端分片落盘与合并实现
先看初始化,这里顺带把秒传和断点查询一起做了:
public record InitRequest(string FileName, long TotalSize, string FileHash, int ChunkSize); public record InitResponse(string UploadId, int ChunkCount, int[] Uploaded, bool Instant); [HttpPost("chunk/init")] public async Task<ActionResult<InitResponse>> Init([FromBody] InitRequest req) { if (req.ChunkSize is <= 0 or > 8 * 1024 * 1024) return BadRequest("chunkSize 需在 1B 到 8MB 之间"); // 秒传:哈希命中已完成文件,直接给结果 var existed = await _store.FindByHashAsync(req.FileHash); if (existed is not null) return Ok(new InitResponse(existed.Id, 0, Array.Empty<int>(), true)); var chunkCount = (int)Math.Ceiling(req.TotalSize / (double)req.ChunkSize); var uploadId = Guid.NewGuid().ToString("N"); var dir = Path.Combine(_chunkRoot, uploadId); Directory.CreateDirectory(dir); // meta 落盘,进程重启后会话还能续 var meta = new ChunkMeta(uploadId, req.FileName, req.TotalSize, req.FileHash, req.ChunkSize); await File.WriteAllTextAsync(Path.Combine(dir, "meta.json"), JsonSerializer.Serialize(meta)); var uploaded = Directory.GetFiles(dir, "*.part") .Select(p => int.Parse(Path.GetFileNameWithoutExtension(p))) .OrderBy(i => i) .ToArray(); return Ok(new InitResponse(uploadId, chunkCount, uploaded, false)); }meta.json落在会话目录里,比放内存字典稳,服务重启、滚动发布都不丢。哈希到文件 ID 的映射可以放数据库,量大的话加一层缓存,但别只放内存,否则重启后秒传全失效。
单片上传要处理两件事,临时文件和分片校验:
[HttpPost("chunk/{uploadId}/{index:int}")] [DisableRequestSizeLimit] public async Task<IActionResult> PutChunk(string uploadId, int index, CancellationToken ct) { var dir = Path.Combine(_chunkRoot, uploadId); if (!Directory.Exists(dir)) return NotFound("会话不存在或已过期"); var expectHash = Request.Headers["X-Chunk-Sha256"].ToString(); var tmp = Path.Combine(dir, $"{index}.tmp"); var part = Path.Combine(dir, $"{index}.part"); await using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { await Request.Body.CopyToAsync(fs, ct); } if (!string.IsNullOrEmpty(expectHash)) { await using var check = File.OpenRead(tmp); var actual = Convert.ToHexString(await SHA256.HashDataAsync(check, ct)) .ToLowerInvariant(); if (actual != expectHash) { File.Delete(tmp); return BadRequest($"分片 {index} 校验失败"); } } // 允许重传覆盖,网络重试时不会因为文件已存在而失败 File.Move(tmp, part, overwrite: true); return Ok(); }FileMode.Create加overwrite: true的组合是故意的:分片重传是常态,前端超时重试同一片很常见,如果这里用CreateNew,重试就会 500。分片级哈希是可选项,带上就校验,不带就跳过,给前端留性能余地。
合并阶段最关键的是「先校验再改名」:
[HttpPost("chunk/{uploadId}/complete")] public async Task<IActionResult> Complete(string uploadId, CancellationToken ct) { var dir = Path.Combine(_chunkRoot, uploadId); var metaPath = Path.Combine(dir, "meta.json"); if (!File.Exists(metaPath)) return NotFound("会话不存在或已过期"); var meta = JsonSerializer.Deserialize<ChunkMeta>( await File.ReadAllTextAsync(metaPath, ct))!; var chunkCount = (int)Math.Ceiling(meta.TotalSize / (double)meta.ChunkSize); for (var i = 0; i < chunkCount; i++) if (!File.Exists(Path.Combine(dir, $"{i}.part"))) return BadRequest($"缺少分片 {i}"); var finalName = $"{Guid.NewGuid():N}{Path.GetExtension(meta.FileName)}"; var finalPath = Path.Combine(_storeRoot, finalName); var tmpPath = finalPath + ".tmp"; await using (var output = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20, useAsync: true)) { for (var i = 0; i < chunkCount; i++) { await using var input = new FileStream( Path.Combine(dir, $"{i}.part"), FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20, useAsync: true); await input.CopyToAsync(output, ct); } } // 大小对不上说明有漏片或重复,宁可失败也别产出坏文件 if (new FileInfo(tmpPath).Length != meta.TotalSize) { File.Delete(tmpPath); return BadRequest("合并后大小与原文件不一致"); } File.Move(tmpPath, finalPath); Directory.Delete(dir, recursive: true); return Ok(new { id = finalName }); }合并缓冲区给到 1MB,是因为这里已经是顺序大块读写,81920 会带来更多次系统调用。按索引从小到大合并,而不是GetFiles的返回顺序,这在多数文件系统上不是稳定有序的,靠它排序会得到一堆乱序文件,症状是「合并成功但文件打不开」——这个坑我见过不止一次。最后校验总大小成本极低,但能挡住绝大多数静默损坏。
3.3 秒传与断点续传的落地细节
秒传的本质是「这个文件我这儿已经有了」,所以哈希必须是内容哈希,不能掺文件名或时间戳。前端算哈希时如果加了文件大小之外的元信息,同一文件换个名字就秒传失效,用户会觉得这个功能时灵时不灵。
断点续传要靠uploaded[]和会话过期策略配合。会话目录不能无限留着,一般给 24 到 48 小时,超时由后台任务清理,前端拿到 404 就重新 init。这里有个细节:清理任务删目录时可能正赶上合并,所以合并开始前先把meta.json改名成meta.lock,清理任务跳过带锁的会话,合并完再删整个目录,比加分布式锁简单得多。
还有一个容易被忽略的点,合并完成后要把fileHash → fileId写进存储,秒传才有依据。写入时机放在File.Move之后,保证「能查到哈希就一定拿得到文件」,反过来的顺序会产生指向不存在文件的脏记录。
4. 下载端实现:Range 请求、流式响应与断点下载
上传做完了,下载往往被当成一行return File(...)就完事,直到有人在浏览器里拖进度条、或者用下载工具开多线程,才发现服务端返回的是 200 全量而不是 206 分段。文件下载的核心就三件事:别把文件读进内存、正确响应 Range 头、别让并发读互相锁死。
4.1 FileStreamResult、PhysicalFile 与手写 Range 的取舍
先看对比,省得选错:
| 方式 | 是否支持 Range | 内存占用 | 适用 |
|---|---|---|---|
PhysicalFile | 需显式开启 | 低 | 文件在本地磁盘、路径可信 |
FileStreamResult | 需显式开启 | 低 | 文件来自流、需要自己控制生命周期 |
| 手写 Range | 完全自控 | 低 | 要做鉴权、限速、审计或特殊头 |
| 静态文件中间件 | 默认支持 | 低 | 公开只读资源,不需要业务鉴权 |
多数场景其实用不到手写,PhysicalFile加一个参数就够了:
[HttpGet("download/{id}")] public IActionResult Download(string id) { var path = _store.Resolve(id); // 内部做 id 到路径的映射与越界校验 if (!System.IO.File.Exists(path)) return NotFound(); // enableRangeProcessing: true 才会处理 Range 头并返回 206 return PhysicalFile(path, "application/octet-stream", fileDownloadName: _store.GetOriginName(id), enableRangeProcessing: true); }enableRangeProcessing这个参数是分水岭,不传就是 200 全量,传了才会解析Range并返回206 Partial Content和Content-Range。fileDownloadName会生成Content-Disposition,中文名要注意编码,框架会做 RFC 5987 处理,但文件名里如果有换行或引号,仍可能造成响应头注入,入库前就该清洗。
_store.Resolve(id)这一步必须做白名单式映射,不能拿用户传的路径直接拼。常见做法是 id 就是磁盘上的 GUID 文件名,拼接前用正则校验只含[0-9a-f]{32}加扩展名,杜绝../穿越。
4.2 手写 Range:需要审计和限速时怎么办
有些场景框架给不了,比如按用户限速、按次记审计日志、或者文件来源不是本地路径而是对象存储的流。这时候手写一段 Range 处理更踏实:
[HttpGet("stream/{id}")] public async Task<IActionResult> Stream(string id, CancellationToken ct) { var path = _store.Resolve(id); var fi = new FileInfo(path); if (!fi.Exists) return NotFound(); var rangeHeader = Request.Headers.Range.ToString(); if (string.IsNullOrEmpty(rangeHeader)) { Response.Headers.AcceptRanges = "bytes"; return PhysicalFile(path, "application/octet-stream", enableRangeProcessing: true); } // 只处理单段 Range,多段(逗号分隔)直接退化成全量 var spec = rangeHeader.Replace("bytes=", "").Split(',')[0].Split('-'); if (!long.TryParse(spec[0], out var start)) return BadRequest("Range 格式错误"); var end = string.IsNullOrEmpty(spec[1]) ? fi.Length - 1 : long.Parse(spec[1]); if (start >= fi.Length || start > end) { Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; Response.Headers.ContentRange = $"bytes */{fi.Length}"; return new EmptyResult(); } end = Math.Min(end, fi.Length - 1); var length = end - start + 1; Response.StatusCode = StatusCodes.Status206PartialContent; Response.ContentType = "application/octet-stream"; Response.ContentLength = length; Response.Headers.ContentRange = $"bytes {start}-{end}/{fi.Length}"; Response.Headers.AcceptRanges = "bytes"; await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, useAsync: true); fs.Seek(start, SeekOrigin.Begin); var buffer = ArrayPool<byte>.Shared.Rent(64 * 1024); try { long remaining = length; while (remaining > 0) { var toRead = (int)Math.Min(buffer.Length, remaining); var read = await fs.ReadAsync(buffer.AsMemory(0, toRead), ct); if (read == 0) break; // 文件被截断 await Response.Body.WriteAsync(buffer.AsMemory(0, read), ct); remaining -= read; } } finally { ArrayPool<byte>.Shared.Return(buffer); } return new EmptyResult(); }关键点有三个。ContentLength必须设成实际要发的字节数,不设的话客户端不知道边界,进度条会算错。FileShare.Read允许其他请求同时读同一个文件,缺省值在某些情况下会让并发下载互相报「文件被占用」。ArrayPool复用缓冲区,高频下载时不至于给 GC 添太多压力。
多段 Range(一个请求要好几段)实现起来收益很低,下载工具通常也只发单段,直接退化成全量或只处理第一段都能接受,但要保证不返回错误的状态码。
4.3 下载侧的内存与连接坑
第一个坑是File.ReadAllBytes。几十 MB 的文件看着没事,几个用户同时下 500MB 的文件,内存曲线会非常难看,而且大对象堆一涨就很难降下来。只要返回类型是IActionResult,就该走流。
第二个坑是Content-Length缺失导致的连接复用问题。不设长度、又不是 chunked 的场景,客户端只能读到连接关闭为止,HTTP/1.1 的 keep-alive 就废了,表现为「下载完页面卡住几秒」。流式返回时能用Response.ContentLength就用,实在不知道长度再靠 chunked。
第三个坑是断点续传的客户端配合。服务端返回了Accept-Ranges: bytes,客户端才会尝试 Range。少这个头,下载工具就老老实实从头下。反过来,如果文件在下载过程中被替换(内容变了但路径没变),ETag和Last-Modified必须跟着变,否则客户端会拿旧缓存拼新文件,产出一个哈希对不上的怪东西。稳妥做法是文件 ID 与内容一一对应,永不原地覆盖。
5. 避坑与排查:把上传下载搞崩的六条血泪经验
这套服务上线后遇到的问题,基本都不是语法错误,而是「配置层级没对齐」和「边界没校验」。下面六条按现象、原因、解决写,都是可以对照日志直接定位的。
5.1 上传侧的三个高频翻车点
现象:本地 200MB 上传正常,部署到 IIS 后直接 404.13 或 413,后端无任何日志。原因:IIS 的requestFiltering默认maxAllowedContentLength约 30MB,这一层在 Kestrel 之前,请求根本没进应用。只改 Kestrel 完全无效。 解决:web.config里加requestLimits,单位是字节,500MB 就写524288000。注意这个文件如果在发布时被覆盖,配置会丢,要把它纳入版本管理。前面有 Nginx 的话,client_max_body_size同步改,否则还是在网关被拦。
现象:上传到 128MB 左右稳定失败,返回 400,日志里只有一行InvalidDataException。原因:FormOptions.MultipartBodyLengthLimit默认 128MB,它管的是 multipart 解析,和 Kestrel 的限制是两套。这个异常在模型绑定阶段抛出,很多全局异常处理抓不到,于是日志空白。 解决:Configure<FormOptions>里把它调到和 Kestrel 一致或更大,同时在异常中间件里专门捕获InvalidDataException并记下请求路径和Content-Length,下次再出问题就有据可查。
现象:上传成功但磁盘上出现一堆.tmp半截文件,占用越来越大。原因:请求中断时CopyToAsync抛异常,File.Move没执行,.tmp就留在原地。代码里如果没有try/finally清理,这些文件永远不会消失。 解决:写入包在 try 里,catch 中删掉.tmp再抛;再加一个后台清理任务,扫超过 24 小时的.tmp和过期分片会话目录。别指望请求一定能走完 finally,进程被杀的场景只能靠后台任务兜底。
5.2 下载与合并侧的三条
现象:合并接口返回成功,但下载下来的文件打不开,大小和原文件一致。原因:按Directory.GetFiles的返回顺序合并,文件系统不保证字典序,分片顺序乱了。大小对得上是因为片数没错,内容全错位。 解决:严格按索引0..chunkCount-1拼路径,不要依赖枚举顺序。合并后除了校验总大小,有条件的话再算一次整文件哈希,与 init 时传的fileHash比对,这一步能把「顺序错」和「漏片」一起挡住。
现象:两三个人同时下载同一个大文件,部分请求报 500,提示文件被占用。原因:FileStream打开时用了FileShare.None或默认值,第二个读请求拿不到句柄。 解决:读场景统一FileShare.Read,写场景才用FileShare.None。用PhysicalFile的话框架内部处理好了,自己 newFileStream时别漏这个参数。
现象:断点续传时,客户端从 60% 继续,最后文件损坏。原因:会话被后台清理任务删了,前端拿到 404 后没有重新 init,而是继续往一个新建的同名目录传剩余分片,前面的片缺失。 解决:清理任务给会话加「最后活跃时间」,按meta.json的写入时间算,并且合并前把meta.json改名占位。前端侧收到 404 必须重新走 init,拿新的uploadId和uploaded[],不能复用旧 ID。
6. 进阶收口:把文件服务做成可观测、可限流的独立模块
文件服务最容易变成「能跑但说不清」的模块,出了问题只能靠猜。我的习惯是把三样东西固定下来:结构化日志字段、入口限流、后台清理。日志至少记traceId、uploadId、文件大小、耗时、落盘路径、失败原因这六项,其中uploadId是串起 init、chunk、complete 三段的关键,缺了它排查断点续传问题基本靠蒙。
限流用框架自带的就够,按用户或 IP 分区的固定窗口,重点限制并发上传数而不是请求数,因为一个大文件会发很多片:
builder.Services.AddRateLimiter(o => { o.AddPolicy("upload", ctx => RateLimitPartition.GetFixedWindowLimiter( partitionKey: ctx.User.Identity?.Name ?? ctx.Connection.RemoteIpAddress!.ToString(), factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 4, // 同时最多 4 个上传请求 Window = TimeSpan.FromMinutes(1), QueueLimit = 8 // 超出排队,排队也满则 503 })); }); app.UseRateLimiter();清理任务写成BackgroundService,每半小时跑一次,扫两类东西:超过 24 小时的.tmp文件,和最后活跃时间超过 48 小时的分片会话目录。删之前先检查meta.lock是否存在,存在就跳过,避免和合并打架。这个任务一定要幂等,删一半崩了下次还能接着删。
验证是否真的生效,我一般看三个指标:上传接口的 P99 耗时、临时目录的文件数、以及 206 响应的比例。如果 206 比例接近零,说明enableRangeProcessing没开或者被前置代理吃掉了 Range 头,这个数字比翻代码快得多。从那以后我每次加文件相关接口,都强制走一遍「2GB 上传 + 断点续传 + 多线程下载」的组合验证,不管需求里写没写。希望帮到你。
本文还有配套的精品资源,点击获取