.NET Core WebApi 文件上传下载:流式落盘与断点续传

发布时间:2026/11/2 11:27:11
.NET Core WebApi 文件上传下载:流式落盘与断点续传

简介:面向 .NET Core 开发者的一份 WebAPI 文件上传与下载示例工程,围绕 IFormFile 接收 multipart/form-data、流式写出下载响应等常见需求展开,适合正在搭建文件服务、需要参考控制器写法与中间件组织方式的中级开发者。压缩包共 50 个文件,以 25 个 C# 源码为主体,配合 5 个项目文件、9 个 JSON 配置及少量 JS、HTML、解决方案与 Dockerfile,整体约 206KB;内含服务端、前端演示、客户端调用和节点处理等多个子项目,便于对照服务端与调用端。已有 1924 人学习。代码覆盖上传保存、Content-Disposition 与 Content-Type 设置、异步流处理、权限校验、路径与文件名防护、文件类型限制、分块传输和缓存策略,并附上传、缩略图、负载均衡等中间件示例,可用于理解目录分层、异常处理与日志记录的组织方式。通过该示例可快速建立从请求接收、文件落盘到响应输出的完整认知,也便于按项目需求裁剪扩展,并补充鉴权、限流与存储切换等生产化能力。

1. 从一次 413 说起:.NET Core WebApi 文件上传下载服务到底该长什么样

去年帮一个团队看他们后台的附件模块,症状很朴素:小文件随便传,用户传一个 200MB 的培训视频,浏览器直接返回 413,日志里连 Controller 的断点都没进去。打开代码一看,一个IFormFile.CopyToAsync加一个File.ReadAllBytes就交付了。这就是典型的「接口能跑,服务不能用」。

一套完整的 .NET Core WebApi 文件上传服务文件下载能力,代码量其实很小,真正花时间的是四件事:请求体大小在 Kestrel、表单解析器、宿主三层各自的限制,流式落盘时内存占用怎么压成常数,下载时 Range 请求怎么正确回 206。这套东西适合不想接第三方对象存储、希望把文件读写控制在自己进程里的中小型后台系统——管理后台附件、报表导出、设备固件包分发,都落在射程内。下面按我自己搭的顺序拆一遍。

2. 骨架先行:把存储抽成接口,Controller 只留三行

动手写之前先定一件事:文件落在哪、怎么命名、谁来负责路径拼接。这三件事只要散落在 Controller 里,后面必然出问题,路径穿越漏洞十有八九就是这么来的。

2.1 为什么不建议在 Controller 里直接 File.WriteAllBytes

最省事的写法是在 Action 里拼一句Path.Combine(root, file.FileName)然后File.WriteAllBytes。它有三个硬伤。第一,file.FileName完全来自客户端,../../appsettings.json这种值会让文件写到根目录之外,这是个真实存在的攻击面,不是理论风险。第二,WriteAllBytes要求你先把整个文件读进byte[],1GB 的文件就是 1GB 的 LOH 分配,GC 压力肉眼可见。第三,业务逻辑和存储细节绑死,将来要换成对象存储或者加一层加密,得把所有 Action 重写一遍。

我的习惯是先抽一个极薄的接口,把「存、读、定位」三件事定下来,Controller 只负责协议层的搬运。接口不要设计得太花,方法越多,后面换实现越痛苦。

// IFileStorage.cs —— 只暴露必要的三个能力 public interface IFileStorage { // 从任意可读流写入存储,返回存储键与真实长度 Task<StoredFile> SaveAsync(Stream source, string originalName, CancellationToken ct); // 打开只读流,供下载接口使用 Stream OpenRead(string storageKey); // 返回物理路径,供 PhysicalFile 直接下发(本地存储实现专用) string GetPhysicalPath(string storageKey); } public record StoredFile(string Key, string OriginalName, long Size);

这段接口的关键点在返回值和参数上。SaveAsync收的是Stream而不是IFormFile,这样单元测试可以直接喂一个MemoryStream,不依赖 HTTP 上下文;返回StoredFile而不是string,是因为存储键(内部用)和原始文件名(展示用)必须分开,前者由服务端生成,后者只做展示和入库,绝不能参与路径拼接。GetPhysicalPath是本地存储特有的妥协,如果将来换成对象存储,这个方法会退化成返回一个预签名 URL,接口形态基本不用动。

2.2 存储键设计:按月分桶,GUID 命名,扩展名保留

存储键的格式我一般定成{yyyyMM}/{32位GUID}{ext}。按月分桶是为了避免单目录堆到几十万文件,Directory.GetFiles一次扫描能把磁盘 IO 打满,运维排查时ls都卡;GUID 是为了杜绝并发覆盖和猜测;保留扩展名纯粹是为了运维时肉眼能认出来,程序本身不依赖它。

原始文件名单独存一张表或者一个 JSON 元数据文件,字段至少包含存储键、原始名、大小、SHA256、上传时间、上传人。哈希不是可选项,后面校验文件完整性和去重都要用。

2.3 Program.cs 里的三处注册,少一处就出 413

很多人以为上传大小只跟 Controller 上的特性有关,实际上限制分布在三层:Kestrel 的MaxRequestBodySize默认约 28.6MB,表单解析器的MultipartBodyLengthLimit默认 128MB,到了 IIS 还有一层maxAllowedContentLength默认 30MB。任何一层没放开,请求都会在到达你的代码之前被掐掉。

// Program.cs using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(opt => { // 单次请求体上限 512MB;比业务最大文件再留 10% 余量 opt.Limits.MaxRequestBodySize = 512L * 1024 * 1024; // 最小数据速率,防止慢速连接长期占用线程(字节/秒,10 秒宽限) opt.Limits.MinRequestBodyDataRate = new MinDataRate(100, TimeSpan.FromSeconds(10)); }); builder.Services.Configure<FormOptions>(opt => { // multipart 整体上限,必须 >= 单文件上限,否则解析阶段就抛异常 opt.MultipartBodyLengthLimit = 512L * 1024 * 1024; // 单个表单项超过 64KB 的部分落临时文件,不再吃内存 opt.MemoryBufferThreshold = 64 * 1024; }); builder.Services.AddControllers(); builder.Services.Configure<FileStorageOptions>( builder.Configuration.GetSection("FileStorage")); builder.Services.AddSingleton<IFileStorage, LocalFileStorage>(); var app = builder.Build(); app.MapControllers(); app.Run();

参数逐个说清楚。MaxRequestBodySize是 Kestrel 层硬闸,超过直接返回 413,代码里连断点都打不上,这是排查时第一个要看的地方。MultipartBodyLengthLimit是MultipartReader的闸,比 Kestrel 小的话,请求能进来但解析到一半抛InvalidDataException。MemoryBufferThreshold决定表单字段在内存里缓冲多久,超过阈值就写磁盘临时文件——注意它管的是「表单整体」的缓冲策略,不是让你把大文件塞进内存。MinDataRate属于防御性配置,弱网环境下调得太激进会误伤正常用户,100 字节/秒配 10 秒宽限是比较稳的起点。

配置项单独抽出来,别硬编码在代码里:

配置项示例值说明
FileStorage:RootPath/app/storage存储根目录绝对路径,容器内要挂卷
FileStorage:MaxFileSize536870912单文件上限,与 Kestrel 限制保持一致
FileStorage:AllowedExtensions.jpg,.png,.pdf,.zip,.mp4白名单,空表示不限制
FileStorage:ChunkTempPath/app/storage/_chunks分片临时目录,定期清理

注意:RootPath 一定要用Path.GetFullPath规范化一次再保存,否则后面做路径前缀校验时,会因为相对路径和绝对路径混用而判断失效。

3. 上传链路:64KB 缓冲线、流式落盘与分片合并的四个参数

上传这块的核心问题只有一个:怎么让内存占用和文件大小解耦。不管传 1MB 还是 1GB,进程内存曲线应该是平的。

3.1 IFormFile 的缓冲机制:64KB 是分水岭

先纠正一个常见误解。很多人以为用了IFormFile就等于把文件读进内存了,其实不是。ASP.NET Core 在解析 multipart 时,单个 section 超过MemoryBufferThreshold(默认 64KB)就会落到磁盘临时文件,IFormFile拿到的是一个指向临时文件的流式封装。所以真正的内存风险不在绑定阶段,而在你后面怎么读它——file.OpenReadStream().CopyToAsync(memoryStream)这种写法才是元凶。

这也带来一个副作用:临时文件默认写在系统临时目录,如果磁盘空间紧张或者临时目录被清理策略扫到,大文件上传会莫名失败。生产环境我一般会显式检查临时目录所在分区的可用空间,或者干脆走分片上传绕开这条路。

[ApiController] [Route("api/files")] public class FilesController : ControllerBase { private readonly IFileStorage _storage; private readonly FileStorageOptions _opt; public FilesController(IFileStorage storage, IOptions<FileStorageOptions> opt) { _storage = storage; _opt = opt.Value; } [HttpPost("upload")] // 与 Kestrel 层保持同一数量级,避免两层限制打架 [RequestSizeLimit(536870912)] [RequestFormLimits(MultipartBodyLengthLimit = 536870912)] public async Task<IActionResult> Upload( [FromForm] IFormFile file, CancellationToken ct) { if (file is null || file.Length == 0) return BadRequest(new { code = "EMPTY_FILE" }); if (file.Length > _opt.MaxFileSize) return BadRequest(new { code = "TOO_LARGE", limit = _opt.MaxFileSize }); // OpenReadStream 不复制数据,只是拿到底层流 await using var source = file.OpenReadStream(); var stored = await _storage.SaveAsync(source, file.FileName, ct); return Ok(new { key = stored.Key, name = stored.OriginalName, size = stored.Size }); } }

[RequestSizeLimit]和[RequestFormLimits]两个特性同时写的理由:前者覆盖 Kestrel 的全局设置,让单个接口可以有独立的额度;后者覆盖FormOptions,作用于这一次请求的表单解析。两个值建议保持一致,如果一个 512MB 一个 128MB,会出现「请求进来了但解析失败」的诡异现象,日志里只看到InvalidDataException,很容易怀疑到别的地方去。file.OpenReadStream()返回的是ReferenceReadStream,它不复制数据,只是对底层缓冲流套了一层引用计数,所以别在using之外的地方继续用它。

3.2 流式落盘:CopyToAsync 的缓冲区为什么给 81920

落盘这段是整个上传链路最值得抠细节的地方。缓冲区大小直接影响吞吐和内存的平衡点,我给的值是 81920 字节(80KB),这不是随手写的数字——它刚好是 .NET 内部流复制时的默认缓冲区大小,也是多数文件系统块大小的整数倍附近,能减少半块读写。

public sealed class LocalFileStorage : IFileStorage { private readonly string _root; public LocalFileStorage(IOptions<FileStorageOptions> opt) { // 规范化一次,后续所有路径比较都基于它 _root = Path.GetFullPath(opt.Value.RootPath); Directory.CreateDirectory(_root); } public async Task<StoredFile> SaveAsync( Stream source, string originalName, CancellationToken ct) { // 关键一:只取文件名部分,剥掉任何目录成分 var safeName = Path.GetFileName(originalName); if (string.IsNullOrWhiteSpace(safeName)) throw new ArgumentException("文件名非法", nameof(originalName)); var ext = Path.GetExtension(safeName).ToLowerInvariant(); var bucket = DateTime.UtcNow.ToString("yyyyMM"); var key = $"{bucket}/{Guid.NewGuid():N}{ext}"; var full = ResolveSafe(key); Directory.CreateDirectory(Path.GetDirectoryName(full)!); // 关键二:CreateNew 保证不覆盖已有文件,并发下直接抛异常而不是静默截断 await using var fs = new FileStream( full, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true); await source.CopyToAsync(fs, 81920, ct); await fs.FlushAsync(ct); return new StoredFile(key, safeName, fs.Length); } public string ResolveSafe(string key) { var full = Path.GetFullPath(Path.Combine(_root, key)); // 关键三:解析后的绝对路径必须仍在根目录之内 if (!full.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.Ordinal)) throw new InvalidOperationException("非法的存储键"); return full; } public Stream OpenRead(string storageKey) => new FileStream(ResolveSafe(storageKey), FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); public string GetPhysicalPath(string storageKey) => ResolveSafe(storageKey); }

三个关键点值得展开。FileMode.CreateNew加FileShare.None是并发安全的底线,两个请求同时写同一个键时,第二个会拿到IOException而不是把第一个截成 0 字节。useAsync: true必须显式打开,否则CopyToAsync会退化成线程池阻塞调用,高并发下线程数会飙。ResolveSafe里的前缀比较是防穿越的最后一道闸,注意要拼上Path.DirectorySeparatorChar再比,否则/app/storage-evil这种路径会被误判为在/app/storage之内。

3.3 分块上传:uploadId 与补零的分片序号

超过几百 MB 的文件,单次上传的成功率会明显下降,弱网用户重传一次就是几百 MB 流量。分片上传是标准解法:前端按固定大小切片,每片独立上传,全部到齐后服务端按序合并。

[HttpPost("chunk")] [RequestSizeLimit(8 * 1024 * 1024)] // 单片 8MB,留一点余量 public async Task<IActionResult> UploadChunk( [FromForm] string uploadId, [FromForm] int index, [FromForm] int total, [FromForm] string fileName, [FromForm] IFormFile chunk, CancellationToken ct) { // uploadId 直接参与路径拼接,必须严格校验格式 if (!Regex.IsMatch(uploadId, "^[0-9a-f]{32}$")) return BadRequest(new { code = "BAD_UPLOAD_ID" }); if (index < 0 || index >= total || total <= 0 || total > 5000) return BadRequest(new { code = "BAD_INDEX" }); if (chunk is null || chunk.Length == 0) return BadRequest(new { code = "EMPTY_CHUNK" }); var dir = Path.Combine(_chunkRoot, uploadId); Directory.CreateDirectory(dir); // 序号补零到 5 位,保证字符串排序 == 数字排序 var partPath = Path.Combine(dir, $"{index:D5}.part"); await using (var fs = new FileStream(partPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { await chunk.CopyToAsync(fs, 81920, ct); } var parts = Directory.GetFiles(dir, "*.part"); if (parts.Length < total) return Ok(new { received = parts.Length, total, done = false }); // 按文件名排序后流式拼接,全程不整读进内存 var safeName = Path.GetFileName(fileName); var bucket = DateTime.UtcNow.ToString("yyyyMM"); var key = $"{bucket}/{Guid.NewGuid():N}{Path.GetExtension(safeName).ToLowerInvariant()}"; var target = ResolveSafe(key); await using (var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { foreach (var p in parts.OrderBy(x => x, StringComparer.Ordinal)) { await using var input = new FileStream(p, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); await input.CopyToAsync(output, 81920, ct); } } Directory.Delete(dir, recursive: true); return Ok(new { key, size = new FileInfo(target).Length, done = true }); }

{index:D5}这个补零是血泪经验。分片序号如果直接写成1.part、10.part、2.part,字符串排序会得到 1、10、2,合并出来的文件是错乱的,而恰好在分片数小于 10 的测试用例里完全看不出来。uploadId用 32 位十六进制并做正则校验,是因为它会被拼进目录名,不校验就等于把路径穿越的口子开在了另一个位置。total加上限 5000,是防止有人构造超大分片数把磁盘打满。

3.4 上传参数速查

参数推荐值调整依据
Kestrel MaxRequestBodySize512MB业务最大文件 × 1.1
MultipartBodyLengthLimit与上者一致低于它会解析失败
MemoryBufferThreshold64KB低于此值内存占用上升
CopyToAsync 缓冲区81920吞吐与内存的平衡点
分片大小4MB ~ 8MB弱网下调小,内网可调大
单请求分片上限5000防止构造攻击

4. 下载链路:PhysicalFile 加 Range,把断点续传一次做对

下载的问题比上传隐蔽,因为小文件测试永远发现不了。1GB 的文件下载到 90% 断网,用户重连之后从头开始——这种体验损失很难归因到代码上。

4.1 为什么 PhysicalFile 比 byte[] 稳

File.ReadAllBytes再Ok(bytes)会把整个文件读进托管堆,1GB 文件直接触发大对象堆分配,几个并发就能把进程内存打爆。PhysicalFile走的是SendFile路径,Kestrel 会把文件句柄交给操作系统直接发送,用户态几乎不参与数据搬运。两者的内存曲线差距在一个数量级上。

[HttpGet("{key}/download")] public IActionResult Download([FromRoute] string key, [FromQuery] string? name) { string physical; try { // 内部会做前缀校验,非法键直接抛 physical = _storage.GetPhysicalPath(key); } catch (InvalidOperationException) { return BadRequest(new { code = "BAD_KEY" }); } if (!System.IO.File.Exists(physical)) return NotFound(new { code = "NOT_FOUND" }); var contentType = "application/octet-stream"; // enableRangeProcessing 打开后才会响应 Range 头并返回 206 return PhysicalFile(physical, contentType, enableRangeProcessing: true); }

enableRangeProcessing: true是断点续传的开关,默认是 false。打开之后框架会自动解析Range头、返回206 Partial Content并带上Content-Range,同时把Accept-Ranges: bytes写进响应。这个参数不是「优化项」,是「功能项」,关着就等于没有续传能力。contentType统一给application/octet-stream是保守做法,避免浏览器内联预览某些类型带来的安全问题;如果要支持图片预览,就得按扩展名映射,同时加上X-Content-Type-Options: nosniff。

4.2 Range 请求与 206:验证方式

改完之后必须验证,不能靠「浏览器能下载」判断。用 curl 发一个带 Range 的请求,看状态码和响应头:

# 请求前 1024 字节 curl -i -H "Range: bytes=0-1023" \ "https://example.internal/api/files/202401/abc123.pdf/download" # 期望响应: # HTTP/1.1 206 Partial Content # Accept-Ranges: bytes # Content-Range: bytes 0-1023/10485760 # Content-Length: 1024

判定标准有两条:状态码必须是 206 而不是 200,Content-Range的总长度必须和真实文件大小一致。如果拿到 200 但内容完整,说明enableRangeProcessing没生效;如果拿到 206 但Content-Range的总大小不对,通常是中间有代理改写了响应头,或者文件在下载过程中被替换了。第二种情况排查起来很费时间,我一般会在响应里带一个ETag(用文件哈希前 16 位),客户端下次带上If-Range,文件变了就直接返回 200 全量,避免拼出一个损坏的文件。

4.3 下载参数与响应头对照

响应头 / 参数值作用
Accept-Rangesbytes告诉客户端支持分段
Content-Rangebytes 0-1023/10485760当前片段与总长
Content-Length片段长度与 Content-Range 一致
Content-Dispositionattachment; filename*=UTF-8''...中文文件名必须用 RFC 5987 编码
ETag文件哈希前 16 位配合 If-Range 做版本校验
Cache-Controlprivate, max-age=0鉴权资源不缓存到中间层

4.4 中文文件名:Content-Disposition 的编码坑

如果直接把中文名塞进Content-Disposition: attachment; filename=报表.xlsx,部分客户端会乱码或者截断。正确做法是用filename*加 RFC 5987 编码,同时保留一个 ASCII 兜底名。用PhysicalFile的fileDownloadName重载时框架会自动处理,但如果手写响应头就得自己编码:

var encoded = Uri.EscapeDataString(fileName); Response.Headers.ContentDisposition = $"attachment; filename=\"download.bin\"; filename*=UTF-8''{encoded}";

filename*优先级高于filename,现代浏览器都认;老客户端读不到就退化成download.bin,至少不会乱码。Uri.EscapeDataString会把中文转成%E6%8A%A5...的形式,正好符合要求。

5. 避坑排查:413、路径穿越、零字节覆盖与容器挂载的五次翻车

这一章全是实际遇到过的,按「现象 → 原因 → 解决」记录,能省掉不少半夜排查的时间。

5.1 本地传 300MB 正常,部署后固定 413

现象:开发机上一切正常,发布到服务器后 100MB 以上的文件必然 413,日志里只有一行 Kestrel 的警告。

原因:多了一层宿主。IIS 的requestFiltering默认maxAllowedContentLength是 30MB,请求在到达 Kestrel 之前就被拒绝了,所以代码里完全看不到痕迹。反向代理层如果也有请求体限制,同样会在更前面拦掉。

解决:在web.config里显式放开,并把超时一并调大——大文件上传耗时容易超过默认的 2 分钟。

<system.webServer> <security> <requestFiltering> <!-- 512MB,单位是字节 --> <requestLimits maxAllowedContentLength="536870912" /> </requestFiltering> </security> <aspNetCore requestTimeout="00:20:00" /> </system.webServer>

改完记得重启应用池,web.config的改动有时需要回收才生效。排查顺序建议固定为:代理层 → 宿主 → Kestrel → FormOptions → Controller 特性,从外往里剥,别一上来就怀疑代码。

5.2 文件名里的 ../ 把文件写到了上级目录

现象:安全扫描报了一个路径穿越,复现时用一个叫../../appsettings.json的文件名上传,配置文件被覆盖,服务重启后配置全丢。

原因:Path.Combine(root, file.FileName)遇到带..的相对路径会向上跳转,Combine本身不做任何净化。

解决:三层防护一起上。第一层Path.GetFileName剥掉目录成分;第二层拼接后用Path.GetFullPath解析成绝对路径,再校验是否仍在根目录下;第三层写入用FileMode.CreateNew,即使前两层被绕过,也不会覆盖已有文件。三层里第二层最容易被写错——比较前缀时要带上目录分隔符,否则/data/storage-backup会被误判在/data/storage之内。

5.3 并发上传同名文件,先传的变成 0 字节

现象:压测时发现部分文件大小是 0,日志里没有任何异常。

原因:用了FileMode.Create加可预测的文件名(比如用原始文件名做键)。两个请求同时打开同一个路径,第二个把文件截断,第一个还在写但写在了已被截断的句柄上,最终结果取决于时序。

解决:存储键用 GUID,写入用FileMode.CreateNew+FileShare.None。这样并发的第二个请求会拿到IOException: The file already exists,可以被捕获并重试生成新键,而不是静默损坏数据。这个改动上线后再没出现过 0 字节文件。

5.4 容器重启后下载全 404

现象:本地跑得好好的,打成镜像部署后,用户反馈之前上传的文件全部 404。

原因:存储目录写在容器可写层。容器一重建,可写层随之销毁,文件自然没了。更隐蔽的一种情况是目录还在,但运行用户没有写权限,上传时抛UnauthorizedAccessException,被全局异常中间件吞成了 500。

解决:存储目录必须挂卷,配置里的RootPath指向卷内路径;同时确认容器运行用户的 UID 对卷目录有读写权限——宿主机目录属主和容器内 UID 不匹配是最常见的失败原因,表现就是「目录存在但写不进去」。

# 起容器时把存储目录挂出来,并显式指定运行用户 docker run -d --name filesvc \ -v /data/filesvc-storage:/app/storage \ -e FileStorage__RootPath=/app/storage \ -u 1654:1654 \ filesvc:latest

-u指定的是容器内运行用户,要提前在宿主机把/data/filesvc-storage的属主改成对应的 UID,否则挂载进去之后仍然不可写。这个参数不加的话,默认以镜像内定义的用户运行,权限问题会被掩盖到上线后才暴露。

5.5 下载大文件时内存曲线直线上升

现象:几个人同时下载 500MB 的文件,进程内存从 200MB 涨到 2GB 以上,偶发 OOM。

原因:下载接口用了File.ReadAllBytes或者MemoryStream中转,每个并发请求都持有一份完整的文件副本。

解决:换成PhysicalFile或者FileStreamResult,让数据从磁盘直接流向 socket。如果必须在中间做加工(比如加水印),也得用流式处理,边读边写,缓冲区控制在 64KB 到 128KB 之间,绝不能先聚合到内存再输出。

提示:判断有没有踩这个坑,最直接的办法是压测时观察进程的私有内存曲线。如果内存随并发数线性增长且下载结束后不回落,基本可以确定是全量缓冲。

5.6 上线前的一份自检清单

五条踩坑对应五个检查点,我一般按这个顺序过一遍:宿主与代理的请求体限制是否与 Kestrel 对齐;存储路径是否经过GetFullPath规范化并做了前缀校验;写入是否用了CreateNew加不可预测的键;存储目录是否在持久卷上且运行用户可写;下载接口是否开启了 Range 且没有全量缓冲。这五条任何一条缺失,都可能在某个特定文件大小或特定并发下炸出来,而且往往是在业务高峰期。

6. 再往前走一步:魔数校验、过期分片清理与一次压测复盘

把上面五章的东西拼起来,服务已经能用了,但还不能算「敢放到公网」。剩下三件事值得补:文件类型不能只信扩展名,临时分片会越堆越多,以及压测得按真实场景设计。

先看类型校验。只检查扩展名等于没检查,攻击者把.exe改成.jpg就能过。真正有效的是读文件头前几个字节比对魔数:

private static readonly Dictionary<string, byte[]> MagicNumbers = new() { [".jpg"] = new byte[] { 0xFF, 0xD8, 0xFF }, [".png"] = new byte[] { 0x89, 0x50, 0x4E, 0x47 }, [".pdf"] = new byte[] { 0x25, 0x50, 0x44, 0x46 }, // %PDF [".zip"] = new byte[] { 0x50, 0x4B, 0x03, 0x04 }, // 同时覆盖 docx/xlsx }; public static async Task<bool> MatchSignatureAsync( Stream s, string ext, CancellationToken ct) { if (!MagicNumbers.TryGetValue(ext.ToLowerInvariant(), out var magic)) return false; var head = new byte[magic.Length]; var read = await s.ReadAsync(head.AsMemory(0, head.Length), ct); s.Position = 0; // 复位,后面还要落盘 return read == magic.Length && head.AsSpan().SequenceEqual(magic); }

调用时机要卡在落盘之前、CopyToAsync之后不行——必须在写正式存储前拒绝,不能先存再删。s.Position = 0这行必须加,否则后续CopyToAsync会从第 4 个字节开始写,存出来的文件头缺失,肉眼看不出来但解析器会报损坏。注意.docx/.xlsx本质是 zip,魔数用50 4B 03 04就够了,别为每个 Office 格式单独写一套。

第二件事是清理。分片上传的中断率比想象中高——用户关页面、网络抖动、后端重启,都会留下半截分片。写一个BackgroundService,每小时扫一次_chunks目录,把最后修改时间超过 24 小时的子目录整体删掉;正式存储那边同理,配合数据库里「已删除」标记做软删加延迟物理删除,避免误删后无法恢复。清理逻辑一定要加日志,记录删了多少目录、释放多少空间,出问题时这是唯一的线索。

第三件事是压测参数。我一般按三组场景测:单文件 5MB 并发 50(模拟常规附件)、单文件 200MB 并发 5(模拟大文件)、1GB 文件分 256 片并发 8(模拟分片续传)。观察三个指标:进程私有内存峰值、磁盘写入吞吐、以及 P99 响应时间。分片场景要特别看合并阶段的耗时——如果合并是串行CopyToAsync,1GB 文件大约需要 2 到 4 秒,这段时间用户侧看到的是最后一次分片请求很慢,容易被误判成网络问题。

最后说个习惯。我早年做文件服务,图省事跳过了 Range 验证和并发写测试,结果上线第三天就出了两个问题:一个是大文件下载断了要重来,用户投诉;另一个是并发上传把文件写成了 0 字节,数据没法恢复。从那以后每次接文件上传下载的活儿,我都会强制走一遍完整的验证流程:curl 打 Range 看 206、两个终端同时传同一个文件名、把文件大小顶到配置上限的 90% 试一次、容器重启一次确认文件还在。这四步花不了十分钟,但能挡掉绝大部分上线后才发现的问题。希望帮到你。

本文还有配套的精品资源,点击获取